This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/school-service/internal/services/exam_service.go
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

249 lines
11 KiB
Go

package services
import (
"context"
"time"
"github.com/breakpilot/school-service/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// ExamService handles exam-related operations
type ExamService struct {
db *pgxpool.Pool
}
// NewExamService creates a new ExamService
func NewExamService(db *pgxpool.Pool) *ExamService {
return &ExamService{db: db}
}
// CreateExam creates a new exam
func (s *ExamService) CreateExam(ctx context.Context, teacherID string, req *models.CreateExamRequest) (*models.Exam, error) {
var classID, subjectID *uuid.UUID
if req.ClassID != "" {
id, _ := uuid.Parse(req.ClassID)
classID = &id
}
if req.SubjectID != "" {
id, _ := uuid.Parse(req.SubjectID)
subjectID = &id
}
var examDate *time.Time
if req.ExamDate != "" {
t, _ := time.Parse("2006-01-02", req.ExamDate)
examDate = &t
}
var durationMinutes *int
if req.DurationMinutes > 0 {
durationMinutes = &req.DurationMinutes
}
var maxPoints *float64
if req.MaxPoints > 0 {
maxPoints = &req.MaxPoints
}
var exam models.Exam
err := s.db.QueryRow(ctx, `
INSERT INTO exams (teacher_id, class_id, subject_id, title, exam_type, topic, content, difficulty_level, duration_minutes, max_points, exam_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, teacher_id, class_id, subject_id, title, exam_type, topic, content, source_file_path, difficulty_level, duration_minutes, max_points, is_template, parent_exam_id, status, exam_date, created_at, updated_at
`, teacherID, classID, subjectID, req.Title, req.ExamType, req.Topic, req.Content, req.DifficultyLevel, durationMinutes, maxPoints, examDate).Scan(
&exam.ID, &exam.TeacherID, &exam.ClassID, &exam.SubjectID, &exam.Title, &exam.ExamType, &exam.Topic, &exam.Content, &exam.SourceFilePath, &exam.DifficultyLevel, &exam.DurationMinutes, &exam.MaxPoints, &exam.IsTemplate, &exam.ParentExamID, &exam.Status, &exam.ExamDate, &exam.CreatedAt, &exam.UpdatedAt,
)
return &exam, err
}
// GetExams returns all exams for a teacher
func (s *ExamService) GetExams(ctx context.Context, teacherID string) ([]models.Exam, error) {
rows, err := s.db.Query(ctx, `
SELECT e.id, e.teacher_id, e.class_id, e.subject_id, e.title, e.exam_type, e.topic, e.content, e.source_file_path, e.difficulty_level, e.duration_minutes, e.max_points, e.is_template, e.parent_exam_id, e.status, e.exam_date, e.created_at, e.updated_at,
COALESCE(c.name, '') as class_name,
COALESCE(sub.name, '') as subject_name
FROM exams e
LEFT JOIN classes c ON e.class_id = c.id
LEFT JOIN subjects sub ON e.subject_id = sub.id
WHERE e.teacher_id = $1
ORDER BY e.created_at DESC
`, teacherID)
if err != nil {
return nil, err
}
defer rows.Close()
var exams []models.Exam
for rows.Next() {
var e models.Exam
if err := rows.Scan(&e.ID, &e.TeacherID, &e.ClassID, &e.SubjectID, &e.Title, &e.ExamType, &e.Topic, &e.Content, &e.SourceFilePath, &e.DifficultyLevel, &e.DurationMinutes, &e.MaxPoints, &e.IsTemplate, &e.ParentExamID, &e.Status, &e.ExamDate, &e.CreatedAt, &e.UpdatedAt, &e.ClassName, &e.SubjectName); err != nil {
return nil, err
}
exams = append(exams, e)
}
return exams, nil
}
// GetExam returns a single exam
func (s *ExamService) GetExam(ctx context.Context, examID, teacherID string) (*models.Exam, error) {
var exam models.Exam
err := s.db.QueryRow(ctx, `
SELECT e.id, e.teacher_id, e.class_id, e.subject_id, e.title, e.exam_type, e.topic, e.content, e.source_file_path, e.difficulty_level, e.duration_minutes, e.max_points, e.is_template, e.parent_exam_id, e.status, e.exam_date, e.created_at, e.updated_at,
COALESCE(c.name, '') as class_name,
COALESCE(sub.name, '') as subject_name
FROM exams e
LEFT JOIN classes c ON e.class_id = c.id
LEFT JOIN subjects sub ON e.subject_id = sub.id
WHERE e.id = $1 AND e.teacher_id = $2
`, examID, teacherID).Scan(
&exam.ID, &exam.TeacherID, &exam.ClassID, &exam.SubjectID, &exam.Title, &exam.ExamType, &exam.Topic, &exam.Content, &exam.SourceFilePath, &exam.DifficultyLevel, &exam.DurationMinutes, &exam.MaxPoints, &exam.IsTemplate, &exam.ParentExamID, &exam.Status, &exam.ExamDate, &exam.CreatedAt, &exam.UpdatedAt, &exam.ClassName, &exam.SubjectName,
)
return &exam, err
}
// UpdateExam updates an exam
func (s *ExamService) UpdateExam(ctx context.Context, examID, teacherID string, req *models.CreateExamRequest) (*models.Exam, error) {
var classID, subjectID *uuid.UUID
if req.ClassID != "" {
id, _ := uuid.Parse(req.ClassID)
classID = &id
}
if req.SubjectID != "" {
id, _ := uuid.Parse(req.SubjectID)
subjectID = &id
}
var examDate *time.Time
if req.ExamDate != "" {
t, _ := time.Parse("2006-01-02", req.ExamDate)
examDate = &t
}
var exam models.Exam
err := s.db.QueryRow(ctx, `
UPDATE exams SET
class_id = $3, subject_id = $4, title = $5, exam_type = $6, topic = $7, content = $8,
difficulty_level = $9, duration_minutes = $10, max_points = $11, exam_date = $12, updated_at = NOW()
WHERE id = $1 AND teacher_id = $2
RETURNING id, teacher_id, class_id, subject_id, title, exam_type, topic, content, source_file_path, difficulty_level, duration_minutes, max_points, is_template, parent_exam_id, status, exam_date, created_at, updated_at
`, examID, teacherID, classID, subjectID, req.Title, req.ExamType, req.Topic, req.Content, req.DifficultyLevel, req.DurationMinutes, req.MaxPoints, examDate).Scan(
&exam.ID, &exam.TeacherID, &exam.ClassID, &exam.SubjectID, &exam.Title, &exam.ExamType, &exam.Topic, &exam.Content, &exam.SourceFilePath, &exam.DifficultyLevel, &exam.DurationMinutes, &exam.MaxPoints, &exam.IsTemplate, &exam.ParentExamID, &exam.Status, &exam.ExamDate, &exam.CreatedAt, &exam.UpdatedAt,
)
return &exam, err
}
// DeleteExam deletes an exam
func (s *ExamService) DeleteExam(ctx context.Context, examID, teacherID string) error {
_, err := s.db.Exec(ctx, `DELETE FROM exams WHERE id = $1 AND teacher_id = $2`, examID, teacherID)
return err
}
// CreateExamVariant creates a variant of an existing exam (for Nachschreiber)
func (s *ExamService) CreateExamVariant(ctx context.Context, parentExamID, teacherID string, newContent string, variationType string) (*models.Exam, error) {
parentID, _ := uuid.Parse(parentExamID)
// Get parent exam
parent, err := s.GetExam(ctx, parentExamID, teacherID)
if err != nil {
return nil, err
}
title := parent.Title + " (Nachschreiber)"
if variationType == "alternative" {
title = parent.Title + " (Alternativ)"
}
var exam models.Exam
err = s.db.QueryRow(ctx, `
INSERT INTO exams (teacher_id, class_id, subject_id, title, exam_type, topic, content, difficulty_level, duration_minutes, max_points, is_template, parent_exam_id, status)
SELECT teacher_id, class_id, subject_id, $3, exam_type, topic, $4, difficulty_level, duration_minutes, max_points, false, $2, 'draft'
FROM exams WHERE id = $2 AND teacher_id = $1
RETURNING id, teacher_id, class_id, subject_id, title, exam_type, topic, content, source_file_path, difficulty_level, duration_minutes, max_points, is_template, parent_exam_id, status, exam_date, created_at, updated_at
`, teacherID, parentID, title, newContent).Scan(
&exam.ID, &exam.TeacherID, &exam.ClassID, &exam.SubjectID, &exam.Title, &exam.ExamType, &exam.Topic, &exam.Content, &exam.SourceFilePath, &exam.DifficultyLevel, &exam.DurationMinutes, &exam.MaxPoints, &exam.IsTemplate, &exam.ParentExamID, &exam.Status, &exam.ExamDate, &exam.CreatedAt, &exam.UpdatedAt,
)
return &exam, err
}
// SaveExamResult saves or updates a student's exam result
func (s *ExamService) SaveExamResult(ctx context.Context, examID string, req *models.UpdateExamResultRequest) (*models.ExamResult, error) {
var result models.ExamResult
err := s.db.QueryRow(ctx, `
INSERT INTO exam_results (exam_id, student_id, points_achieved, grade, notes, is_absent, needs_rewrite)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (exam_id, student_id) DO UPDATE SET
points_achieved = EXCLUDED.points_achieved,
grade = EXCLUDED.grade,
notes = EXCLUDED.notes,
is_absent = EXCLUDED.is_absent,
needs_rewrite = EXCLUDED.needs_rewrite,
updated_at = NOW()
RETURNING id, exam_id, student_id, points_achieved, grade, percentage, notes, is_absent, needs_rewrite, approved_by_teacher, approved_at, created_at, updated_at
`, examID, req.StudentID, req.PointsAchieved, req.Grade, req.Notes, req.IsAbsent, req.NeedsRewrite).Scan(
&result.ID, &result.ExamID, &result.StudentID, &result.PointsAchieved, &result.Grade, &result.Percentage, &result.Notes, &result.IsAbsent, &result.NeedsRewrite, &result.ApprovedByTeacher, &result.ApprovedAt, &result.CreatedAt, &result.UpdatedAt,
)
return &result, err
}
// GetExamResults returns all results for an exam
func (s *ExamService) GetExamResults(ctx context.Context, examID string) ([]models.ExamResult, error) {
rows, err := s.db.Query(ctx, `
SELECT er.id, er.exam_id, er.student_id, er.points_achieved, er.grade, er.percentage, er.notes, er.is_absent, er.needs_rewrite, er.approved_by_teacher, er.approved_at, er.created_at, er.updated_at,
CONCAT(s.first_name, ' ', s.last_name) as student_name
FROM exam_results er
JOIN students s ON er.student_id = s.id
WHERE er.exam_id = $1
ORDER BY s.last_name, s.first_name
`, examID)
if err != nil {
return nil, err
}
defer rows.Close()
var results []models.ExamResult
for rows.Next() {
var r models.ExamResult
if err := rows.Scan(&r.ID, &r.ExamID, &r.StudentID, &r.PointsAchieved, &r.Grade, &r.Percentage, &r.Notes, &r.IsAbsent, &r.NeedsRewrite, &r.ApprovedByTeacher, &r.ApprovedAt, &r.CreatedAt, &r.UpdatedAt, &r.StudentName); err != nil {
return nil, err
}
results = append(results, r)
}
return results, nil
}
// ApproveExamResult approves a result for transfer to grade overview
func (s *ExamService) ApproveExamResult(ctx context.Context, examID, studentID string) error {
_, err := s.db.Exec(ctx, `
UPDATE exam_results SET approved_by_teacher = true, approved_at = NOW(), updated_at = NOW()
WHERE exam_id = $1 AND student_id = $2
`, examID, studentID)
return err
}
// GetStudentsNeedingRewrite returns students who need to rewrite an exam
func (s *ExamService) GetStudentsNeedingRewrite(ctx context.Context, examID string) ([]models.Student, error) {
rows, err := s.db.Query(ctx, `
SELECT s.id, s.class_id, s.first_name, s.last_name, s.date_of_birth, s.student_number, s.created_at
FROM students s
JOIN exam_results er ON s.id = er.student_id
WHERE er.exam_id = $1 AND (er.needs_rewrite = true OR er.is_absent = true)
ORDER BY s.last_name, s.first_name
`, examID)
if err != nil {
return nil, err
}
defer rows.Close()
var students []models.Student
for rows.Next() {
var st models.Student
if err := rows.Scan(&st.ID, &st.ClassID, &st.FirstName, &st.LastName, &st.BirthDate, &st.StudentNumber, &st.CreatedAt); err != nil {
return nil, err
}
students = append(students, st)
}
return students, nil
}