diff --git a/school-service/cmd/server/main.go b/school-service/cmd/server/main.go index 97f95c7..229934f 100644 --- a/school-service/cmd/server/main.go +++ b/school-service/cmd/server/main.go @@ -123,6 +123,101 @@ func main() { 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) } // Start server diff --git a/school-service/internal/database/database.go b/school-service/internal/database/database.go index 659e8bc..4be2b82 100644 --- a/school-service/internal/database/database.go +++ b/school-service/internal/database/database.go @@ -212,6 +212,12 @@ func Migrate(db *DB) error { `CREATE INDEX IF NOT EXISTS idx_gradebook_class ON gradebook_entries(class_id)`, } + // Append timetable scheduler migrations (see timetable_migrations.go) + migrations = append(migrations, TimetableMigrations()...) + + // Append timetable constraint migrations (see timetable_constraints_migrations.go) + migrations = append(migrations, TimetableConstraintMigrations()...) + for _, migration := range migrations { _, err := db.Pool.Exec(ctx, migration) if err != nil { diff --git a/school-service/internal/database/timetable_constraints_migrations.go b/school-service/internal/database/timetable_constraints_migrations.go new file mode 100644 index 0000000..0a03765 --- /dev/null +++ b/school-service/internal/database/timetable_constraints_migrations.go @@ -0,0 +1,224 @@ +package database + +// TimetableConstraintMigrations returns the DDL for all 15 constraint tables. +// Each table follows the same shape: +// - id / created_by_user_id / is_hard / weight / active / note / created_at +// - one or more FKs to tt_teacher / tt_class / tt_subject / tt_room +// +// FK ON DELETE CASCADE removes constraints when their parent (teacher/room/etc.) +// is deleted — the rules become meaningless without the referenced resource. +func TimetableConstraintMigrations() []string { + return []string{ + // ---------- Teacher constraints (6) ---------- + + `CREATE TABLE IF NOT EXISTS tt_constraint_teacher_unavailable_day ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE, + day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7), + is_hard BOOLEAN NOT NULL DEFAULT true, + weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(teacher_id, day_of_week) + )`, + + `CREATE TABLE IF NOT EXISTS tt_constraint_teacher_unavailable_window ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE, + day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7), + start_time TIME NOT NULL, + end_time TIME NOT NULL, + is_hard BOOLEAN NOT NULL DEFAULT true, + weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + CHECK (end_time > start_time) + )`, + + `CREATE TABLE IF NOT EXISTS tt_constraint_teacher_max_hours_day ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE, + max_hours INT NOT NULL CHECK (max_hours BETWEEN 1 AND 12), + is_hard BOOLEAN NOT NULL DEFAULT false, + weight INT NOT NULL DEFAULT 50 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(teacher_id) + )`, + + `CREATE TABLE IF NOT EXISTS tt_constraint_teacher_max_hours_week ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE, + max_hours INT NOT NULL CHECK (max_hours BETWEEN 1 AND 40), + is_hard BOOLEAN NOT NULL DEFAULT true, + weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(teacher_id) + )`, + + `CREATE TABLE IF NOT EXISTS tt_constraint_teacher_excluded_subject ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE, + subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE, + is_hard BOOLEAN NOT NULL DEFAULT true, + weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(teacher_id, subject_id) + )`, + + `CREATE TABLE IF NOT EXISTS tt_constraint_teacher_excluded_room ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE, + room_id UUID NOT NULL REFERENCES tt_room(id) ON DELETE CASCADE, + is_hard BOOLEAN NOT NULL DEFAULT true, + weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(teacher_id, room_id) + )`, + + // ---------- Subject constraints (5) ---------- + + `CREATE TABLE IF NOT EXISTS tt_constraint_subject_min_day_gap ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE, + min_gap_days INT NOT NULL CHECK (min_gap_days BETWEEN 1 AND 4), + is_hard BOOLEAN NOT NULL DEFAULT false, + weight INT NOT NULL DEFAULT 70 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(subject_id) + )`, + + `CREATE TABLE IF NOT EXISTS tt_constraint_subject_max_consecutive ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE, + max_consecutive INT NOT NULL CHECK (max_consecutive BETWEEN 1 AND 5), + is_hard BOOLEAN NOT NULL DEFAULT true, + weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(subject_id) + )`, + + `CREATE TABLE IF NOT EXISTS tt_constraint_subject_contiguous_when_repeated ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE, + is_hard BOOLEAN NOT NULL DEFAULT true, + weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(subject_id) + )`, + + `CREATE TABLE IF NOT EXISTS tt_constraint_subject_preferred_period ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE, + period_from INT NOT NULL CHECK (period_from BETWEEN 1 AND 12), + period_to INT NOT NULL CHECK (period_to BETWEEN 1 AND 12), + is_hard BOOLEAN NOT NULL DEFAULT false, + weight INT NOT NULL DEFAULT 40 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + CHECK (period_to >= period_from) + )`, + + `CREATE TABLE IF NOT EXISTS tt_constraint_subject_double_lesson ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE, + is_hard BOOLEAN NOT NULL DEFAULT false, + weight INT NOT NULL DEFAULT 60 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(subject_id) + )`, + + // ---------- Class constraints (2) ---------- + + `CREATE TABLE IF NOT EXISTS tt_constraint_class_max_hours_day ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE, + max_hours INT NOT NULL CHECK (max_hours BETWEEN 1 AND 12), + is_hard BOOLEAN NOT NULL DEFAULT true, + weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(class_id) + )`, + + `CREATE TABLE IF NOT EXISTS tt_constraint_class_no_gaps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE, + is_hard BOOLEAN NOT NULL DEFAULT false, + weight INT NOT NULL DEFAULT 80 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(class_id) + )`, + + // ---------- Room constraints (2) ---------- + + `CREATE TABLE IF NOT EXISTS tt_constraint_room_requires_type ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE, + room_type VARCHAR(30) NOT NULL, + is_hard BOOLEAN NOT NULL DEFAULT true, + weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(subject_id, room_type) + )`, + + `CREATE TABLE IF NOT EXISTS tt_constraint_room_unavailable ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + room_id UUID NOT NULL REFERENCES tt_room(id) ON DELETE CASCADE, + day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7), + period_index INT NOT NULL CHECK (period_index BETWEEN 1 AND 12), + is_hard BOOLEAN NOT NULL DEFAULT true, + weight INT NOT NULL DEFAULT 100 CHECK (weight BETWEEN 0 AND 100), + active BOOLEAN NOT NULL DEFAULT true, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(room_id, day_of_week, period_index) + )`, + + // ---------- Indexes ---------- + + `CREATE INDEX IF NOT EXISTS idx_tt_c_teacher_unav_day_teacher ON tt_constraint_teacher_unavailable_day(teacher_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_c_teacher_unav_win_teacher ON tt_constraint_teacher_unavailable_window(teacher_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_c_teacher_excl_subj_teacher ON tt_constraint_teacher_excluded_subject(teacher_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_c_teacher_excl_room_teacher ON tt_constraint_teacher_excluded_room(teacher_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_c_room_unav_room ON tt_constraint_room_unavailable(room_id)`, + } +} diff --git a/school-service/internal/database/timetable_migrations.go b/school-service/internal/database/timetable_migrations.go new file mode 100644 index 0000000..37a11c7 --- /dev/null +++ b/school-service/internal/database/timetable_migrations.go @@ -0,0 +1,104 @@ +package database + +// TimetableMigrations returns the SQL statements that create all timetable-related +// tables. They are applied idempotently via CREATE TABLE IF NOT EXISTS. +func TimetableMigrations() []string { + return []string{ + // Classes (school-wide, distinct from per-teacher `classes` table) + `CREATE TABLE IF NOT EXISTS tt_class ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + name VARCHAR(50) NOT NULL, + grade_level INT NOT NULL, + student_count INT DEFAULT 0, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(created_by_user_id, name) + )`, + + // Time periods (Mon=1..Sun=7, period_index = 1..N) + `CREATE TABLE IF NOT EXISTS tt_period ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7), + period_index INT NOT NULL CHECK (period_index >= 1), + start_time TIME NOT NULL, + end_time TIME NOT NULL, + is_break BOOLEAN DEFAULT false, + label VARCHAR(50), + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(created_by_user_id, day_of_week, period_index) + )`, + + // Rooms + `CREATE TABLE IF NOT EXISTS tt_room ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + name VARCHAR(50) NOT NULL, + room_type VARCHAR(30), + capacity INT DEFAULT 30, + floor_level INT DEFAULT 0, + has_elevator BOOLEAN DEFAULT true, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(created_by_user_id, name) + )`, + + // Subjects (school-wide, distinct from per-teacher `subjects`) + `CREATE TABLE IF NOT EXISTS tt_subject ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + name VARCHAR(100) NOT NULL, + short_code VARCHAR(10) NOT NULL, + color VARCHAR(7), + is_main_subject BOOLEAN DEFAULT false, + required_room_type VARCHAR(30), + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(created_by_user_id, short_code) + )`, + + // Teachers (planning resource, NOT a BreakPilot user) + `CREATE TABLE IF NOT EXISTS tt_teacher ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + short_code VARCHAR(10) NOT NULL, + employment_percentage INT DEFAULT 100, + max_hours_week INT DEFAULT 28, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(created_by_user_id, short_code) + )`, + + // Curriculum: weekly hour count per class+subject + `CREATE TABLE IF NOT EXISTS tt_curriculum ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE, + subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE, + weekly_hours INT NOT NULL CHECK (weekly_hours >= 1), + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(class_id, subject_id) + )`, + + // Assignment: which teacher teaches which subject in which class + `CREATE TABLE IF NOT EXISTS tt_assignment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE, + class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE, + subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(teacher_id, class_id, subject_id) + )`, + + // Indexes + `CREATE INDEX IF NOT EXISTS idx_tt_class_user ON tt_class(created_by_user_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_period_user ON tt_period(created_by_user_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_room_user ON tt_room(created_by_user_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_subject_user ON tt_subject(created_by_user_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_teacher_user ON tt_teacher(created_by_user_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_curriculum_class ON tt_curriculum(class_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_assignment_teacher ON tt_assignment(teacher_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_assignment_class ON tt_assignment(class_id)`, + } +} diff --git a/school-service/internal/handlers/handlers.go b/school-service/internal/handlers/handlers.go index 3b39a71..368c0bd 100644 --- a/school-service/internal/handlers/handlers.go +++ b/school-service/internal/handlers/handlers.go @@ -16,6 +16,7 @@ type Handler struct { gradebookService *services.GradebookService certificateService *services.CertificateService aiService *services.AIService + timetableService *services.TimetableService } // NewHandler creates a new Handler with all services @@ -26,6 +27,7 @@ func NewHandler(db *pgxpool.Pool, llmGatewayURL string) *Handler { gradebookService := services.NewGradebookService(db) certificateService := services.NewCertificateService(db, gradeService, gradebookService) aiService := services.NewAIService(llmGatewayURL) + timetableService := services.NewTimetableService(db) return &Handler{ classService: classService, @@ -34,6 +36,7 @@ func NewHandler(db *pgxpool.Pool, llmGatewayURL string) *Handler { gradebookService: gradebookService, certificateService: certificateService, aiService: aiService, + timetableService: timetableService, } } diff --git a/school-service/internal/handlers/timetable_constraints_class_room_handlers.go b/school-service/internal/handlers/timetable_constraints_class_room_handlers.go new file mode 100644 index 0000000..bd1d484 --- /dev/null +++ b/school-service/internal/handlers/timetable_constraints_class_room_handlers.go @@ -0,0 +1,202 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/school-service/internal/models" + "github.com/gin-gonic/gin" +) + +// Class- and Room-constraint HTTP handlers. + +// ---------- Class Max Hours / Day ---------- + +func (h *Handler) CreateClassMaxHoursDay(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateClassMaxHoursDayRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateClassMaxHoursDay(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListClassMaxHoursDay(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListClassMaxHoursDay(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteClassMaxHoursDay(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteClassMaxHoursDay(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} + +// ---------- Class No Gaps ---------- + +func (h *Handler) CreateClassNoGaps(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateClassNoGapsRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateClassNoGaps(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListClassNoGaps(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListClassNoGaps(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteClassNoGaps(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteClassNoGaps(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} + +// ---------- Room Requires Type ---------- + +func (h *Handler) CreateRoomRequiresType(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateRoomRequiresTypeRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateRoomRequiresType(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListRoomRequiresTypes(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListRoomRequiresTypes(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteRoomRequiresType(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteRoomRequiresType(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} + +// ---------- Room Unavailable ---------- + +func (h *Handler) CreateRoomUnavailable(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateRoomUnavailableRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateRoomUnavailable(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListRoomUnavailable(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListRoomUnavailable(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteRoomUnavailable(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteRoomUnavailable(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} diff --git a/school-service/internal/handlers/timetable_constraints_subject_handlers.go b/school-service/internal/handlers/timetable_constraints_subject_handlers.go new file mode 100644 index 0000000..2ae28f9 --- /dev/null +++ b/school-service/internal/handlers/timetable_constraints_subject_handlers.go @@ -0,0 +1,250 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/school-service/internal/models" + "github.com/gin-gonic/gin" +) + +// Subject-constraint HTTP handlers. + +// ---------- Subject Min Day Gap ---------- + +func (h *Handler) CreateSubjectMinDayGap(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateSubjectMinDayGapRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateSubjectMinDayGap(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListSubjectMinDayGaps(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListSubjectMinDayGaps(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteSubjectMinDayGap(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteSubjectMinDayGap(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} + +// ---------- Subject Max Consecutive ---------- + +func (h *Handler) CreateSubjectMaxConsecutive(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateSubjectMaxConsecutiveRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateSubjectMaxConsecutive(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListSubjectMaxConsecutives(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListSubjectMaxConsecutives(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteSubjectMaxConsecutive(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteSubjectMaxConsecutive(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} + +// ---------- Subject Contiguous When Repeated ---------- + +func (h *Handler) CreateSubjectContiguousWhenRepeated(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateSubjectContiguousWhenRepeatedRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateSubjectContiguousWhenRepeated(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListSubjectContiguousWhenRepeated(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListSubjectContiguousWhenRepeated(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteSubjectContiguousWhenRepeated(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteSubjectContiguousWhenRepeated(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} + +// ---------- Subject Preferred Period ---------- + +func (h *Handler) CreateSubjectPreferredPeriod(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateSubjectPreferredPeriodRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateSubjectPreferredPeriod(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListSubjectPreferredPeriods(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListSubjectPreferredPeriods(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteSubjectPreferredPeriod(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteSubjectPreferredPeriod(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} + +// ---------- Subject Double Lesson ---------- + +func (h *Handler) CreateSubjectDoubleLesson(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateSubjectDoubleLessonRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateSubjectDoubleLesson(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListSubjectDoubleLessons(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListSubjectDoubleLessons(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteSubjectDoubleLesson(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteSubjectDoubleLesson(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} diff --git a/school-service/internal/handlers/timetable_constraints_teacher_handlers.go b/school-service/internal/handlers/timetable_constraints_teacher_handlers.go new file mode 100644 index 0000000..b92417b --- /dev/null +++ b/school-service/internal/handlers/timetable_constraints_teacher_handlers.go @@ -0,0 +1,300 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/school-service/internal/models" + "github.com/gin-gonic/gin" +) + +// Teacher-constraint HTTP handlers. They share the same auth + JSON-bind +// shape as the existing timetable handlers; per-table the only thing that +// differs is the request DTO type and the service method invoked. + +// ---------- Teacher Unavailable Day ---------- + +func (h *Handler) CreateTeacherUnavailableDay(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTeacherUnavailableDayRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateTeacherUnavailableDay(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListTeacherUnavailableDays(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListTeacherUnavailableDays(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTeacherUnavailableDay(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteTeacherUnavailableDay(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} + +// ---------- Teacher Unavailable Window ---------- + +func (h *Handler) CreateTeacherUnavailableWindow(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTeacherUnavailableWindowRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateTeacherUnavailableWindow(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListTeacherUnavailableWindows(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListTeacherUnavailableWindows(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTeacherUnavailableWindow(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteTeacherUnavailableWindow(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} + +// ---------- Teacher Max Hours / Day ---------- + +func (h *Handler) CreateTeacherMaxHoursDay(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTeacherMaxHoursDayRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateTeacherMaxHoursDay(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListTeacherMaxHoursDay(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListTeacherMaxHoursDay(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTeacherMaxHoursDay(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteTeacherMaxHoursDay(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} + +// ---------- Teacher Max Hours / Week ---------- + +func (h *Handler) CreateTeacherMaxHoursWeek(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTeacherMaxHoursWeekRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateTeacherMaxHoursWeek(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListTeacherMaxHoursWeek(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListTeacherMaxHoursWeek(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTeacherMaxHoursWeek(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteTeacherMaxHoursWeek(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} + +// ---------- Teacher Excluded Subject ---------- + +func (h *Handler) CreateTeacherExcludedSubject(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTeacherExcludedSubjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateTeacherExcludedSubject(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListTeacherExcludedSubjects(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListTeacherExcludedSubjects(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTeacherExcludedSubject(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteTeacherExcludedSubject(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} + +// ---------- Teacher Excluded Room ---------- + +func (h *Handler) CreateTeacherExcludedRoom(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTeacherExcludedRoomRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateTeacherExcludedRoom(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create constraint: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListTeacherExcludedRooms(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListTeacherExcludedRooms(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list constraints: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTeacherExcludedRoom(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteTeacherExcludedRoom(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete constraint: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Constraint deleted"}) +} diff --git a/school-service/internal/handlers/timetable_handlers.go b/school-service/internal/handlers/timetable_handlers.go new file mode 100644 index 0000000..6f2fddb --- /dev/null +++ b/school-service/internal/handlers/timetable_handlers.go @@ -0,0 +1,248 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/school-service/internal/models" + "github.com/gin-gonic/gin" +) + +// ---------- Classes ---------- + +func (h *Handler) CreateTimetableClass(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTimetableClassRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateClass(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create class: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListTimetableClasses(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListClasses(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list classes: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTimetableClass(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteClass(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete class: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Class deleted"}) +} + +// ---------- Periods ---------- + +func (h *Handler) CreateTimetablePeriod(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTimetablePeriodRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreatePeriod(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create period: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListTimetablePeriods(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListPeriods(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list periods: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTimetablePeriod(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeletePeriod(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete period: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Period deleted"}) +} + +// ---------- Rooms ---------- + +func (h *Handler) CreateTimetableRoom(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTimetableRoomRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateRoom(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create room: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListTimetableRooms(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListRooms(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list rooms: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTimetableRoom(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteRoom(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete room: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Room deleted"}) +} + +// ---------- Subjects ---------- + +func (h *Handler) CreateTimetableSubject(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTimetableSubjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateSubject(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create subject: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListTimetableSubjects(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListSubjects(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list subjects: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTimetableSubject(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteSubject(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete subject: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Subject deleted"}) +} + +// ---------- Teachers ---------- + +func (h *Handler) CreateTimetableTeacher(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTimetableTeacherRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateTeacher(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create teacher: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListTimetableTeachers(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListTeachers(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list teachers: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTimetableTeacher(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteTeacher(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete teacher: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Teacher deleted"}) +} diff --git a/school-service/internal/handlers/timetable_relation_handlers.go b/school-service/internal/handlers/timetable_relation_handlers.go new file mode 100644 index 0000000..3a6d734 --- /dev/null +++ b/school-service/internal/handlers/timetable_relation_handlers.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/school-service/internal/models" + "github.com/gin-gonic/gin" +) + +// ---------- Curriculum ---------- + +func (h *Handler) CreateTimetableCurriculum(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTimetableCurriculumRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateCurriculum(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create curriculum: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListTimetableCurriculum(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListCurriculum(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list curriculum: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTimetableCurriculum(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteCurriculum(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete curriculum: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Curriculum entry deleted"}) +} + +// ---------- Assignment ---------- + +func (h *Handler) CreateTimetableAssignment(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTimetableAssignmentRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.timetableService.CreateAssignment(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create assignment: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListTimetableAssignments(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListAssignments(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list assignments: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTimetableAssignment(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteAssignment(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete assignment: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Assignment deleted"}) +} diff --git a/school-service/internal/models/timetable.go b/school-service/internal/models/timetable.go new file mode 100644 index 0000000..c580674 --- /dev/null +++ b/school-service/internal/models/timetable.go @@ -0,0 +1,155 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// TimetableClass is a school class managed for timetabling. +// Separate from the per-teacher `classes` table because timetabling is school-wide. +type TimetableClass struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + Name string `json:"name" db:"name"` + GradeLevel int `json:"grade_level" db:"grade_level"` + StudentCount int `json:"student_count" db:"student_count"` + Notes string `json:"notes,omitempty" db:"notes"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// TimetablePeriod is one time slot in the weekly grid. +// DayOfWeek: 1=Mon..7=Sun. PeriodIndex: 1=first lesson of the day. +type TimetablePeriod struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + DayOfWeek int `json:"day_of_week" db:"day_of_week"` + PeriodIndex int `json:"period_index" db:"period_index"` + StartTime string `json:"start_time" db:"start_time"` + EndTime string `json:"end_time" db:"end_time"` + IsBreak bool `json:"is_break" db:"is_break"` + Label string `json:"label,omitempty" db:"label"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// TimetableRoom is a physical room that can host lessons. +type TimetableRoom struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + Name string `json:"name" db:"name"` + RoomType string `json:"room_type,omitempty" db:"room_type"` + Capacity int `json:"capacity" db:"capacity"` + FloorLevel int `json:"floor_level" db:"floor_level"` + HasElevator bool `json:"has_elevator" db:"has_elevator"` + Notes string `json:"notes,omitempty" db:"notes"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// TimetableSubject is a school-wide subject for timetabling. +type TimetableSubject struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + Name string `json:"name" db:"name"` + ShortCode string `json:"short_code" db:"short_code"` + Color string `json:"color,omitempty" db:"color"` + IsMainSubject bool `json:"is_main_subject" db:"is_main_subject"` + RequiredRoomType string `json:"required_room_type,omitempty" db:"required_room_type"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// TimetableTeacher is a teacher as a schedulable resource. +// Independent from BreakPilot users — the Rektor enters all teachers manually. +type TimetableTeacher struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + FirstName string `json:"first_name" db:"first_name"` + LastName string `json:"last_name" db:"last_name"` + ShortCode string `json:"short_code" db:"short_code"` + EmploymentPercentage int `json:"employment_percentage" db:"employment_percentage"` + MaxHoursWeek int `json:"max_hours_week" db:"max_hours_week"` + Notes string `json:"notes,omitempty" db:"notes"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// TimetableCurriculum links a class to a subject with the weekly hour count. +type TimetableCurriculum struct { + ID uuid.UUID `json:"id" db:"id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + WeeklyHours int `json:"weekly_hours" db:"weekly_hours"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + + // Joined fields + SubjectName string `json:"subject_name,omitempty"` + ClassName string `json:"class_name,omitempty"` +} + +// TimetableAssignment is the teaching contract: who teaches what subject in which class. +type TimetableAssignment struct { + ID uuid.UUID `json:"id" db:"id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + + // Joined fields + TeacherName string `json:"teacher_name,omitempty"` + ClassName string `json:"class_name,omitempty"` + SubjectName string `json:"subject_name,omitempty"` +} + +// Request DTOs + +type CreateTimetableClassRequest struct { + Name string `json:"name" binding:"required"` + GradeLevel int `json:"grade_level" binding:"required,min=1,max=13"` + StudentCount int `json:"student_count" binding:"min=0"` + Notes string `json:"notes"` +} + +type CreateTimetablePeriodRequest struct { + DayOfWeek int `json:"day_of_week" binding:"required,min=1,max=7"` + PeriodIndex int `json:"period_index" binding:"required,min=1"` + StartTime string `json:"start_time" binding:"required"` + EndTime string `json:"end_time" binding:"required"` + IsBreak bool `json:"is_break"` + Label string `json:"label"` +} + +type CreateTimetableRoomRequest struct { + Name string `json:"name" binding:"required"` + RoomType string `json:"room_type"` + Capacity int `json:"capacity"` + FloorLevel int `json:"floor_level"` + HasElevator bool `json:"has_elevator"` + Notes string `json:"notes"` +} + +type CreateTimetableSubjectRequest struct { + Name string `json:"name" binding:"required"` + ShortCode string `json:"short_code" binding:"required"` + Color string `json:"color"` + IsMainSubject bool `json:"is_main_subject"` + RequiredRoomType string `json:"required_room_type"` +} + +type CreateTimetableTeacherRequest struct { + FirstName string `json:"first_name" binding:"required"` + LastName string `json:"last_name" binding:"required"` + ShortCode string `json:"short_code" binding:"required"` + EmploymentPercentage int `json:"employment_percentage" binding:"min=0,max=100"` + MaxHoursWeek int `json:"max_hours_week" binding:"min=0"` + Notes string `json:"notes"` +} + +type CreateTimetableCurriculumRequest struct { + ClassID string `json:"class_id" binding:"required,uuid"` + SubjectID string `json:"subject_id" binding:"required,uuid"` + WeeklyHours int `json:"weekly_hours" binding:"required,min=1,max=10"` +} + +type CreateTimetableAssignmentRequest struct { + TeacherID string `json:"teacher_id" binding:"required,uuid"` + ClassID string `json:"class_id" binding:"required,uuid"` + SubjectID string `json:"subject_id" binding:"required,uuid"` +} diff --git a/school-service/internal/models/timetable_constraints.go b/school-service/internal/models/timetable_constraints.go new file mode 100644 index 0000000..a4a88f5 --- /dev/null +++ b/school-service/internal/models/timetable_constraints.go @@ -0,0 +1,360 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// Each constraint table carries the same audit/policy columns: +// - is_hard: true = solver must satisfy, false = soft (weighted) +// - weight: higher = stronger penalty when violated (used for soft constraints) +// - active: allows toggling a rule off without deletion +// - note: free-text rationale ("Lehrer X im Rollstuhl") +// - created_by_user_id: the Rektor account that owns this rule + +// ---------- Teacher constraints (6) ---------- + +// TeacherUnavailableDay: Lehrer kann an Wochentag NIE. +type TeacherUnavailableDay struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + DayOfWeek int `json:"day_of_week" db:"day_of_week"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// TeacherUnavailableWindow: Lehrer kann an Tag X von HH:MM bis HH:MM nicht. +type TeacherUnavailableWindow struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + DayOfWeek int `json:"day_of_week" db:"day_of_week"` + StartTime string `json:"start_time" db:"start_time"` + EndTime string `json:"end_time" db:"end_time"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// TeacherMaxHoursDay: Lehrer darf max. N Stunden pro Tag haben. +type TeacherMaxHoursDay struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + MaxHours int `json:"max_hours" db:"max_hours"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// TeacherMaxHoursWeek: Teilzeit-Cap. +type TeacherMaxHoursWeek struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + MaxHours int `json:"max_hours" db:"max_hours"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// TeacherExcludedSubject: Lehrer darf bestimmtes Fach nicht unterrichten. +type TeacherExcludedSubject struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// TeacherExcludedRoom: Lehrer kann Raum nicht nutzen (z.B. kein Aufzug). +type TeacherExcludedRoom struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + RoomID uuid.UUID `json:"room_id" db:"room_id"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ---------- Subject constraints (5) ---------- + +// SubjectMinDayGap: Mindestens N Tage Abstand zwischen zwei Lessons desselben Fachs. +type SubjectMinDayGap struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + MinGapDays int `json:"min_gap_days" db:"min_gap_days"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// SubjectMaxConsecutive: Max. N aufeinander folgende Stunden des Fachs (keine Tripel-Stunde). +type SubjectMaxConsecutive struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + MaxConsecutive int `json:"max_consecutive" db:"max_consecutive"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// SubjectContiguousWhenRepeated: Wenn das Fach mehrfach am gleichen Tag stattfindet, dann nur als Block. +type SubjectContiguousWhenRepeated struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// SubjectPreferredPeriod: Fach lieber in einem bestimmten Period-Bereich (z.B. Hauptfächer morgens). +type SubjectPreferredPeriod struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + PeriodFrom int `json:"period_from" db:"period_from"` + PeriodTo int `json:"period_to" db:"period_to"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// SubjectDoubleLesson: Fach bevorzugt als Doppelstunde. +type SubjectDoubleLesson struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ---------- Class constraints (2) ---------- + +// ClassMaxHoursDay: Klasse darf max. N Stunden pro Tag haben. +type ClassMaxHoursDay struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + MaxHours int `json:"max_hours" db:"max_hours"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ClassNoGaps: Keine Freistunden für die Klasse zwischen Lessons (Soft-Standard). +type ClassNoGaps struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ---------- Room constraints (2) ---------- + +// RoomRequiresType: Fach benötigt einen bestimmten Raumtyp (Sport → Sporthalle). +type RoomRequiresType struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + RoomType string `json:"room_type" db:"room_type"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// RoomUnavailable: Raum an Tag X, Stunde Y blockiert (Wartung, Renovierung). +type RoomUnavailable struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + RoomID uuid.UUID `json:"room_id" db:"room_id"` + DayOfWeek int `json:"day_of_week" db:"day_of_week"` + PeriodIndex int `json:"period_index" db:"period_index"` + IsHard bool `json:"is_hard" db:"is_hard"` + Weight int `json:"weight" db:"weight"` + Active bool `json:"active" db:"active"` + Note string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ---------- Request DTOs ---------- + +// Teacher +type CreateTeacherUnavailableDayRequest struct { + TeacherID string `json:"teacher_id" binding:"required,uuid"` + DayOfWeek int `json:"day_of_week" binding:"required,min=1,max=7"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +type CreateTeacherUnavailableWindowRequest struct { + TeacherID string `json:"teacher_id" binding:"required,uuid"` + DayOfWeek int `json:"day_of_week" binding:"required,min=1,max=7"` + StartTime string `json:"start_time" binding:"required"` + EndTime string `json:"end_time" binding:"required"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +type CreateTeacherMaxHoursDayRequest struct { + TeacherID string `json:"teacher_id" binding:"required,uuid"` + MaxHours int `json:"max_hours" binding:"required,min=1,max=12"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +type CreateTeacherMaxHoursWeekRequest struct { + TeacherID string `json:"teacher_id" binding:"required,uuid"` + MaxHours int `json:"max_hours" binding:"required,min=1,max=40"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +type CreateTeacherExcludedSubjectRequest struct { + TeacherID string `json:"teacher_id" binding:"required,uuid"` + SubjectID string `json:"subject_id" binding:"required,uuid"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +type CreateTeacherExcludedRoomRequest struct { + TeacherID string `json:"teacher_id" binding:"required,uuid"` + RoomID string `json:"room_id" binding:"required,uuid"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +// Subject +type CreateSubjectMinDayGapRequest struct { + SubjectID string `json:"subject_id" binding:"required,uuid"` + MinGapDays int `json:"min_gap_days" binding:"required,min=1,max=4"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +type CreateSubjectMaxConsecutiveRequest struct { + SubjectID string `json:"subject_id" binding:"required,uuid"` + MaxConsecutive int `json:"max_consecutive" binding:"required,min=1,max=5"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +type CreateSubjectContiguousWhenRepeatedRequest struct { + SubjectID string `json:"subject_id" binding:"required,uuid"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +type CreateSubjectPreferredPeriodRequest struct { + SubjectID string `json:"subject_id" binding:"required,uuid"` + PeriodFrom int `json:"period_from" binding:"required,min=1,max=12"` + PeriodTo int `json:"period_to" binding:"required,min=1,max=12"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +type CreateSubjectDoubleLessonRequest struct { + SubjectID string `json:"subject_id" binding:"required,uuid"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +// Class +type CreateClassMaxHoursDayRequest struct { + ClassID string `json:"class_id" binding:"required,uuid"` + MaxHours int `json:"max_hours" binding:"required,min=1,max=12"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +type CreateClassNoGapsRequest struct { + ClassID string `json:"class_id" binding:"required,uuid"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +// Room +type CreateRoomRequiresTypeRequest struct { + SubjectID string `json:"subject_id" binding:"required,uuid"` + RoomType string `json:"room_type" binding:"required"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} + +type CreateRoomUnavailableRequest struct { + RoomID string `json:"room_id" binding:"required,uuid"` + DayOfWeek int `json:"day_of_week" binding:"required,min=1,max=7"` + PeriodIndex int `json:"period_index" binding:"required,min=1,max=12"` + IsHard bool `json:"is_hard"` + Weight int `json:"weight" binding:"min=0,max=100"` + Active bool `json:"active"` + Note string `json:"note"` +} diff --git a/school-service/internal/services/timetable_constraints_class_room.go b/school-service/internal/services/timetable_constraints_class_room.go new file mode 100644 index 0000000..4d7b419 --- /dev/null +++ b/school-service/internal/services/timetable_constraints_class_room.go @@ -0,0 +1,174 @@ +package services + +import ( + "context" + + "github.com/breakpilot/school-service/internal/models" +) + +// Class- and room-scoped constraint CRUD. Ownership is via tt_class / +// tt_subject / tt_room.created_by_user_id. + +// ---------- Class Max Hours / Day ---------- + +func (s *TimetableService) CreateClassMaxHoursDay(ctx context.Context, userID string, req *models.CreateClassMaxHoursDayRequest) (*models.ClassMaxHoursDay, error) { + var c models.ClassMaxHoursDay + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_class_max_hours_day + (created_by_user_id, class_id, max_hours, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6, $7 + WHERE EXISTS (SELECT 1 FROM tt_class WHERE id = $2 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, class_id, max_hours, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.ClassID, req.MaxHours, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.ClassID, &c.MaxHours, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListClassMaxHoursDay(ctx context.Context, userID string) ([]models.ClassMaxHoursDay, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, class_id, max_hours, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_class_max_hours_day WHERE created_by_user_id = $1 ORDER BY class_id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.ClassMaxHoursDay + for rows.Next() { + var c models.ClassMaxHoursDay + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.ClassID, &c.MaxHours, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteClassMaxHoursDay(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_class_max_hours_day WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Class No Gaps ---------- + +func (s *TimetableService) CreateClassNoGaps(ctx context.Context, userID string, req *models.CreateClassNoGapsRequest) (*models.ClassNoGaps, error) { + var c models.ClassNoGaps + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_class_no_gaps + (created_by_user_id, class_id, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6 + WHERE EXISTS (SELECT 1 FROM tt_class WHERE id = $2 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, class_id, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.ClassID, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.ClassID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListClassNoGaps(ctx context.Context, userID string) ([]models.ClassNoGaps, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, class_id, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_class_no_gaps WHERE created_by_user_id = $1 ORDER BY class_id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.ClassNoGaps + for rows.Next() { + var c models.ClassNoGaps + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.ClassID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteClassNoGaps(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_class_no_gaps WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Room Requires Type ---------- + +func (s *TimetableService) CreateRoomRequiresType(ctx context.Context, userID string, req *models.CreateRoomRequiresTypeRequest) (*models.RoomRequiresType, error) { + var c models.RoomRequiresType + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_room_requires_type + (created_by_user_id, subject_id, room_type, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6, $7 + WHERE EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, subject_id, room_type, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.SubjectID, req.RoomType, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.SubjectID, &c.RoomType, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListRoomRequiresTypes(ctx context.Context, userID string) ([]models.RoomRequiresType, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, subject_id, room_type, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_room_requires_type WHERE created_by_user_id = $1 ORDER BY subject_id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.RoomRequiresType + for rows.Next() { + var c models.RoomRequiresType + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.RoomType, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteRoomRequiresType(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_room_requires_type WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Room Unavailable ---------- + +func (s *TimetableService) CreateRoomUnavailable(ctx context.Context, userID string, req *models.CreateRoomUnavailableRequest) (*models.RoomUnavailable, error) { + var c models.RoomUnavailable + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_room_unavailable + (created_by_user_id, room_id, day_of_week, period_index, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6, $7, $8 + WHERE EXISTS (SELECT 1 FROM tt_room WHERE id = $2 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, room_id, day_of_week, period_index, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.RoomID, req.DayOfWeek, req.PeriodIndex, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.RoomID, &c.DayOfWeek, &c.PeriodIndex, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListRoomUnavailable(ctx context.Context, userID string) ([]models.RoomUnavailable, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, room_id, day_of_week, period_index, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_room_unavailable WHERE created_by_user_id = $1 ORDER BY room_id, day_of_week, period_index + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.RoomUnavailable + for rows.Next() { + var c models.RoomUnavailable + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.RoomID, &c.DayOfWeek, &c.PeriodIndex, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteRoomUnavailable(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_room_unavailable WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} diff --git a/school-service/internal/services/timetable_constraints_subject.go b/school-service/internal/services/timetable_constraints_subject.go new file mode 100644 index 0000000..2b1af73 --- /dev/null +++ b/school-service/internal/services/timetable_constraints_subject.go @@ -0,0 +1,214 @@ +package services + +import ( + "context" + + "github.com/breakpilot/school-service/internal/models" +) + +// Subject-scoped constraint CRUD. Ownership via tt_subject.created_by_user_id. + +// ---------- Subject Min Day Gap ---------- + +func (s *TimetableService) CreateSubjectMinDayGap(ctx context.Context, userID string, req *models.CreateSubjectMinDayGapRequest) (*models.SubjectMinDayGap, error) { + var c models.SubjectMinDayGap + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_subject_min_day_gap + (created_by_user_id, subject_id, min_gap_days, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6, $7 + WHERE EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, subject_id, min_gap_days, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.SubjectID, req.MinGapDays, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.SubjectID, &c.MinGapDays, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListSubjectMinDayGaps(ctx context.Context, userID string) ([]models.SubjectMinDayGap, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, subject_id, min_gap_days, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_subject_min_day_gap WHERE created_by_user_id = $1 ORDER BY subject_id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.SubjectMinDayGap + for rows.Next() { + var c models.SubjectMinDayGap + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.MinGapDays, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteSubjectMinDayGap(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_subject_min_day_gap WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Subject Max Consecutive ---------- + +func (s *TimetableService) CreateSubjectMaxConsecutive(ctx context.Context, userID string, req *models.CreateSubjectMaxConsecutiveRequest) (*models.SubjectMaxConsecutive, error) { + var c models.SubjectMaxConsecutive + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_subject_max_consecutive + (created_by_user_id, subject_id, max_consecutive, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6, $7 + WHERE EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, subject_id, max_consecutive, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.SubjectID, req.MaxConsecutive, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.SubjectID, &c.MaxConsecutive, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListSubjectMaxConsecutives(ctx context.Context, userID string) ([]models.SubjectMaxConsecutive, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, subject_id, max_consecutive, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_subject_max_consecutive WHERE created_by_user_id = $1 ORDER BY subject_id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.SubjectMaxConsecutive + for rows.Next() { + var c models.SubjectMaxConsecutive + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.MaxConsecutive, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteSubjectMaxConsecutive(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_subject_max_consecutive WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Subject Contiguous When Repeated ---------- + +func (s *TimetableService) CreateSubjectContiguousWhenRepeated(ctx context.Context, userID string, req *models.CreateSubjectContiguousWhenRepeatedRequest) (*models.SubjectContiguousWhenRepeated, error) { + var c models.SubjectContiguousWhenRepeated + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_subject_contiguous_when_repeated + (created_by_user_id, subject_id, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6 + WHERE EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, subject_id, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.SubjectID, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.SubjectID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListSubjectContiguousWhenRepeated(ctx context.Context, userID string) ([]models.SubjectContiguousWhenRepeated, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, subject_id, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_subject_contiguous_when_repeated WHERE created_by_user_id = $1 ORDER BY subject_id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.SubjectContiguousWhenRepeated + for rows.Next() { + var c models.SubjectContiguousWhenRepeated + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteSubjectContiguousWhenRepeated(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_subject_contiguous_when_repeated WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Subject Preferred Period ---------- + +func (s *TimetableService) CreateSubjectPreferredPeriod(ctx context.Context, userID string, req *models.CreateSubjectPreferredPeriodRequest) (*models.SubjectPreferredPeriod, error) { + var c models.SubjectPreferredPeriod + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_subject_preferred_period + (created_by_user_id, subject_id, period_from, period_to, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6, $7, $8 + WHERE EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, subject_id, period_from, period_to, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.SubjectID, req.PeriodFrom, req.PeriodTo, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.SubjectID, &c.PeriodFrom, &c.PeriodTo, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListSubjectPreferredPeriods(ctx context.Context, userID string) ([]models.SubjectPreferredPeriod, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, subject_id, period_from, period_to, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_subject_preferred_period WHERE created_by_user_id = $1 ORDER BY subject_id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.SubjectPreferredPeriod + for rows.Next() { + var c models.SubjectPreferredPeriod + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.PeriodFrom, &c.PeriodTo, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteSubjectPreferredPeriod(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_subject_preferred_period WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Subject Double Lesson ---------- + +func (s *TimetableService) CreateSubjectDoubleLesson(ctx context.Context, userID string, req *models.CreateSubjectDoubleLessonRequest) (*models.SubjectDoubleLesson, error) { + var c models.SubjectDoubleLesson + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_subject_double_lesson + (created_by_user_id, subject_id, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6 + WHERE EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, subject_id, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.SubjectID, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.SubjectID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListSubjectDoubleLessons(ctx context.Context, userID string) ([]models.SubjectDoubleLesson, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, subject_id, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_subject_double_lesson WHERE created_by_user_id = $1 ORDER BY subject_id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.SubjectDoubleLesson + for rows.Next() { + var c models.SubjectDoubleLesson + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.SubjectID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteSubjectDoubleLesson(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_subject_double_lesson WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} diff --git a/school-service/internal/services/timetable_constraints_teacher.go b/school-service/internal/services/timetable_constraints_teacher.go new file mode 100644 index 0000000..fd28221 --- /dev/null +++ b/school-service/internal/services/timetable_constraints_teacher.go @@ -0,0 +1,263 @@ +package services + +import ( + "context" + + "github.com/breakpilot/school-service/internal/models" +) + +// Teacher-scoped constraint CRUD. Ownership is enforced via the parent +// tt_teacher row's created_by_user_id (and tt_subject / tt_room for the +// composite excluded-* constraints). + +// ---------- Teacher Unavailable Day ---------- + +func (s *TimetableService) CreateTeacherUnavailableDay(ctx context.Context, userID string, req *models.CreateTeacherUnavailableDayRequest) (*models.TeacherUnavailableDay, error) { + var c models.TeacherUnavailableDay + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_teacher_unavailable_day + (created_by_user_id, teacher_id, day_of_week, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6, $7 + WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $2 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, teacher_id, day_of_week, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.TeacherID, req.DayOfWeek, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.TeacherID, &c.DayOfWeek, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListTeacherUnavailableDays(ctx context.Context, userID string) ([]models.TeacherUnavailableDay, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, teacher_id, day_of_week, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_teacher_unavailable_day + WHERE created_by_user_id = $1 + ORDER BY teacher_id, day_of_week + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TeacherUnavailableDay + for rows.Next() { + var c models.TeacherUnavailableDay + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.DayOfWeek, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteTeacherUnavailableDay(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_teacher_unavailable_day WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Teacher Unavailable Window ---------- + +func (s *TimetableService) CreateTeacherUnavailableWindow(ctx context.Context, userID string, req *models.CreateTeacherUnavailableWindowRequest) (*models.TeacherUnavailableWindow, error) { + var c models.TeacherUnavailableWindow + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_teacher_unavailable_window + (created_by_user_id, teacher_id, day_of_week, start_time, end_time, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9 + WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $2 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, teacher_id, day_of_week, start_time::text, end_time::text, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.TeacherID, req.DayOfWeek, req.StartTime, req.EndTime, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.TeacherID, &c.DayOfWeek, &c.StartTime, &c.EndTime, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListTeacherUnavailableWindows(ctx context.Context, userID string) ([]models.TeacherUnavailableWindow, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, teacher_id, day_of_week, start_time::text, end_time::text, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_teacher_unavailable_window + WHERE created_by_user_id = $1 + ORDER BY teacher_id, day_of_week, start_time + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TeacherUnavailableWindow + for rows.Next() { + var c models.TeacherUnavailableWindow + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.DayOfWeek, &c.StartTime, &c.EndTime, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteTeacherUnavailableWindow(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_teacher_unavailable_window WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Teacher Max Hours / Day ---------- + +func (s *TimetableService) CreateTeacherMaxHoursDay(ctx context.Context, userID string, req *models.CreateTeacherMaxHoursDayRequest) (*models.TeacherMaxHoursDay, error) { + var c models.TeacherMaxHoursDay + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_teacher_max_hours_day + (created_by_user_id, teacher_id, max_hours, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6, $7 + WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $2 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, teacher_id, max_hours, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.TeacherID, req.MaxHours, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.TeacherID, &c.MaxHours, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListTeacherMaxHoursDay(ctx context.Context, userID string) ([]models.TeacherMaxHoursDay, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, teacher_id, max_hours, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_teacher_max_hours_day WHERE created_by_user_id = $1 ORDER BY teacher_id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TeacherMaxHoursDay + for rows.Next() { + var c models.TeacherMaxHoursDay + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.MaxHours, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteTeacherMaxHoursDay(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_teacher_max_hours_day WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Teacher Max Hours / Week ---------- + +func (s *TimetableService) CreateTeacherMaxHoursWeek(ctx context.Context, userID string, req *models.CreateTeacherMaxHoursWeekRequest) (*models.TeacherMaxHoursWeek, error) { + var c models.TeacherMaxHoursWeek + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_teacher_max_hours_week + (created_by_user_id, teacher_id, max_hours, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6, $7 + WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $2 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, teacher_id, max_hours, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.TeacherID, req.MaxHours, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.TeacherID, &c.MaxHours, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListTeacherMaxHoursWeek(ctx context.Context, userID string) ([]models.TeacherMaxHoursWeek, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, teacher_id, max_hours, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_teacher_max_hours_week WHERE created_by_user_id = $1 ORDER BY teacher_id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TeacherMaxHoursWeek + for rows.Next() { + var c models.TeacherMaxHoursWeek + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.MaxHours, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteTeacherMaxHoursWeek(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_teacher_max_hours_week WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Teacher Excluded Subject ---------- + +func (s *TimetableService) CreateTeacherExcludedSubject(ctx context.Context, userID string, req *models.CreateTeacherExcludedSubjectRequest) (*models.TeacherExcludedSubject, error) { + var c models.TeacherExcludedSubject + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_teacher_excluded_subject + (created_by_user_id, teacher_id, subject_id, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6, $7 + WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $2 AND created_by_user_id = $1) + AND EXISTS (SELECT 1 FROM tt_subject WHERE id = $3 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, teacher_id, subject_id, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.TeacherID, req.SubjectID, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.TeacherID, &c.SubjectID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListTeacherExcludedSubjects(ctx context.Context, userID string) ([]models.TeacherExcludedSubject, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, teacher_id, subject_id, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_teacher_excluded_subject WHERE created_by_user_id = $1 ORDER BY teacher_id, subject_id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TeacherExcludedSubject + for rows.Next() { + var c models.TeacherExcludedSubject + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.SubjectID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteTeacherExcludedSubject(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_teacher_excluded_subject WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Teacher Excluded Room ---------- + +func (s *TimetableService) CreateTeacherExcludedRoom(ctx context.Context, userID string, req *models.CreateTeacherExcludedRoomRequest) (*models.TeacherExcludedRoom, error) { + var c models.TeacherExcludedRoom + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_constraint_teacher_excluded_room + (created_by_user_id, teacher_id, room_id, is_hard, weight, active, note) + SELECT $1, $2, $3, $4, $5, $6, $7 + WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $2 AND created_by_user_id = $1) + AND EXISTS (SELECT 1 FROM tt_room WHERE id = $3 AND created_by_user_id = $1) + RETURNING id, created_by_user_id, teacher_id, room_id, is_hard, weight, active, COALESCE(note,''), created_at + `, userID, req.TeacherID, req.RoomID, req.IsHard, req.Weight, req.Active, req.Note).Scan( + &c.ID, &c.CreatedByUserID, &c.TeacherID, &c.RoomID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListTeacherExcludedRooms(ctx context.Context, userID string) ([]models.TeacherExcludedRoom, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, teacher_id, room_id, is_hard, weight, active, COALESCE(note,''), created_at + FROM tt_constraint_teacher_excluded_room WHERE created_by_user_id = $1 ORDER BY teacher_id, room_id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TeacherExcludedRoom + for rows.Next() { + var c models.TeacherExcludedRoom + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.TeacherID, &c.RoomID, &c.IsHard, &c.Weight, &c.Active, &c.Note, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteTeacherExcludedRoom(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_constraint_teacher_excluded_room WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} diff --git a/school-service/internal/services/timetable_constraints_test.go b/school-service/internal/services/timetable_constraints_test.go new file mode 100644 index 0000000..d0ec823 --- /dev/null +++ b/school-service/internal/services/timetable_constraints_test.go @@ -0,0 +1,140 @@ +package services + +import ( + "testing" + + "github.com/breakpilot/school-service/internal/models" +) + +// These tests exercise the request DTO binding tags (the same the Gin layer +// uses). They don't hit the database — DB-level checks live in integration +// tests against a real Postgres. + +func TestCreateTeacherUnavailableDayRequest_Validation(t *testing.T) { + uid := "00000000-0000-0000-0000-000000000001" + tests := []struct { + name string + req models.CreateTeacherUnavailableDayRequest + wantErr bool + }{ + {"valid monday", models.CreateTeacherUnavailableDayRequest{TeacherID: uid, DayOfWeek: 1, IsHard: true, Weight: 100, Active: true}, false}, + {"day too low", models.CreateTeacherUnavailableDayRequest{TeacherID: uid, DayOfWeek: 0}, true}, + {"day too high", models.CreateTeacherUnavailableDayRequest{TeacherID: uid, DayOfWeek: 8}, true}, + {"non-uuid teacher", models.CreateTeacherUnavailableDayRequest{TeacherID: "not-a-uuid", DayOfWeek: 1}, true}, + {"weight above 100", models.CreateTeacherUnavailableDayRequest{TeacherID: uid, DayOfWeek: 1, Weight: 150}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate.Struct(tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr) + } + }) + } +} + +func TestCreateTeacherUnavailableWindowRequest_Validation(t *testing.T) { + uid := "00000000-0000-0000-0000-000000000001" + tests := []struct { + name string + req models.CreateTeacherUnavailableWindowRequest + wantErr bool + }{ + {"valid", models.CreateTeacherUnavailableWindowRequest{TeacherID: uid, DayOfWeek: 2, StartTime: "13:00", EndTime: "17:00"}, false}, + {"missing times", models.CreateTeacherUnavailableWindowRequest{TeacherID: uid, DayOfWeek: 2}, true}, + {"day too high", models.CreateTeacherUnavailableWindowRequest{TeacherID: uid, DayOfWeek: 8, StartTime: "13:00", EndTime: "17:00"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate.Struct(tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr) + } + }) + } +} + +func TestCreateSubjectMaxConsecutiveRequest_Validation(t *testing.T) { + uid := "00000000-0000-0000-0000-000000000002" + tests := []struct { + name string + req models.CreateSubjectMaxConsecutiveRequest + wantErr bool + }{ + {"valid 2 in a row", models.CreateSubjectMaxConsecutiveRequest{SubjectID: uid, MaxConsecutive: 2, IsHard: true, Weight: 100}, false}, + {"below 1", models.CreateSubjectMaxConsecutiveRequest{SubjectID: uid, MaxConsecutive: 0}, true}, + {"above 5", models.CreateSubjectMaxConsecutiveRequest{SubjectID: uid, MaxConsecutive: 6}, true}, + {"non-uuid subject", models.CreateSubjectMaxConsecutiveRequest{SubjectID: "x", MaxConsecutive: 2}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate.Struct(tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr) + } + }) + } +} + +func TestCreateSubjectPreferredPeriodRequest_Validation(t *testing.T) { + uid := "00000000-0000-0000-0000-000000000002" + tests := []struct { + name string + req models.CreateSubjectPreferredPeriodRequest + wantErr bool + }{ + {"valid morning", models.CreateSubjectPreferredPeriodRequest{SubjectID: uid, PeriodFrom: 1, PeriodTo: 4, IsHard: false, Weight: 40}, false}, + {"from missing", models.CreateSubjectPreferredPeriodRequest{SubjectID: uid, PeriodTo: 4}, true}, + {"to too high", models.CreateSubjectPreferredPeriodRequest{SubjectID: uid, PeriodFrom: 1, PeriodTo: 13}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate.Struct(tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr) + } + }) + } +} + +func TestCreateClassMaxHoursDayRequest_Validation(t *testing.T) { + uid := "00000000-0000-0000-0000-000000000003" + tests := []struct { + name string + req models.CreateClassMaxHoursDayRequest + wantErr bool + }{ + {"valid", models.CreateClassMaxHoursDayRequest{ClassID: uid, MaxHours: 6, IsHard: true, Weight: 100}, false}, + {"hours below 1", models.CreateClassMaxHoursDayRequest{ClassID: uid, MaxHours: 0}, true}, + {"hours above 12", models.CreateClassMaxHoursDayRequest{ClassID: uid, MaxHours: 13}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate.Struct(tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr) + } + }) + } +} + +func TestCreateRoomUnavailableRequest_Validation(t *testing.T) { + uid := "00000000-0000-0000-0000-000000000004" + tests := []struct { + name string + req models.CreateRoomUnavailableRequest + wantErr bool + }{ + {"valid", models.CreateRoomUnavailableRequest{RoomID: uid, DayOfWeek: 3, PeriodIndex: 4, IsHard: true, Weight: 100}, false}, + {"missing day", models.CreateRoomUnavailableRequest{RoomID: uid, PeriodIndex: 4}, true}, + {"period too high", models.CreateRoomUnavailableRequest{RoomID: uid, DayOfWeek: 3, PeriodIndex: 13}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate.Struct(tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr) + } + }) + } +} diff --git a/school-service/internal/services/timetable_relations.go b/school-service/internal/services/timetable_relations.go new file mode 100644 index 0000000..fec9d55 --- /dev/null +++ b/school-service/internal/services/timetable_relations.go @@ -0,0 +1,112 @@ +package services + +import ( + "context" + + "github.com/breakpilot/school-service/internal/models" +) + +// Curriculum and Assignment operations. +// Ownership is enforced by joining against tt_class.created_by_user_id. + +// ---------- Curriculum (class × subject → weekly hours) ---------- + +func (s *TimetableService) CreateCurriculum(ctx context.Context, userID string, req *models.CreateTimetableCurriculumRequest) (*models.TimetableCurriculum, error) { + var c models.TimetableCurriculum + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_curriculum (class_id, subject_id, weekly_hours) + SELECT $1, $2, $3 + WHERE EXISTS (SELECT 1 FROM tt_class WHERE id = $1 AND created_by_user_id = $4) + AND EXISTS (SELECT 1 FROM tt_subject WHERE id = $2 AND created_by_user_id = $4) + RETURNING id, class_id, subject_id, weekly_hours, created_at + `, req.ClassID, req.SubjectID, req.WeeklyHours, userID).Scan( + &c.ID, &c.ClassID, &c.SubjectID, &c.WeeklyHours, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListCurriculum(ctx context.Context, userID string) ([]models.TimetableCurriculum, error) { + rows, err := s.db.Query(ctx, ` + SELECT cu.id, cu.class_id, cu.subject_id, cu.weekly_hours, cu.created_at, + sub.name, cl.name + FROM tt_curriculum cu + JOIN tt_class cl ON cu.class_id = cl.id + JOIN tt_subject sub ON cu.subject_id = sub.id + WHERE cl.created_by_user_id = $1 + ORDER BY cl.grade_level, cl.name, sub.name + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TimetableCurriculum + for rows.Next() { + var c models.TimetableCurriculum + if err := rows.Scan(&c.ID, &c.ClassID, &c.SubjectID, &c.WeeklyHours, &c.CreatedAt, &c.SubjectName, &c.ClassName); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteCurriculum(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, ` + DELETE FROM tt_curriculum cu + USING tt_class cl + WHERE cu.id = $1 AND cu.class_id = cl.id AND cl.created_by_user_id = $2 + `, id, userID) + return err +} + +// ---------- Assignment (teacher × class × subject) ---------- + +func (s *TimetableService) CreateAssignment(ctx context.Context, userID string, req *models.CreateTimetableAssignmentRequest) (*models.TimetableAssignment, error) { + var a models.TimetableAssignment + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_assignment (teacher_id, class_id, subject_id) + SELECT $1, $2, $3 + WHERE EXISTS (SELECT 1 FROM tt_teacher WHERE id = $1 AND created_by_user_id = $4) + AND EXISTS (SELECT 1 FROM tt_class WHERE id = $2 AND created_by_user_id = $4) + AND EXISTS (SELECT 1 FROM tt_subject WHERE id = $3 AND created_by_user_id = $4) + RETURNING id, teacher_id, class_id, subject_id, created_at + `, req.TeacherID, req.ClassID, req.SubjectID, userID).Scan( + &a.ID, &a.TeacherID, &a.ClassID, &a.SubjectID, &a.CreatedAt, + ) + return &a, err +} + +func (s *TimetableService) ListAssignments(ctx context.Context, userID string) ([]models.TimetableAssignment, error) { + rows, err := s.db.Query(ctx, ` + SELECT a.id, a.teacher_id, a.class_id, a.subject_id, a.created_at, + t.last_name || ', ' || t.first_name, cl.name, sub.name + FROM tt_assignment a + JOIN tt_teacher t ON a.teacher_id = t.id + JOIN tt_class cl ON a.class_id = cl.id + JOIN tt_subject sub ON a.subject_id = sub.id + WHERE t.created_by_user_id = $1 + ORDER BY cl.grade_level, cl.name, sub.name + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TimetableAssignment + for rows.Next() { + var a models.TimetableAssignment + if err := rows.Scan(&a.ID, &a.TeacherID, &a.ClassID, &a.SubjectID, &a.CreatedAt, &a.TeacherName, &a.ClassName, &a.SubjectName); err != nil { + return nil, err + } + out = append(out, a) + } + return out, nil +} + +func (s *TimetableService) DeleteAssignment(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, ` + DELETE FROM tt_assignment a + USING tt_teacher t + WHERE a.id = $1 AND a.teacher_id = t.id AND t.created_by_user_id = $2 + `, id, userID) + return err +} diff --git a/school-service/internal/services/timetable_service.go b/school-service/internal/services/timetable_service.go new file mode 100644 index 0000000..d0a15bd --- /dev/null +++ b/school-service/internal/services/timetable_service.go @@ -0,0 +1,213 @@ +package services + +import ( + "context" + + "github.com/breakpilot/school-service/internal/models" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TimetableService handles all CRUD for the school-wide timetable scheduler: +// classes, periods, rooms, subjects, teachers, curriculum, assignments. +type TimetableService struct { + db *pgxpool.Pool +} + +func NewTimetableService(db *pgxpool.Pool) *TimetableService { + return &TimetableService{db: db} +} + +// ---------- Classes ---------- + +func (s *TimetableService) CreateClass(ctx context.Context, userID string, req *models.CreateTimetableClassRequest) (*models.TimetableClass, error) { + var c models.TimetableClass + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_class (created_by_user_id, name, grade_level, student_count, notes) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, created_by_user_id, name, grade_level, student_count, notes, created_at + `, userID, req.Name, req.GradeLevel, req.StudentCount, req.Notes).Scan( + &c.ID, &c.CreatedByUserID, &c.Name, &c.GradeLevel, &c.StudentCount, &c.Notes, &c.CreatedAt, + ) + return &c, err +} + +func (s *TimetableService) ListClasses(ctx context.Context, userID string) ([]models.TimetableClass, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, name, grade_level, student_count, COALESCE(notes,''), created_at + FROM tt_class WHERE created_by_user_id = $1 ORDER BY grade_level, name + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TimetableClass + for rows.Next() { + var c models.TimetableClass + if err := rows.Scan(&c.ID, &c.CreatedByUserID, &c.Name, &c.GradeLevel, &c.StudentCount, &c.Notes, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +func (s *TimetableService) DeleteClass(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_class WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Periods ---------- + +func (s *TimetableService) CreatePeriod(ctx context.Context, userID string, req *models.CreateTimetablePeriodRequest) (*models.TimetablePeriod, error) { + var p models.TimetablePeriod + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_period (created_by_user_id, day_of_week, period_index, start_time, end_time, is_break, label) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, created_by_user_id, day_of_week, period_index, start_time::text, end_time::text, is_break, COALESCE(label,''), created_at + `, userID, req.DayOfWeek, req.PeriodIndex, req.StartTime, req.EndTime, req.IsBreak, req.Label).Scan( + &p.ID, &p.CreatedByUserID, &p.DayOfWeek, &p.PeriodIndex, &p.StartTime, &p.EndTime, &p.IsBreak, &p.Label, &p.CreatedAt, + ) + return &p, err +} + +func (s *TimetableService) ListPeriods(ctx context.Context, userID string) ([]models.TimetablePeriod, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, day_of_week, period_index, start_time::text, end_time::text, is_break, COALESCE(label,''), created_at + FROM tt_period WHERE created_by_user_id = $1 ORDER BY day_of_week, period_index + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TimetablePeriod + for rows.Next() { + var p models.TimetablePeriod + if err := rows.Scan(&p.ID, &p.CreatedByUserID, &p.DayOfWeek, &p.PeriodIndex, &p.StartTime, &p.EndTime, &p.IsBreak, &p.Label, &p.CreatedAt); err != nil { + return nil, err + } + out = append(out, p) + } + return out, nil +} + +func (s *TimetableService) DeletePeriod(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_period WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Rooms ---------- + +func (s *TimetableService) CreateRoom(ctx context.Context, userID string, req *models.CreateTimetableRoomRequest) (*models.TimetableRoom, error) { + var r models.TimetableRoom + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_room (created_by_user_id, name, room_type, capacity, floor_level, has_elevator, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, created_by_user_id, name, COALESCE(room_type,''), capacity, floor_level, has_elevator, COALESCE(notes,''), created_at + `, userID, req.Name, req.RoomType, req.Capacity, req.FloorLevel, req.HasElevator, req.Notes).Scan( + &r.ID, &r.CreatedByUserID, &r.Name, &r.RoomType, &r.Capacity, &r.FloorLevel, &r.HasElevator, &r.Notes, &r.CreatedAt, + ) + return &r, err +} + +func (s *TimetableService) ListRooms(ctx context.Context, userID string) ([]models.TimetableRoom, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, name, COALESCE(room_type,''), capacity, floor_level, has_elevator, COALESCE(notes,''), created_at + FROM tt_room WHERE created_by_user_id = $1 ORDER BY name + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TimetableRoom + for rows.Next() { + var r models.TimetableRoom + if err := rows.Scan(&r.ID, &r.CreatedByUserID, &r.Name, &r.RoomType, &r.Capacity, &r.FloorLevel, &r.HasElevator, &r.Notes, &r.CreatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, nil +} + +func (s *TimetableService) DeleteRoom(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_room WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Subjects ---------- + +func (s *TimetableService) CreateSubject(ctx context.Context, userID string, req *models.CreateTimetableSubjectRequest) (*models.TimetableSubject, error) { + var sub models.TimetableSubject + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_subject (created_by_user_id, name, short_code, color, is_main_subject, required_room_type) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_by_user_id, name, short_code, COALESCE(color,''), is_main_subject, COALESCE(required_room_type,''), created_at + `, userID, req.Name, req.ShortCode, req.Color, req.IsMainSubject, req.RequiredRoomType).Scan( + &sub.ID, &sub.CreatedByUserID, &sub.Name, &sub.ShortCode, &sub.Color, &sub.IsMainSubject, &sub.RequiredRoomType, &sub.CreatedAt, + ) + return &sub, err +} + +func (s *TimetableService) ListSubjects(ctx context.Context, userID string) ([]models.TimetableSubject, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, name, short_code, COALESCE(color,''), is_main_subject, COALESCE(required_room_type,''), created_at + FROM tt_subject WHERE created_by_user_id = $1 ORDER BY name + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TimetableSubject + for rows.Next() { + var sub models.TimetableSubject + if err := rows.Scan(&sub.ID, &sub.CreatedByUserID, &sub.Name, &sub.ShortCode, &sub.Color, &sub.IsMainSubject, &sub.RequiredRoomType, &sub.CreatedAt); err != nil { + return nil, err + } + out = append(out, sub) + } + return out, nil +} + +func (s *TimetableService) DeleteSubject(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_subject WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// ---------- Teachers ---------- + +func (s *TimetableService) CreateTeacher(ctx context.Context, userID string, req *models.CreateTimetableTeacherRequest) (*models.TimetableTeacher, error) { + var t models.TimetableTeacher + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_teacher (created_by_user_id, first_name, last_name, short_code, employment_percentage, max_hours_week, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, created_by_user_id, first_name, last_name, short_code, employment_percentage, max_hours_week, COALESCE(notes,''), created_at + `, userID, req.FirstName, req.LastName, req.ShortCode, req.EmploymentPercentage, req.MaxHoursWeek, req.Notes).Scan( + &t.ID, &t.CreatedByUserID, &t.FirstName, &t.LastName, &t.ShortCode, &t.EmploymentPercentage, &t.MaxHoursWeek, &t.Notes, &t.CreatedAt, + ) + return &t, err +} + +func (s *TimetableService) ListTeachers(ctx context.Context, userID string) ([]models.TimetableTeacher, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, first_name, last_name, short_code, employment_percentage, max_hours_week, COALESCE(notes,''), created_at + FROM tt_teacher WHERE created_by_user_id = $1 ORDER BY last_name, first_name + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TimetableTeacher + for rows.Next() { + var t models.TimetableTeacher + if err := rows.Scan(&t.ID, &t.CreatedByUserID, &t.FirstName, &t.LastName, &t.ShortCode, &t.EmploymentPercentage, &t.MaxHoursWeek, &t.Notes, &t.CreatedAt); err != nil { + return nil, err + } + out = append(out, t) + } + return out, nil +} + +func (s *TimetableService) DeleteTeacher(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_teacher WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} diff --git a/school-service/internal/services/timetable_service_test.go b/school-service/internal/services/timetable_service_test.go new file mode 100644 index 0000000..4bbe362 --- /dev/null +++ b/school-service/internal/services/timetable_service_test.go @@ -0,0 +1,109 @@ +package services + +import ( + "testing" + + "github.com/go-playground/validator/v10" + + "github.com/breakpilot/school-service/internal/models" +) + +// validate is a singleton used to exercise the same struct tags Gin uses for +// request validation. The DB tests live in integration tests against a real +// database; this test pins the contract for the request DTOs. +var validate = func() *validator.Validate { + v := validator.New() + v.SetTagName("binding") + return v +}() + +func TestNewTimetableService_Constructs(t *testing.T) { + s := NewTimetableService(nil) + if s == nil { + t.Fatal("expected non-nil service") + } +} + +func TestCreateTimetableClassRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.CreateTimetableClassRequest + wantErr bool + }{ + {"valid", models.CreateTimetableClassRequest{Name: "5a", GradeLevel: 5, StudentCount: 24}, false}, + {"missing name", models.CreateTimetableClassRequest{GradeLevel: 5}, true}, + {"grade too low", models.CreateTimetableClassRequest{Name: "0a", GradeLevel: 0}, true}, + {"grade too high", models.CreateTimetableClassRequest{Name: "14a", GradeLevel: 14}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate.Struct(tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr) + } + }) + } +} + +func TestCreateTimetablePeriodRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.CreateTimetablePeriodRequest + wantErr bool + }{ + {"valid monday first", models.CreateTimetablePeriodRequest{DayOfWeek: 1, PeriodIndex: 1, StartTime: "08:00", EndTime: "08:45"}, false}, + {"day too low", models.CreateTimetablePeriodRequest{DayOfWeek: 0, PeriodIndex: 1, StartTime: "08:00", EndTime: "08:45"}, true}, + {"day too high", models.CreateTimetablePeriodRequest{DayOfWeek: 8, PeriodIndex: 1, StartTime: "08:00", EndTime: "08:45"}, true}, + {"missing times", models.CreateTimetablePeriodRequest{DayOfWeek: 1, PeriodIndex: 1}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate.Struct(tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr) + } + }) + } +} + +func TestCreateTimetableTeacherRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.CreateTimetableTeacherRequest + wantErr bool + }{ + {"valid full-time", models.CreateTimetableTeacherRequest{FirstName: "Anna", LastName: "Schmidt", ShortCode: "SCH", EmploymentPercentage: 100, MaxHoursWeek: 28}, false}, + {"valid part-time", models.CreateTimetableTeacherRequest{FirstName: "Bea", LastName: "Mueller", ShortCode: "MUE", EmploymentPercentage: 50, MaxHoursWeek: 14}, false}, + {"missing names", models.CreateTimetableTeacherRequest{ShortCode: "XX"}, true}, + {"employment above 100", models.CreateTimetableTeacherRequest{FirstName: "X", LastName: "Y", ShortCode: "Z", EmploymentPercentage: 150}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate.Struct(tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr) + } + }) + } +} + +func TestCreateTimetableCurriculumRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.CreateTimetableCurriculumRequest + wantErr bool + }{ + {"valid", models.CreateTimetableCurriculumRequest{ClassID: "00000000-0000-0000-0000-000000000001", SubjectID: "00000000-0000-0000-0000-000000000002", WeeklyHours: 4}, false}, + {"non-uuid class", models.CreateTimetableCurriculumRequest{ClassID: "not-a-uuid", SubjectID: "00000000-0000-0000-0000-000000000002", WeeklyHours: 4}, true}, + {"hours below 1", models.CreateTimetableCurriculumRequest{ClassID: "00000000-0000-0000-0000-000000000001", SubjectID: "00000000-0000-0000-0000-000000000002", WeeklyHours: 0}, true}, + {"hours above 10", models.CreateTimetableCurriculumRequest{ClassID: "00000000-0000-0000-0000-000000000001", SubjectID: "00000000-0000-0000-0000-000000000002", WeeklyHours: 11}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate.Struct(tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr) + } + }) + } +}