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:
Benjamin Admin
2026-05-21 22:12:23 +02:00
parent a1488b2fec
commit e958f88a2d
19 changed files with 3276 additions and 0 deletions
@@ -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
}