school-timetracker/backend/handlers.go
Patryk Hegenberg 55b36e5e62 fix: fix while deleting timeentries for whole week
old entries have not been deleted, before new entries have been added.
This has been fixed. Also manual entries by administrators are know
protected and can only be deleted by an administrator.
2025-11-09 23:22:49 +01:00

728 lines
18 KiB
Go

package main
import (
"database/sql"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
)
type App struct {
DB *sql.DB
}
func HandleError(c echo.Context, err *AppError) error {
log.Printf("[%s] %s", err.Code, err.Error())
return c.JSON(err.HTTPStatus, err.ToResponse())
}
func getClaims(c echo.Context) (*Claims, error) {
user, ok := c.Get("user").(*jwt.Token)
if !ok {
return nil, fmt.Errorf("JWT token missing or invalid")
}
claims, ok := user.Claims.(*Claims)
if !ok {
return nil, fmt.Errorf("failed to parse JWT claims")
}
return claims, nil
}
func isDuplicateError(err error) bool {
return err != nil && (err.Error() == "UNIQUE constraint failed" ||
strings.Contains(err.Error(), "UNIQUE") ||
strings.Contains(err.Error(), "duplicate"))
}
func (app *App) LoginHandler(c echo.Context) error {
var req LoginRequest
if err := c.Bind(&req); err != nil {
return HandleError(c, ErrInvalidInputMsg("Login-Daten"))
}
if req.Username == "" {
return HandleError(c, ErrMissingFieldMsg("Benutzername"))
}
if req.Password == "" {
return HandleError(c, ErrMissingFieldMsg("Passwort"))
}
user, err := GetUserByUsername(app.DB, req.Username)
if err != nil {
if err == sql.ErrNoRows {
return HandleError(c, ErrInvalidCredentialsMsg())
}
return HandleError(c, ErrDatabaseMsg(err))
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
return HandleError(c, ErrInvalidCredentialsMsg())
}
token, err := createToken(user.ID, user.Username, user.IsAdmin)
if err != nil {
return HandleError(c, ErrInternalMsg(err))
}
response := LoginResponse{
Token: token,
Username: user.Username,
IsAdmin: user.IsAdmin,
}
return c.JSON(http.StatusOK, response)
}
func (app *App) GetSchedulesHandler(c echo.Context) error {
schedules, err := GetAllSchedules(app.DB)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
return c.JSON(http.StatusOK, schedules)
}
func (app *App) CreateScheduleHandler(c echo.Context) error {
var schedule Schedule
if err := c.Bind(&schedule); err != nil {
return HandleError(c, ErrInvalidInputMsg("Stundenplan-Daten"))
}
if schedule.StartTime == "" {
return HandleError(c, ErrMissingFieldMsg("Startzeit"))
}
if schedule.EndTime == "" {
return HandleError(c, ErrMissingFieldMsg("Endzeit"))
}
if schedule.Title == "" {
return HandleError(c, ErrMissingFieldMsg("Titel"))
}
if err := CreateSchedule(app.DB, &schedule); err != nil {
if isDuplicateError(err) {
return HandleError(c, ErrAlreadyExistsMsg("Stundenplan-Eintrag"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.JSON(http.StatusCreated, map[string]string{"message": "Stundenplan erstellt"})
}
func (app *App) DeleteScheduleHandler(c echo.Context) error {
id, err := strconv.Atoi(c.QueryParam("id"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Stundenplan-ID"))
}
if err := DeleteSchedule(app.DB, id); err != nil {
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Stundenplan"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusNoContent)
}
func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error {
hours, err := GetYearlyHoursSummary(app.DB)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
if hours == nil {
hours = []WeeklyHours{}
}
return c.JSON(http.StatusOK, hours)
}
func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error {
var req struct {
UserID int `json:"user_id"`
Date string `json:"date"`
Hours float64 `json:"hours"`
Type string `json:"type"`
}
if err := c.Bind(&req); err != nil {
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
}
if req.UserID == 0 {
return HandleError(c, ErrMissingFieldMsg("Benutzer"))
}
if req.Date == "" {
return HandleError(c, ErrMissingFieldMsg("Datum"))
}
if req.Hours == 0 {
return HandleError(c, ErrMissingFieldMsg("Stunden"))
}
entry := TimeEntry{
UserID: req.UserID,
Date: req.Date,
StartTime: "00:00",
EndTime: "00:00",
Type: "manual",
}
if err := CreateManualTimeEntry(app.DB, &entry, req.Hours); err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusNoContent)
}
func (app *App) GetUsersHandler(c echo.Context) error {
users, err := GetAllUsers(app.DB)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
if users == nil {
users = []User{}
}
return c.JSON(http.StatusOK, users)
}
func (app *App) DeleteUserHandler(c echo.Context) error {
id, err := strconv.Atoi(c.QueryParam("id"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
}
if id == 1 {
return HandleError(c, ErrProtectedUserMsg())
}
if err := DeleteUser(app.DB, id); err != nil {
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Benutzer"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusNoContent)
}
func (app *App) CreateTimeEntryHandler(c echo.Context) error {
claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
var entry TimeEntry
if err := c.Bind(&entry); err != nil {
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
}
if entry.Date == "" {
return HandleError(c, ErrMissingFieldMsg("Datum"))
}
if entry.StartTime == "" {
return HandleError(c, ErrMissingFieldMsg("Startzeit"))
}
if entry.EndTime == "" {
return HandleError(c, ErrMissingFieldMsg("Endzeit"))
}
entry.UserID = claims.UserID
if err := CreateTimeEntry(app.DB, &entry); err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteintrag erstellt"})
}
func (app *App) GetMyTimeEntriesHandler(c echo.Context) error {
claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
entries, err := GetTimeEntriesByUser(app.DB, claims.UserID)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
if entries == nil {
entries = []TimeEntry{}
}
return c.JSON(http.StatusOK, entries)
}
func (app *App) GetWeekDates(c echo.Context) error {
year, err := strconv.Atoi(c.QueryParam("year"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Jahr"))
}
week, err := strconv.Atoi(c.QueryParam("week"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Woche"))
}
if week < 1 || week > 53 {
return HandleError(c, ErrInvalidInputMsg("Woche (muss zwischen 1 und 53 liegen)"))
}
dates := calculateWeekDates(year, week)
return c.JSON(http.StatusOK, dates)
}
func (app *App) CheckWeekHasEntries(c echo.Context) error {
claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
year, err := strconv.Atoi(c.QueryParam("year"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Jahr"))
}
week, err := strconv.Atoi(c.QueryParam("week"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Woche"))
}
hasEntries, err := CheckUserHasEntriesForWeek(app.DB, claims.UserID, year, week)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
return c.JSON(http.StatusOK, map[string]bool{"has_entries": hasEntries})
}
func (app *App) GetAllTimeEntriesHandler(c echo.Context) error {
entries, err := GetAllTimeEntries(app.DB)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
if entries == nil {
entries = []TimeEntry{}
}
return c.JSON(http.StatusOK, entries)
}
func (app *App) GetWeeklyHoursHandler(c echo.Context) error {
hours, err := GetWeeklyHours(app.DB)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
if hours == nil {
hours = []WeeklyHours{}
}
return c.JSON(http.StatusOK, hours)
}
func (app *App) DeleteWeekEntries(c echo.Context) error {
claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
year, err := strconv.Atoi(c.QueryParam("year"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Jahr"))
}
week, err := strconv.Atoi(c.QueryParam("week"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Woche"))
}
if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusNoContent)
}
type WeekDates struct {
Year int `json:"year"`
Week int `json:"week"`
Dates map[string]string `json:"dates"`
Range string `json:"range"`
}
func calculateWeekDates(year, week int) WeekDates {
jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, time.UTC)
weekday := int(jan4.Weekday())
if weekday == 0 {
weekday = 7
}
daysToMonday := weekday - 1
mondayWeek1 := jan4.AddDate(0, 0, -daysToMonday)
targetMonday := mondayWeek1.AddDate(0, 0, (week-1)*7)
dates := make(map[string]string)
weekDays := []string{"0", "1", "2", "3", "4"}
var firstDate, lastDate time.Time
for i, day := range weekDays {
date := targetMonday.AddDate(0, 0, i)
dates[day] = date.Format("2006-01-02")
if i == 0 {
firstDate = date
}
if i == 4 {
lastDate = date
}
}
rangeStr := firstDate.Format("2006-01-02") + " bis " + lastDate.Format("2006-01-02")
return WeekDates{
Year: year,
Week: week,
Dates: dates,
Range: rangeStr,
}
}
type BatchTimeEntryRequest struct {
Entries []struct {
ScheduleID int `json:"schedule_id"`
Date string `json:"date"`
Type string `json:"type"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
} `json:"entries"`
}
func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error {
claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
var req BatchTimeEntryRequest
if err := c.Bind(&req); err != nil {
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
}
if len(req.Entries) == 0 {
return HandleError(c, ErrMissingFieldMsg("Zeiteinträge"))
}
if len(req.Entries) > 0 {
firstDate := req.Entries[0].Date
t, err := time.Parse("2006-01-02", firstDate)
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Datum-Format"))
}
year, week := t.ISOWeek()
if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
}
tx, err := app.DB.Begin()
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
defer tx.Rollback()
stmt, err := tx.Prepare("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)")
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
defer stmt.Close()
for _, entry := range req.Entries {
_, err := stmt.Exec(claims.UserID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
}
if err := tx.Commit(); err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteinträge erstellt"})
}
func (app *App) UpdateUserHandler(c echo.Context) error {
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
}
if userID == 1 {
return HandleError(c, ErrProtectedUserMsg())
}
var req UpdateUserRequest
if err := c.Bind(&req); err != nil {
return HandleError(c, ErrInvalidInputMsg("Benutzerdaten"))
}
if req.YearlyHours <= 0 {
return HandleError(c, ErrInvalidInputMsg("Jahresarbeitsstunden (muss positiv sein)"))
}
if err := UpdateUser(app.DB, userID, req.YearlyHours); err != nil {
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Benutzer"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusOK)
}
func (app *App) ResetPasswordHandler(c echo.Context) error {
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
}
var req ResetPasswordRequest
if err := c.Bind(&req); err != nil {
return HandleError(c, ErrInvalidInputMsg("Passwort-Daten"))
}
if len(req.NewPassword) < 6 {
return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)"))
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
return HandleError(c, ErrInternalMsg(err))
}
if err := ResetUserPassword(app.DB, userID, string(hashedPassword)); err != nil {
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Benutzer"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusOK)
}
func (app *App) UpdateTimeEntryHandler(c echo.Context) error {
entryID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID"))
}
var req UpdateTimeEntryRequest
if err := c.Bind(&req); err != nil {
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
}
if req.Date == "" {
return HandleError(c, ErrMissingFieldMsg("Datum"))
}
if req.StartTime == "" {
return HandleError(c, ErrMissingFieldMsg("Startzeit"))
}
if req.EndTime == "" {
return HandleError(c, ErrMissingFieldMsg("Endzeit"))
}
if err := UpdateTimeEntry(app.DB, entryID, req.Date, req.StartTime, req.EndTime, req.Type); err != nil {
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Zeiteintrag"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusOK)
}
func (app *App) DeleteTimeEntryHandler(c echo.Context) error {
entryID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID"))
}
if err := DeleteTimeEntry(app.DB, entryID); err != nil {
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Zeiteintrag"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusNoContent)
}
func (app *App) GetMyInfoHandler(c echo.Context) error {
claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
user, err := GetUserByID(app.DB, claims.UserID)
if err != nil {
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Benutzer"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.JSON(http.StatusOK, user)
}
func (app *App) CreateUserHandler(c echo.Context) error {
var req CreateUserRequest
if err := c.Bind(&req); err != nil {
return HandleError(c, ErrInvalidInputMsg("Benutzerdaten"))
}
if req.Username == "" {
return HandleError(c, ErrMissingFieldMsg("Benutzername"))
}
if req.Password == "" {
return HandleError(c, ErrMissingFieldMsg("Passwort"))
}
if len(req.Password) < 6 {
return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)"))
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return HandleError(c, ErrInternalMsg(err))
}
if req.YearlyHours == 0 {
req.YearlyHours = 60.0
}
if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.YearlyHours); err != nil {
if isDuplicateError(err) {
return HandleError(c, ErrAlreadyExistsMsg("Benutzername"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusCreated)
}
func (app *App) GetSchoolYearsHandler(c echo.Context) error {
years, err := GetAllSchoolYears(app.DB)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
if years == nil {
years = []SchoolYear{}
}
return c.JSON(http.StatusOK, years)
}
func (app *App) CreateSchoolYearHandler(c echo.Context) error {
var req CreateSchoolYearRequest
if err := c.Bind(&req); err != nil {
return HandleError(c, ErrInvalidInputMsg("Schuljahr-Daten"))
}
if req.Name == "" {
return HandleError(c, ErrMissingFieldMsg("Name"))
}
if req.StartDate == "" {
return HandleError(c, ErrMissingFieldMsg("Startdatum"))
}
if req.EndDate == "" {
return HandleError(c, ErrMissingFieldMsg("Enddatum"))
}
if err := CreateSchoolYear(app.DB, req.Name, req.StartDate, req.EndDate); err != nil {
if isDuplicateError(err) {
return HandleError(c, ErrAlreadyExistsMsg("Schuljahr"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusCreated)
}
func (app *App) SetActiveSchoolYearHandler(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Schuljahr-ID"))
}
if err := SetActiveSchoolYear(app.DB, id); err != nil {
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Schuljahr"))
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusNoContent)
}
func (app *App) GetActiveSchoolYearHandler(c echo.Context) error {
year, err := GetActiveSchoolYear(app.DB)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
if year == nil {
return c.JSON(http.StatusOK, map[string]any{"active": false})
}
return c.JSON(http.StatusOK, year)
}
func (app *App) GenerateYearlySummaryPDFHandler(c echo.Context) error {
schoolYear, err := GetActiveSchoolYear(app.DB)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
if schoolYear == nil {
return HandleError(c, ErrNoActiveSchoolYearMsg())
}
summary, err := GetYearlyHoursSummary(app.DB)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
pdfBytes, err := GenerateYearlySummaryPDF(schoolYear, summary)
if err != nil {
return HandleError(c, ErrInternalMsg(err))
}
filename := fmt.Sprintf("Jahresuebersicht_%s.pdf", schoolYear.Name)
c.Response().Header().Set("Content-Type", "application/pdf")
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
return c.Blob(http.StatusOK, "application/pdf", pdfBytes)
}
func (app *App) DeleteSchoolYearHandler(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Schuljahr-ID"))
}
if err := DeleteSchoolYear(app.DB, id); err != nil {
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Schuljahr"))
}
if err.Error() == "cannot delete active school year" {
return HandleError(c, &AppError{
Code: "CANNOT_DELETE_ACTIVE_SCHOOL_YEAR",
Message: "Aktives Schuljahr kann nicht gelöscht werden",
HTTPStatus: http.StatusBadRequest,
})
}
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusNoContent)
}