d9858084dd
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 31s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m36s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 26s
Backend (school-service):
- parent_account, parent_child, parent_magic_link, parent_session
tables. Tokens are sha256-hashed in DB; raw goes back exactly
once to the inviting teacher.
- InviteParent upserts the parent account, links a child to a tt_
class, mints a 7-day magic link. Returns the link path so the
teacher can paste it into Matrix/Email.
- RedeemMagicLink validates + marks used + mints a 30-day session,
sets HttpOnly bp_parent_session cookie.
- ParentSessionMiddleware reads the cookie and resolves the parent.
Lives in its own router group /api/v1/parent — totally separate
from the teacher JWT path.
- ParentMe returns the account + list of children (with class name).
- ParentTimetable returns the latest completed tt_solution's lessons
for the requested child's class, with full authorization check
(parent must own a child in that class).
Frontend (studio-v2):
- lib/calendar/subject-i18n.ts maps 22 German subject names to 8
parent locales (de/en/tr/ar/uk/ru/pl/fr). Falls back to German
for custom subjects.
- ParentManager component on the Schulkalender page lets the teacher
invite parents via email + child name + class + language. Newly
minted magic-link is shown with a copy-to-clipboard button.
- app/api/parent/[...path]/route.ts proxies parent-side endpoints
via the cookie so HttpOnly survives the Next.js round-trip.
- /eltern/login?token=… redeems and redirects to /eltern.
- /eltern shows a Wochengrid with German days + translated subject
names in the parent's preferred language. Headings and weekday
labels also localised (de/en/tr/ar/uk/ru/pl/fr).
Tests:
- 3 new Go unit tests (random token, hash stability, invite-request
validator). 83 subtests gesamt.
- studio-v2: e2e/eltern.spec.ts mit 7 tests across ParentManager,
/eltern/login, /eltern overview, subject-i18n end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
294 lines
14 KiB
Go
294 lines
14 KiB
Go
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)
|
|
}
|
|
}
|