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 @@