feat: first test implementation for substitution system

This commit is contained in:
Patryk Hegenberg 2026-01-16 13:03:01 +01:00
parent 8fe3d71dde
commit 3e2b6d46e6
11 changed files with 1048 additions and 249 deletions

View file

@ -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(&currentDate, &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
}

View file

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

View file

@ -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!"})
}

72
backend/license.go Normal file
View file

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

View file

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

View file

@ -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"`
}