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