Files
breakpilot-lehrer/school-service/cmd/server/main.go
T
Benjamin Admin 8311b33fb3
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 1m10s
CI / test-go-edu-search (push) Successful in 43s
CI / test-python-klausur (push) Failing after 4m4s
CI / test-python-agent-core (push) Successful in 44s
CI / test-nodejs-website (push) Successful in 51s
Phase 9d: Notification cron + multilingual templates + status badges
Backend (school-service):
  - notification_log table with UNIQUE(event_id, lead_days, audience,
    channel) for idempotent re-runs. Status enum sent/failed/skipped.
  - internal/notifications/templates.go: per-event-type × audience ×
    lead-day-bucket × language templates in 8 languages (de/en/tr/ar/
    uk/ru/pl/fr). Fallback chain (lang→de, eventType→andere) so we
    never miss a render.
  - service.go scans cal_school_event for events whose
    (start_date - runDate) appears in notification_lead_days. For each
    due (audience, channel) tuple it dispatches via POST to the
    Matrix/Email upstreams owned by the colleague's services.
    Empty URL → status='skipped', logged for visibility.
  - dispatcher.go handles the POST, parent-recipient lookup (joins
    parent_account + parent_child + cal_school_event.affected_class_ids),
    and writeLog with the unique constraint dropping duplicate runs.
  - main.go runs a 1-hour ticker; when time.Hour()==6 it invokes the
    scanner for today. Idempotent so transient restarts don't double-
    send.
  - POST /calendar/notifications/run-now for manual trigger + backfill
    (?date=YYYY-MM-DD).
  - GET /calendar/events/:id/notifications returns notification_log
    rows scoped to the owning teacher.
  - MATRIX_SERVICE_URL + EMAIL_SERVICE_URL env vars added (default
    empty = stub mode).

Frontend (studio-v2):
  - NotificationStatus component fetches /events/:id/notifications and
    renders coloured badges per (lead, audience, channel, status).
  - DayDetail mounts NotificationStatus inside each event card when
    notify_parents or notify_students is set.

Tests:
  - 6 new Go unit tests for bucketFor + Render (de/tr/fallback paths)
    + substitute(class_suffix). 89 subtests gesamt.
  - 2 new Playwright tests: badge render with mocked log, hidden when
    notifications are off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:12:39 +02:00

320 lines
14 KiB
Go

package main
import (
"context"
"log"
"os"
"time"
"github.com/breakpilot/school-service/internal/config"
"github.com/breakpilot/school-service/internal/database"
"github.com/breakpilot/school-service/internal/handlers"
"github.com/breakpilot/school-service/internal/middleware"
"github.com/gin-gonic/gin"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Set Gin mode based on environment
if cfg.Environment == "production" {
gin.SetMode(gin.ReleaseMode)
}
// Connect to database
db, err := database.Connect(cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer db.Close()
// Run migrations
if err := database.Migrate(db); err != nil {
log.Fatalf("Failed to run migrations: %v", err)
}
// Create handler
handler := handlers.NewHandler(db.Pool, cfg.LLMGatewayURL, cfg.SolverServiceURL, cfg.MatrixServiceURL, cfg.EmailServiceURL)
// Phase 9d: daily notification cron. Ticks every hour and runs the
// scanner once when the current hour == 6. Idempotent via the
// notification_log UNIQUE constraint, so multiple ticks the same day
// are safe.
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
now := time.Now()
if now.Hour() == 6 {
res, err := handler.NotificationService().RunForDate(context.Background(), now)
if err != nil {
log.Printf("notification cron error: %v", err)
} else {
log.Printf("notification cron: %+v", res)
}
}
<-ticker.C
}
}()
// Calendar seed — idempotent, runs every boot. Snapshot path is bundled
// in the Docker image at /app/internal/seed/calendar_holidays.json. Failures
// don't block startup; the holiday table is filled lazily next boot.
go func() {
seedPath := "internal/seed/calendar_holidays.json"
if _, err := os.Stat(seedPath); err != nil {
seedPath = "/app/internal/seed/calendar_holidays.json"
}
if err := handler.CalendarService().SeedFromSnapshot(context.Background(), seedPath); err != nil {
log.Printf("calendar seed failed: %v", err)
}
}()
// Create router
router := gin.New()
router.Use(gin.Recovery())
router.Use(middleware.RequestLogger())
router.Use(middleware.CORS())
router.Use(middleware.RateLimiter())
// Health endpoint (no auth required)
router.GET("/health", handler.Health)
// API routes (auth required)
api := router.Group("/api/v1/school")
api.Use(middleware.AuthMiddleware(cfg.JWTSecret, cfg.Environment != "production"))
{
// School Years
api.GET("/years", handler.GetSchoolYears)
api.POST("/years", handler.CreateSchoolYear)
// Classes
api.GET("/classes", handler.GetClasses)
api.POST("/classes", handler.CreateClass)
api.GET("/classes/:id", handler.GetClass)
api.DELETE("/classes/:id", handler.DeleteClass)
// Students (nested under classes)
api.GET("/classes/:id/students", handler.GetStudents)
api.POST("/classes/:id/students", handler.CreateStudent)
api.POST("/classes/:id/students/import", handler.ImportStudents)
api.DELETE("/classes/:id/students/:studentId", handler.DeleteStudent)
// Subjects
api.GET("/subjects", handler.GetSubjects)
api.POST("/subjects", handler.CreateSubject)
api.DELETE("/subjects/:id", handler.DeleteSubject)
// Exams
api.GET("/exams", handler.GetExams)
api.POST("/exams", handler.CreateExam)
api.GET("/exams/:id", handler.GetExam)
api.PUT("/exams/:id", handler.UpdateExam)
api.DELETE("/exams/:id", handler.DeleteExam)
api.POST("/exams/:id/generate-variant", handler.GenerateExamVariant)
api.GET("/exams/:id/results", handler.GetExamResults)
api.POST("/exams/:id/results", handler.SaveExamResults)
api.PUT("/exams/:id/results/:studentId/approve", handler.ApproveExamResult)
api.GET("/exams/:id/needs-rewrite", handler.GetStudentsNeedingRewrite)
// Grades
api.GET("/grades/:classId", handler.GetClassGrades)
api.GET("/grades/student/:studentId", handler.GetStudentGrades)
api.PUT("/grades/:studentId/:subjectId/oral", handler.UpdateOralGrade)
api.POST("/grades/calculate", handler.CalculateFinalGrades)
api.POST("/grades/transfer", handler.TransferApprovedGrades)
api.PUT("/grades/:studentId/:subjectId/lock", handler.LockFinalGrade)
api.PUT("/grades/:studentId/:subjectId/weights", handler.UpdateGradeWeights)
// Statistics
api.GET("/statistics/:classId", handler.GetClassStatistics)
api.GET("/statistics/:classId/subject/:subjectId", handler.GetSubjectStatistics)
api.GET("/statistics/student/:studentId", handler.GetStudentStatistics)
api.GET("/statistics/:classId/notenspiegel", handler.GetNotenspiegel)
// Attendance
api.GET("/attendance/:classId", handler.GetClassAttendance)
api.GET("/attendance/student/:studentId", handler.GetStudentAttendance)
api.POST("/attendance", handler.CreateAttendance)
api.POST("/attendance/:classId/bulk", handler.BulkCreateAttendance)
api.DELETE("/attendance/:id", handler.DeleteAttendance)
// Gradebook Entries
api.GET("/gradebook/:classId", handler.GetGradebookEntries)
api.GET("/gradebook/student/:studentId", handler.GetStudentEntries)
api.POST("/gradebook", handler.CreateGradebookEntry)
api.DELETE("/gradebook/:id", handler.DeleteGradebookEntry)
// Certificates
api.GET("/certificates/templates", handler.GetCertificateTemplates)
api.GET("/certificates/class/:classId", handler.GetClassCertificates)
api.GET("/certificates/feedback/:studentId", handler.GenerateGradeFeedback)
api.POST("/certificates/generate", handler.GenerateCertificate)
api.POST("/certificates/generate-bulk", handler.BulkGenerateCertificates)
api.GET("/certificates/detail/:id", handler.GetCertificate)
api.PUT("/certificates/detail/:id", handler.UpdateCertificate)
api.PUT("/certificates/detail/:id/finalize", handler.FinalizeCertificate)
api.GET("/certificates/detail/:id/pdf", handler.GetCertificatePDF)
api.DELETE("/certificates/detail/:id", handler.DeleteCertificate)
// Timetable Scheduler — Stammdaten
api.GET("/timetable/classes", handler.ListTimetableClasses)
api.POST("/timetable/classes", handler.CreateTimetableClass)
api.DELETE("/timetable/classes/:id", handler.DeleteTimetableClass)
api.GET("/timetable/periods", handler.ListTimetablePeriods)
api.POST("/timetable/periods", handler.CreateTimetablePeriod)
api.DELETE("/timetable/periods/:id", handler.DeleteTimetablePeriod)
api.GET("/timetable/rooms", handler.ListTimetableRooms)
api.POST("/timetable/rooms", handler.CreateTimetableRoom)
api.DELETE("/timetable/rooms/:id", handler.DeleteTimetableRoom)
api.GET("/timetable/subjects", handler.ListTimetableSubjects)
api.POST("/timetable/subjects", handler.CreateTimetableSubject)
api.DELETE("/timetable/subjects/:id", handler.DeleteTimetableSubject)
api.GET("/timetable/teachers", handler.ListTimetableTeachers)
api.POST("/timetable/teachers", handler.CreateTimetableTeacher)
api.DELETE("/timetable/teachers/:id", handler.DeleteTimetableTeacher)
// Timetable Scheduler — Relations
api.GET("/timetable/curriculum", handler.ListTimetableCurriculum)
api.POST("/timetable/curriculum", handler.CreateTimetableCurriculum)
api.DELETE("/timetable/curriculum/:id", handler.DeleteTimetableCurriculum)
api.GET("/timetable/assignments", handler.ListTimetableAssignments)
api.POST("/timetable/assignments", handler.CreateTimetableAssignment)
api.DELETE("/timetable/assignments/:id", handler.DeleteTimetableAssignment)
// Timetable Scheduler — Constraints (15 typed tables)
// Teacher
api.GET("/timetable/constraints/teacher/unavailable-day", handler.ListTeacherUnavailableDays)
api.POST("/timetable/constraints/teacher/unavailable-day", handler.CreateTeacherUnavailableDay)
api.DELETE("/timetable/constraints/teacher/unavailable-day/:id", handler.DeleteTeacherUnavailableDay)
api.GET("/timetable/constraints/teacher/unavailable-window", handler.ListTeacherUnavailableWindows)
api.POST("/timetable/constraints/teacher/unavailable-window", handler.CreateTeacherUnavailableWindow)
api.DELETE("/timetable/constraints/teacher/unavailable-window/:id", handler.DeleteTeacherUnavailableWindow)
api.GET("/timetable/constraints/teacher/max-hours-day", handler.ListTeacherMaxHoursDay)
api.POST("/timetable/constraints/teacher/max-hours-day", handler.CreateTeacherMaxHoursDay)
api.DELETE("/timetable/constraints/teacher/max-hours-day/:id", handler.DeleteTeacherMaxHoursDay)
api.GET("/timetable/constraints/teacher/max-hours-week", handler.ListTeacherMaxHoursWeek)
api.POST("/timetable/constraints/teacher/max-hours-week", handler.CreateTeacherMaxHoursWeek)
api.DELETE("/timetable/constraints/teacher/max-hours-week/:id", handler.DeleteTeacherMaxHoursWeek)
api.GET("/timetable/constraints/teacher/excluded-subject", handler.ListTeacherExcludedSubjects)
api.POST("/timetable/constraints/teacher/excluded-subject", handler.CreateTeacherExcludedSubject)
api.DELETE("/timetable/constraints/teacher/excluded-subject/:id", handler.DeleteTeacherExcludedSubject)
api.GET("/timetable/constraints/teacher/excluded-room", handler.ListTeacherExcludedRooms)
api.POST("/timetable/constraints/teacher/excluded-room", handler.CreateTeacherExcludedRoom)
api.DELETE("/timetable/constraints/teacher/excluded-room/:id", handler.DeleteTeacherExcludedRoom)
// Subject
api.GET("/timetable/constraints/subject/min-day-gap", handler.ListSubjectMinDayGaps)
api.POST("/timetable/constraints/subject/min-day-gap", handler.CreateSubjectMinDayGap)
api.DELETE("/timetable/constraints/subject/min-day-gap/:id", handler.DeleteSubjectMinDayGap)
api.GET("/timetable/constraints/subject/max-consecutive", handler.ListSubjectMaxConsecutives)
api.POST("/timetable/constraints/subject/max-consecutive", handler.CreateSubjectMaxConsecutive)
api.DELETE("/timetable/constraints/subject/max-consecutive/:id", handler.DeleteSubjectMaxConsecutive)
api.GET("/timetable/constraints/subject/contiguous-when-repeated", handler.ListSubjectContiguousWhenRepeated)
api.POST("/timetable/constraints/subject/contiguous-when-repeated", handler.CreateSubjectContiguousWhenRepeated)
api.DELETE("/timetable/constraints/subject/contiguous-when-repeated/:id", handler.DeleteSubjectContiguousWhenRepeated)
api.GET("/timetable/constraints/subject/preferred-period", handler.ListSubjectPreferredPeriods)
api.POST("/timetable/constraints/subject/preferred-period", handler.CreateSubjectPreferredPeriod)
api.DELETE("/timetable/constraints/subject/preferred-period/:id", handler.DeleteSubjectPreferredPeriod)
api.GET("/timetable/constraints/subject/double-lesson", handler.ListSubjectDoubleLessons)
api.POST("/timetable/constraints/subject/double-lesson", handler.CreateSubjectDoubleLesson)
api.DELETE("/timetable/constraints/subject/double-lesson/:id", handler.DeleteSubjectDoubleLesson)
// Class
api.GET("/timetable/constraints/class/max-hours-day", handler.ListClassMaxHoursDay)
api.POST("/timetable/constraints/class/max-hours-day", handler.CreateClassMaxHoursDay)
api.DELETE("/timetable/constraints/class/max-hours-day/:id", handler.DeleteClassMaxHoursDay)
api.GET("/timetable/constraints/class/no-gaps", handler.ListClassNoGaps)
api.POST("/timetable/constraints/class/no-gaps", handler.CreateClassNoGaps)
api.DELETE("/timetable/constraints/class/no-gaps/:id", handler.DeleteClassNoGaps)
// Room
api.GET("/timetable/constraints/room/requires-type", handler.ListRoomRequiresTypes)
api.POST("/timetable/constraints/room/requires-type", handler.CreateRoomRequiresType)
api.DELETE("/timetable/constraints/room/requires-type/:id", handler.DeleteRoomRequiresType)
api.GET("/timetable/constraints/room/unavailable", handler.ListRoomUnavailable)
api.POST("/timetable/constraints/room/unavailable", handler.CreateRoomUnavailable)
api.DELETE("/timetable/constraints/room/unavailable/:id", handler.DeleteRoomUnavailable)
// Timetable Solver — Solutions
api.GET("/timetable/solutions", handler.ListTimetableSolutions)
api.POST("/timetable/solutions", handler.CreateTimetableSolution)
api.GET("/timetable/solutions/:id", handler.GetTimetableSolution)
api.DELETE("/timetable/solutions/:id", handler.DeleteTimetableSolution)
api.GET("/timetable/solutions/:id/lessons", handler.ListTimetableLessons)
// Phase 7: pin/unpin individual lessons for the next re-solve.
api.PUT("/timetable/lessons/:id/pin", handler.UpdateTimetableLessonPin)
// Phase 8: exports.
api.GET("/timetable/solutions/:id/export.csv", handler.ExportTimetableSolutionCSV)
api.GET("/timetable/solutions/:id/export.ics", handler.ExportTimetableSolutionICS)
// Phase 9a: Schulkalender (holidays + per-user Bundesland config).
api.GET("/calendar/holidays", handler.ListCalendarHolidays)
api.GET("/calendar/config", handler.GetCalendarConfig)
api.PUT("/calendar/config", handler.UpsertCalendarConfig)
// Phase 9b: school-events CRUD + Schuljahres-Rollover.
api.GET("/calendar/events", handler.ListSchoolEvents)
api.POST("/calendar/events", handler.CreateSchoolEvent)
api.DELETE("/calendar/events/:id", handler.DeleteSchoolEvent)
api.POST("/calendar/school-year-rollover", handler.RolloverSchoolYear)
// Phase 9c: parent invitations (teacher side).
api.GET("/calendar/parents", handler.ListParentInvites)
api.POST("/calendar/parents/invite", handler.InviteParent)
api.DELETE("/calendar/parents/children/:id", handler.DeleteParentInvite)
// Phase 9d: notifications.
api.POST("/calendar/notifications/run-now", handler.RunNotificationsNow)
api.GET("/calendar/events/:id/notifications", handler.ListEventNotifications)
}
// Phase 9c: parent-side endpoints. Auth is the parent session cookie,
// NOT the teacher JWT. /parent/auth/redeem creates the cookie; the
// other routes require it via ParentSessionMiddleware.
parentAPI := router.Group("/api/v1/parent")
{
parentAPI.POST("/auth/redeem", handler.RedeemMagicLink)
authed := parentAPI.Group("/")
authed.Use(middleware.ParentSessionMiddleware(func(ctx context.Context, token string) (string, string, string, error) {
p, err := handler.ParentService().ParentFromSession(ctx, token)
if err != nil {
return "", "", "", err
}
return p.ID.String(), p.Email, p.PreferredLanguage, nil
}))
authed.GET("/me", handler.ParentMe)
authed.GET("/me/timetable", handler.ParentTimetable)
authed.POST("/auth/logout", handler.ParentLogout)
}
// Start server
log.Printf("School Service starting on port %s", cfg.Port)
if err := router.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}