Files
breakpilot-lehrer/school-service/internal/services/timetable_solutions.go
T
Benjamin Admin bf5ea860cc
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 3m56s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 23s
Phase 7: pinning, plan versions, solver budget + UX polish
Backend (school-service):
  - tt_solution gains parent_solution_id (self-FK, ON DELETE SET NULL)
    and seconds_limit columns via ALTER TABLE IF NOT EXISTS.
  - CreateTimetableSolutionRequest accepts optional parent_solution_id
    and seconds_limit (5-600s) with binding validation.
  - CreateSolution checks parent ownership before INSERT so users can't
    fork another tenant's plan.
  - New PUT /timetable/lessons/:id/pin endpoint; ownership enforced via
    the lesson's solution.created_by_user_id JOIN.

Solver:
  - Lesson.pinned now carries @PlanningPin so Timefold leaves locked
    cells untouched during the search.
  - build_problem() takes optional parent_solution_id; if set, copies
    pinned (class_id, subject_id, day, period, room) tuples onto fresh
    Lesson objects via greedy first-fit matching. Surplus pinned rows
    from curriculum changes are silently dropped.
  - _build_factory(seconds) replaces the module-level factory so each
    job honours its tt_solution.seconds_limit override.
  - persist_solution writes lesson.pinned back so subsequent re-solves
    inherit it.

Frontend (studio-v2):
  - SolutionList grows three knobs in the create-form: Basieren auf
    (parent dropdown, only completed solutions, disabled when none),
    Sekunden-Limit (5-600), and the existing Name.
  - PlanView cells get a pin/unpin button with optimistic update and
    rollback on error. Pinned cells gain an amber ring.
  - types.ts + api.ts mirror the new fields; lessonsApi.pin(id, bool).
  - HelpPanel: collapsible 6-step Bedienungsanleitung explaining the
    setup-to-plan workflow. Anchored at the top of /stundenplan above
    the dev token banner.
  - page.tsx switches to the same gradient + animated-blob background
    used on /korrektur so /stundenplan stops looking like a slate-900
    test page.
  - JWT dev banner gets a step-by-step explanation of how to grab the
    token from DevTools and a non-blocking success indicator (no more
    alert()).

Tests:
  - school-service: 6 new validator cases for parent_solution_id +
    seconds_limit boundaries. 73 subtests total, all green.
  - studio-v2: mockSchoolApi adds PUT /lessons/:id/pin route. 5 new
    Playwright tests across two suites (parent-selector visibility +
    options, seconds-limit input, pin button render, pin-icon flip).
    Existing tests adjusted to the new help panel + JWT banner wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:19:39 +02:00

192 lines
6.7 KiB
Go

package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/breakpilot/school-service/internal/models"
)
// TimetableSolutionService persists solver runs and forwards solve requests
// to the timetable-solver-service. The solver writes lesson rows back to the
// same DB once it finishes, so listing solutions = simple SELECTs here.
func (s *TimetableService) CreateSolution(ctx context.Context, userID string, req *models.CreateTimetableSolutionRequest) (*models.TimetableSolution, error) {
// Resolve optional parent — guard against cross-user references.
var parentID *string
if req.ParentSolutionID != nil && *req.ParentSolutionID != "" {
var owned bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM tt_solution
WHERE id = $1 AND created_by_user_id = $2)
`, *req.ParentSolutionID, userID).Scan(&owned)
if err != nil {
return nil, err
}
if !owned {
return nil, fmt.Errorf("parent_solution_id not found or not owned by user")
}
parentID = req.ParentSolutionID
}
var sol models.TimetableSolution
err := s.db.QueryRow(ctx, `
INSERT INTO tt_solution (created_by_user_id, name, status, parent_solution_id, seconds_limit)
VALUES ($1, $2, 'pending', $3::uuid, $4)
RETURNING id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
COALESCE(error_message, ''), started_at, finished_at, created_at,
parent_solution_id, seconds_limit
`, userID, req.Name, parentID, req.SecondsLimit).Scan(
&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
&sol.ParentSolutionID, &sol.SecondsLimit,
)
return &sol, err
}
// UpdateLessonPin flips tt_lesson.pinned. Ownership is enforced via the
// lesson's solution.created_by_user_id — users can only pin their own
// solutions' lessons.
func (s *TimetableService) UpdateLessonPin(ctx context.Context, lessonID, userID string, pinned bool) error {
res, err := s.db.Exec(ctx, `
UPDATE tt_lesson l
SET pinned = $1
FROM tt_solution s
WHERE l.solution_id = s.id AND l.id = $2 AND s.created_by_user_id = $3
`, pinned, lessonID, userID)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return fmt.Errorf("lesson not found or not owned")
}
return nil
}
func (s *TimetableService) ListSolutions(ctx context.Context, userID string) ([]models.TimetableSolution, error) {
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
COALESCE(error_message, ''), started_at, finished_at, created_at,
parent_solution_id, seconds_limit
FROM tt_solution WHERE created_by_user_id = $1 ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TimetableSolution
for rows.Next() {
var sol models.TimetableSolution
if err := rows.Scan(&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
&sol.ParentSolutionID, &sol.SecondsLimit); err != nil {
return nil, err
}
out = append(out, sol)
}
return out, nil
}
func (s *TimetableService) GetSolution(ctx context.Context, id, userID string) (*models.TimetableSolution, error) {
var sol models.TimetableSolution
err := s.db.QueryRow(ctx, `
SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score,
COALESCE(error_message, ''), started_at, finished_at, created_at,
parent_solution_id, seconds_limit
FROM tt_solution WHERE id = $1 AND created_by_user_id = $2
`, id, userID).Scan(
&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status,
&sol.HardScore, &sol.SoftScore, &sol.ErrorMessage,
&sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt,
&sol.ParentSolutionID, &sol.SecondsLimit,
)
if err != nil {
return nil, err
}
return &sol, nil
}
func (s *TimetableService) ListLessons(ctx context.Context, solutionID, userID string) ([]models.TimetableLesson, error) {
rows, err := s.db.Query(ctx, `
SELECT l.id, l.solution_id, l.class_id, l.subject_id, l.teacher_id, l.room_id,
l.day_of_week, l.period_index, l.pinned, l.created_at,
cl.name, sub.name, t.last_name || ', ' || t.first_name,
COALESCE(r.name, '')
FROM tt_lesson l
JOIN tt_solution s ON l.solution_id = s.id
JOIN tt_class cl ON l.class_id = cl.id
JOIN tt_subject sub ON l.subject_id = sub.id
JOIN tt_teacher t ON l.teacher_id = t.id
LEFT JOIN tt_room r ON l.room_id = r.id
WHERE s.id = $1 AND s.created_by_user_id = $2
ORDER BY l.day_of_week, l.period_index
`, solutionID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.TimetableLesson
for rows.Next() {
var l models.TimetableLesson
if err := rows.Scan(&l.ID, &l.SolutionID, &l.ClassID, &l.SubjectID, &l.TeacherID, &l.RoomID,
&l.DayOfWeek, &l.PeriodIndex, &l.Pinned, &l.CreatedAt,
&l.ClassName, &l.SubjectName, &l.TeacherName, &l.RoomName); err != nil {
return nil, err
}
out = append(out, l)
}
return out, nil
}
func (s *TimetableService) DeleteSolution(ctx context.Context, id, userID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM tt_solution WHERE id = $1 AND created_by_user_id = $2`, id, userID)
return err
}
// TriggerSolve hands the freshly-created solution off to the solver-service.
// The solver writes back to tt_solution/tt_lesson directly once finished, so
// from this side we just need to fire-and-forget and let the client poll.
func (s *TimetableService) TriggerSolve(ctx context.Context, solverURL, solutionID, userID string) error {
payload := map[string]string{
"solution_id": solutionID,
"created_by_user_id": userID,
}
body, _ := json.Marshal(payload)
// 5s timeout — solver should accept the job in milliseconds and run async.
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, "POST", solverURL+"/api/v1/solve", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
// Mark solution as failed so the user sees something went wrong.
_, _ = s.db.Exec(ctx, `
UPDATE tt_solution SET status = 'failed', error_message = $1, finished_at = NOW()
WHERE id = $2
`, "solver-service unreachable: "+err.Error(), solutionID)
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
_, _ = s.db.Exec(ctx, `
UPDATE tt_solution SET status = 'failed', error_message = $1, finished_at = NOW()
WHERE id = $2
`, fmt.Sprintf("solver returned HTTP %d", resp.StatusCode), solutionID)
return fmt.Errorf("solver returned HTTP %d", resp.StatusCode)
}
return nil
}