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 := DeleteTimeEntriesByUserAndWeek(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")) } 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) }