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
@@ -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)`,
}
}