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 }