Add timetable scheduler Phases 1 + 2 to school-service
Phase 1 — Stammdaten (7 tables):
tt_class, tt_period, tt_room, tt_subject, tt_teacher,
tt_curriculum, tt_assignment with CRUD endpoints.
Phase 2 — Constraints (15 typed tables):
Teacher (6): unavailable_day, unavailable_window, max_hours_day,
max_hours_week, excluded_subject, excluded_room
Subject (5): min_day_gap, max_consecutive, contiguous_when_repeated,
preferred_period, double_lesson
Class (2): max_hours_day, no_gaps
Room (2): requires_type, unavailable
Each constraint row carries is_hard / weight / active / note /
created_by_user_id; ownership enforced via WHERE EXISTS against the
parent tt_teacher/tt_class/tt_subject/tt_room row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)`,
|
||||
}
|
||||
}
|
||||
@@ -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)`,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user