From 3e2b6d46e6bddf92dae56e36f9f06c4c1f12da1f Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Fri, 16 Jan 2026 13:03:01 +0100 Subject: [PATCH] feat: first test implementation for substitution system --- backend/database.go | 123 +++- backend/go.mod | 4 +- backend/handlers.go | 113 +++ backend/license.go | 72 ++ backend/main.go | 6 + backend/models.go | 40 ++ frontend/src/components/AdminDashboard.svelte | 31 + frontend/src/components/UserDashboard.svelte | 651 +++++++++++------- .../components/admin/AdminSettingsTab.svelte | 80 ++- .../admin/AdminSubstitutionsTab.svelte | 144 ++++ frontend/src/lib/api.js | 33 + 11 files changed, 1048 insertions(+), 249 deletions(-) create mode 100644 backend/license.go create mode 100644 frontend/src/components/admin/AdminSubstitutionsTab.svelte diff --git a/backend/database.go b/backend/database.go index 0569420..42d1229 100644 --- a/backend/database.go +++ b/backend/database.go @@ -99,6 +99,17 @@ func createTables(db *sql.DB) { is_active BOOLEAN NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, + `CREATE TABLE IF NOT EXISTS substitutions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + title TEXT NOT NULL, + notes TEXT, + taken_by_user_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(taken_by_user_id) REFERENCES users(id) + )`, } for _, query := range queries { @@ -114,6 +125,7 @@ func createIndexes(db *sql.DB) { "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)", "CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)", "CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)", + `CREATE INDEX IF NOT EXISTS idx_substitutions_date ON substitutions(date)`, } for _, idx := range indexes { db.Exec(idx) @@ -577,15 +589,16 @@ func CreateManualTimeEntry(db *sql.DB, entry *TimeEntry, hours float64) error { } func calculateHours(entry TimeEntry) float64 { - if entry.Type == "lesson" { + switch entry.Type { + case "lesson": return 1.0 - } else if entry.Type == "manual" { + case "manual": hours, err := strconv.ParseFloat(entry.StartTime, 64) if err != nil { return 0 } return hours - } else { + default: return calculateHoursDiff(entry.StartTime, entry.EndTime) } } @@ -633,3 +646,107 @@ func DeleteNonManualTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, w _, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4]) return err } + +func CreateSubstitution(db *sql.DB, date, start, end, title, notes string) error { + _, err := db.Exec(` + INSERT INTO substitutions (date, start_time, end_time, title, notes) + VALUES (?, ?, ?, ?, ?) + `, date, start, end, title, notes) + return err +} + +func GetOpenSubstitutions(db *sql.DB) ([]Substitution, error) { + rows, err := db.Query(` + SELECT id, date, start_time, end_time, title, notes, created_at + FROM substitutions + WHERE taken_by_user_id IS NULL + AND date >= date('now') + ORDER BY date ASC, start_time ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var subs []Substitution + for rows.Next() { + var s Substitution + if err := rows.Scan(&s.ID, &s.Date, &s.StartTime, &s.EndTime, &s.Title, &s.Notes, &s.CreatedAt); err != nil { + continue + } + subs = append(subs, s) + } + return subs, nil +} + +func AcceptSubstitution(db *sql.DB, substitutionID int, userID int) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + var currentDate, start, end, title string + err = tx.QueryRow(` + SELECT date, start_time, end_time, title + FROM substitutions + WHERE id = ? AND taken_by_user_id IS NULL + `, substitutionID).Scan(¤tDate, &start, &end, &title) + + if err == sql.ErrNoRows { + return fmt.Errorf("Vertretung wurde bereits vergeben oder existiert nicht") + } + if err != nil { + return err + } + + _, err = tx.Exec(` + UPDATE substitutions + SET taken_by_user_id = ? + WHERE id = ? + `, userID, substitutionID) + if err != nil { + return err + } + + _, err = tx.Exec(` + INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) + VALUES (?, 0, ?, 'lesson', ?, ?) + `, userID, currentDate, start, end) + if err != nil { + return err + } + + return tx.Commit() +} + +func GetAllSubstitutions(db *sql.DB) ([]Substitution, error) { + rows, err := db.Query(` + SELECT s.id, s.date, s.start_time, s.end_time, s.title, s.notes, s.taken_by_user_id, u.username + FROM substitutions s + LEFT JOIN users u ON s.taken_by_user_id = u.id + ORDER BY s.date DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var subs []Substitution + for rows.Next() { + var s Substitution + var takenID sql.NullInt64 + var takenName sql.NullString + + if err := rows.Scan(&s.ID, &s.Date, &s.StartTime, &s.EndTime, &s.Title, &s.Notes, &takenID, &takenName); err != nil { + continue + } + if takenID.Valid { + id := int(takenID.Int64) + s.TakenByUserID = &id + s.TakenByUsername = takenName.String + } + subs = append(subs, s) + } + return subs, nil +} diff --git a/backend/go.mod b/backend/go.mod index 2a1d344..76a4aed 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,7 +3,9 @@ module school-timetracker go 1.25.3 require ( + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/jung-kurt/gofpdf v1.16.2 + github.com/labstack/echo-jwt/v4 v4.3.1 github.com/labstack/echo/v4 v4.13.4 golang.org/x/crypto v0.43.0 golang.org/x/time v0.11.0 @@ -12,9 +14,7 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/labstack/echo-jwt/v4 v4.3.1 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/backend/handlers.go b/backend/handlers.go index 55f8684..31c2b97 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -71,6 +71,16 @@ func (app *App) LoginHandler(c echo.Context) error { return HandleError(c, ErrInvalidCredentialsMsg()) } + if !user.IsAdmin { + _, err := VerifyLicenseFile() + if err != nil { + return c.JSON(http.StatusForbidden, map[string]string{ + "error": "Lizenzfehler: " + err.Error(), + "code": "LICENSE_INVALID", + }) + } + + } token, err := createToken(user.ID, user.Username, user.IsAdmin) if err != nil { return HandleError(c, ErrInternalMsg(err)) @@ -799,3 +809,106 @@ func (app *App) UploadLogoHandler(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"message": "Logo erfolgreich hochgeladen"}) } + +func (app *App) GetLicenseStatusHandler(c echo.Context) error { + var count int + app.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + + status := GetCurrentLicenseStatus(nil) + status.UserCount = count + + if status.IsValid && status.MaxUsers > 0 && count > status.MaxUsers { + status.IsValid = false + status.Message = fmt.Sprintf("Benutzerlimit überschritten (%d / %d)", count, status.MaxUsers) + } + + return c.JSON(http.StatusOK, status) +} + +func (app *App) UploadLicenseHandler(c echo.Context) error { + file, err := c.FormFile("license") + if err != nil { + return HandleError(c, ErrInvalidInputMsg("Keine Datei")) + } + + src, err := file.Open() + if err != nil { + return HandleError(c, ErrInternalMsg(err)) + } + defer src.Close() + + dst, err := os.Create("license.lic") + if err != nil { + return HandleError(c, ErrInternalMsg(err)) + } + defer dst.Close() + + if _, err = io.Copy(dst, src); err != nil { + return HandleError(c, ErrInternalMsg(err)) + } + + if _, err := VerifyLicenseFile(); err != nil { + return c.JSON(http.StatusOK, map[string]string{"message": "Lizenz hochgeladen, aber ungültig: " + err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]string{"message": "Lizenz erfolgreich aktiviert"}) +} + +func (app *App) CreateSubstitutionHandler(c echo.Context) error { + var req CreateSubstitutionRequest + if err := c.Bind(&req); err != nil { + return HandleError(c, ErrInvalidInputMsg("Eingabedaten")) + } + if req.Date == "" || req.StartTime == "" || req.Title == "" { + return HandleError(c, ErrMissingFieldMsg("Pflichtfelder")) + } + + if err := CreateSubstitution(app.DB, req.Date, req.StartTime, req.EndTime, req.Title, req.Notes); err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + + return c.JSON(http.StatusCreated, map[string]string{"message": "Vertretung ausgeschrieben"}) +} + +func (app *App) GetAllSubstitutionsHandler(c echo.Context) error { + subs, err := GetAllSubstitutions(app.DB) + if err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + if subs == nil { + subs = []Substitution{} + } + return c.JSON(http.StatusOK, subs) +} + +func (app *App) GetOpenSubstitutionsHandler(c echo.Context) error { + subs, err := GetOpenSubstitutions(app.DB) + if err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + if subs == nil { + subs = []Substitution{} + } + return c.JSON(http.StatusOK, subs) +} + +func (app *App) AcceptSubstitutionHandler(c echo.Context) error { + claims, err := getClaims(c) + if err != nil { + return HandleError(c, ErrUnauthorizedMsg()) + } + + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return HandleError(c, ErrInvalidInputMsg("ID")) + } + + if err := AcceptSubstitution(app.DB, id, claims.UserID); err != nil { + if err.Error() == "Vertretung wurde bereits vergeben oder existiert nicht" { + return HandleError(c, ErrAlreadyExistsMsg("Diese Vertretung ist leider schon vergeben")) + } + return HandleError(c, ErrDatabaseMsg(err)) + } + + return c.JSON(http.StatusOK, map[string]string{"message": "Vertretung erfolgreich übernommen!"}) +} diff --git a/backend/license.go b/backend/license.go new file mode 100644 index 0000000..9882f25 --- /dev/null +++ b/backend/license.go @@ -0,0 +1,72 @@ +package main + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "time" +) + +const PublicKeyHex = "ab8287380d4f26b66b3e8067e179a2e304dcb3c4070963edd213cca9b225978f" + +func VerifyLicenseFile() (*LicenseData, error) { + bytes, err := os.ReadFile("license.lic") + if err != nil { + return nil, fmt.Errorf("Keine Lizenzdatei gefunden") + } + + var lic LicenseFile + if err := json.Unmarshal(bytes, &lic); err != nil { + return nil, fmt.Errorf("Lizenzdatei beschädigt") + } + + pubBytes, _ := hex.DecodeString(PublicKeyHex) + publicKey := ed25519.PublicKey(pubBytes) + + dataBytes, _ := json.Marshal(lic.Data) + sigBytes, err := base64.StdEncoding.DecodeString(lic.Signature) + if err != nil { + return nil, fmt.Errorf("Signatur ungültig") + } + + if !ed25519.Verify(publicKey, dataBytes, sigBytes) { + return nil, fmt.Errorf("Lizenz-Signatur ungültig (Manipuliert?)") + } + + expiry, err := time.Parse("2006-01-02", lic.Data.ExpiresAt) + if err != nil { + return nil, fmt.Errorf("Ungültiges Datumsformat") + } + if time.Now().After(expiry) { + return &lic.Data, fmt.Errorf("Lizenz abgelaufen am %s", lic.Data.ExpiresAt) + } + + return &lic.Data, nil +} + +func GetCurrentLicenseStatus(db *any) LicenseStatus { + lic, err := VerifyLicenseFile() + status := LicenseStatus{ + IsValid: err == nil, + Message: "Gültig", + } + + if err != nil { + status.Message = err.Error() + if lic != nil { + status.SchoolName = lic.SchoolName + status.ExpiresAt = lic.ExpiresAt + status.MaxUsers = lic.MaxUsers + } + return status + } + + status.SchoolName = lic.SchoolName + status.ExpiresAt = lic.ExpiresAt + status.MaxUsers = lic.MaxUsers + + return status +} diff --git a/backend/main.go b/backend/main.go index c7faa85..0cc162e 100644 --- a/backend/main.go +++ b/backend/main.go @@ -70,6 +70,8 @@ func main() { protected.GET("/my-info", app.GetMyInfoHandler) protected.POST("/change-password", app.ChangeMyPasswordHandler) protected.GET("/school-year/active", app.GetActiveSchoolYearHandler) + protected.GET("/substitutions/open", app.GetOpenSubstitutionsHandler) + protected.POST("/substitutions/:id/accept", app.AcceptSubstitutionHandler) } admin := e.Group("/api/admin") @@ -94,6 +96,10 @@ func main() { admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler) admin.GET("/yearly-summary/pdf", app.GenerateYearlySummaryPDFHandler) admin.POST("/settings/logo", app.UploadLogoHandler) + admin.GET("/settings/license", app.GetLicenseStatusHandler) + admin.POST("/settings/license", app.UploadLicenseHandler) + admin.GET("/substitutions", app.GetAllSubstitutionsHandler) + admin.POST("/substitutions", app.CreateSubstitutionHandler) } distDir, err := fs.Sub(frontendDist, "dist") diff --git a/backend/models.go b/backend/models.go index 8cf5c6b..4e2f2e8 100644 --- a/backend/models.go +++ b/backend/models.go @@ -106,3 +106,43 @@ type ChangePasswordRequest struct { OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` } + +type LicenseData struct { + SchoolName string `json:"school_name"` + MaxUsers int `json:"max_users"` + ExpiresAt string `json:"expires_at"` +} + +type LicenseFile struct { + Data LicenseData `json:"data"` + Signature string `json:"signature"` +} + +type LicenseStatus struct { + IsValid bool `json:"is_valid"` + SchoolName string `json:"school_name"` + ExpiresAt string `json:"expires_at"` + MaxUsers int `json:"max_users"` + UserCount int `json:"user_count"` + Message string `json:"message"` +} + +type Substitution struct { + ID int `json:"id"` + Date string `json:"date"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Title string `json:"title"` + Notes string `json:"notes"` + TakenByUserID *int `json:"taken_by_user_id,omitempty"` + TakenByUsername string `json:"taken_by_username,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type CreateSubstitutionRequest struct { + Date string `json:"date" validate:"required"` + StartTime string `json:"start_time" validate:"required"` + EndTime string `json:"end_time" validate:"required"` + Title string `json:"title" validate:"required"` + Notes string `json:"notes"` +} diff --git a/frontend/src/components/AdminDashboard.svelte b/frontend/src/components/AdminDashboard.svelte index ba77bf1..82405cf 100644 --- a/frontend/src/components/AdminDashboard.svelte +++ b/frontend/src/components/AdminDashboard.svelte @@ -7,6 +7,7 @@ import AdminTimeEntriesTab from "./admin/AdminTimeEntriesTab.svelte"; import AdminSchoolYearsTab from "./admin/AdminSchoolYearsTab.svelte"; import AdminSettingsTab from "./admin/AdminSettingsTab.svelte"; + import AdminSubstitutionsTab from "./admin/AdminSubstitutionsTab.svelte"; let activeTab = "schedule"; const user = $auth.user; @@ -25,6 +26,8 @@ return "Schuljahre & Perioden"; case "settings": return "Einstellungen"; + case "substitutions": + return "Vertretungen"; default: return "Admin"; } @@ -121,6 +124,8 @@ {:else if activeTab === "settings"} + {:else if activeTab === "substitutions"} + {/if} @@ -277,6 +282,32 @@ Schuljahre +
  • + +