package main import ( "context" "log" "os" "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) // 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 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) } }