Files
breakpilot-core/consent-service/internal/services/grade_service.go
Benjamin Admin 92c86ec6ba [split-required] [guardrail-change] Enforce 500 LOC budget across all services
Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook)
and split all 44 files exceeding 500 LOC into domain-focused modules:

- consent-service (Go): models, handlers, services, database splits
- backend-core (Python): security_api, rbac_api, pdf_service, auth splits
- admin-core (TypeScript): 5 page.tsx + sidebar extractions
- pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits
- voice-service (Python): enhanced_task_orchestrator split

Result: 0 violations, 36 exempted (pipeline, tests, pure-data files).
Go build verified clean. No behavior changes — pure structural splits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 00:09:30 +02:00

337 lines
10 KiB
Go

package services
import (
"context"
"fmt"
"time"
"github.com/breakpilot/consent-service/internal/database"
"github.com/breakpilot/consent-service/internal/models"
"github.com/breakpilot/consent-service/internal/services/matrix"
"github.com/google/uuid"
)
// GradeService handles grade management and notifications
type GradeService struct {
db *database.DB
matrix *matrix.MatrixService
}
// NewGradeService creates a new grade service
func NewGradeService(db *database.DB, matrixService *matrix.MatrixService) *GradeService {
return &GradeService{
db: db,
matrix: matrixService,
}
}
// ========================================
// Grade CRUD
// ========================================
// CreateGrade creates a new grade for a student
func (s *GradeService) CreateGrade(ctx context.Context, req models.CreateGradeRequest, teacherID uuid.UUID) (*models.Grade, error) {
studentID, err := uuid.Parse(req.StudentID)
if err != nil {
return nil, fmt.Errorf("invalid student ID: %w", err)
}
subjectID, err := uuid.Parse(req.SubjectID)
if err != nil {
return nil, fmt.Errorf("invalid subject ID: %w", err)
}
schoolYearID, err := uuid.Parse(req.SchoolYearID)
if err != nil {
return nil, fmt.Errorf("invalid school year ID: %w", err)
}
date, err := time.Parse("2006-01-02", req.Date)
if err != nil {
return nil, fmt.Errorf("invalid date format: %w", err)
}
// Get default grade scale for the school
var gradeScaleID uuid.UUID
var schoolID uuid.UUID
err = s.db.Pool.QueryRow(ctx, `
SELECT gs.id, gs.school_id
FROM grade_scales gs
JOIN students st ON st.school_id = gs.school_id
WHERE st.id = $1 AND gs.is_default = true`, studentID).Scan(&gradeScaleID, &schoolID)
if err != nil {
return nil, fmt.Errorf("failed to get grade scale: %w", err)
}
weight := req.Weight
if weight == 0 {
weight = 1.0
}
grade := &models.Grade{
ID: uuid.New(),
StudentID: studentID,
SubjectID: subjectID,
TeacherID: teacherID,
SchoolYearID: schoolYearID,
GradeScaleID: gradeScaleID,
Type: req.Type,
Value: req.Value,
Weight: weight,
Date: date,
Title: req.Title,
Description: req.Description,
IsVisible: true,
Semester: req.Semester,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `
INSERT INTO grades (id, student_id, subject_id, teacher_id, school_year_id, grade_scale_id, type, value, weight, date, title, description, is_visible, semester, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id`
err = s.db.Pool.QueryRow(ctx, query,
grade.ID, grade.StudentID, grade.SubjectID, grade.TeacherID,
grade.SchoolYearID, grade.GradeScaleID, grade.Type, grade.Value,
grade.Weight, grade.Date, grade.Title, grade.Description,
grade.IsVisible, grade.Semester, grade.CreatedAt, grade.UpdatedAt,
).Scan(&grade.ID)
if err != nil {
return nil, fmt.Errorf("failed to create grade: %w", err)
}
// Send notification to parents if grade is visible
if grade.IsVisible {
go s.notifyParentsOfNewGrade(context.Background(), grade)
}
return grade, nil
}
// GetGrade retrieves a grade by ID
func (s *GradeService) GetGrade(ctx context.Context, gradeID uuid.UUID) (*models.Grade, error) {
query := `
SELECT id, student_id, subject_id, teacher_id, school_year_id, grade_scale_id, type, value, weight, date, title, description, is_visible, semester, created_at, updated_at
FROM grades
WHERE id = $1`
grade := &models.Grade{}
err := s.db.Pool.QueryRow(ctx, query, gradeID).Scan(
&grade.ID, &grade.StudentID, &grade.SubjectID, &grade.TeacherID,
&grade.SchoolYearID, &grade.GradeScaleID, &grade.Type, &grade.Value,
&grade.Weight, &grade.Date, &grade.Title, &grade.Description,
&grade.IsVisible, &grade.Semester, &grade.CreatedAt, &grade.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get grade: %w", err)
}
return grade, nil
}
// UpdateGrade updates an existing grade
func (s *GradeService) UpdateGrade(ctx context.Context, gradeID uuid.UUID, value float64, title, description *string) error {
query := `
UPDATE grades
SET value = $1, title = COALESCE($2, title), description = COALESCE($3, description), updated_at = NOW()
WHERE id = $4`
result, err := s.db.Pool.Exec(ctx, query, value, title, description, gradeID)
if err != nil {
return fmt.Errorf("failed to update grade: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("grade not found")
}
return nil
}
// DeleteGrade deletes a grade
func (s *GradeService) DeleteGrade(ctx context.Context, gradeID uuid.UUID) error {
result, err := s.db.Pool.Exec(ctx, `DELETE FROM grades WHERE id = $1`, gradeID)
if err != nil {
return fmt.Errorf("failed to delete grade: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("grade not found")
}
return nil
}
// ========================================
// Grade Queries
// ========================================
// GetStudentGrades gets all grades for a student in a school year
func (s *GradeService) GetStudentGrades(ctx context.Context, studentID, schoolYearID uuid.UUID) ([]models.Grade, error) {
query := `
SELECT id, student_id, subject_id, teacher_id, school_year_id, grade_scale_id, type, value, weight, date, title, description, is_visible, semester, created_at, updated_at
FROM grades
WHERE student_id = $1 AND school_year_id = $2 AND is_visible = true
ORDER BY date DESC`
rows, err := s.db.Pool.Query(ctx, query, studentID, schoolYearID)
if err != nil {
return nil, fmt.Errorf("failed to get student grades: %w", err)
}
defer rows.Close()
var grades []models.Grade
for rows.Next() {
var grade models.Grade
err := rows.Scan(
&grade.ID, &grade.StudentID, &grade.SubjectID, &grade.TeacherID,
&grade.SchoolYearID, &grade.GradeScaleID, &grade.Type, &grade.Value,
&grade.Weight, &grade.Date, &grade.Title, &grade.Description,
&grade.IsVisible, &grade.Semester, &grade.CreatedAt, &grade.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan grade: %w", err)
}
grades = append(grades, grade)
}
return grades, nil
}
// GetStudentGradesBySubject gets grades for a student in a specific subject
func (s *GradeService) GetStudentGradesBySubject(ctx context.Context, studentID, subjectID, schoolYearID uuid.UUID, semester int) ([]models.Grade, error) {
query := `
SELECT id, student_id, subject_id, teacher_id, school_year_id, grade_scale_id, type, value, weight, date, title, description, is_visible, semester, created_at, updated_at
FROM grades
WHERE student_id = $1 AND subject_id = $2 AND school_year_id = $3 AND semester = $4 AND is_visible = true
ORDER BY date DESC`
rows, err := s.db.Pool.Query(ctx, query, studentID, subjectID, schoolYearID, semester)
if err != nil {
return nil, fmt.Errorf("failed to get grades by subject: %w", err)
}
defer rows.Close()
var grades []models.Grade
for rows.Next() {
var grade models.Grade
err := rows.Scan(
&grade.ID, &grade.StudentID, &grade.SubjectID, &grade.TeacherID,
&grade.SchoolYearID, &grade.GradeScaleID, &grade.Type, &grade.Value,
&grade.Weight, &grade.Date, &grade.Title, &grade.Description,
&grade.IsVisible, &grade.Semester, &grade.CreatedAt, &grade.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan grade: %w", err)
}
grades = append(grades, grade)
}
return grades, nil
}
// GetClassGradesBySubject gets all grades for a class in a subject (Notenspiegel)
func (s *GradeService) GetClassGradesBySubject(ctx context.Context, classID, subjectID, schoolYearID uuid.UUID, semester int) ([]models.StudentGradeOverview, error) {
// Get all students in the class
studentsQuery := `
SELECT id, first_name, last_name
FROM students
WHERE class_id = $1 AND is_active = true
ORDER BY last_name, first_name`
rows, err := s.db.Pool.Query(ctx, studentsQuery, classID)
if err != nil {
return nil, fmt.Errorf("failed to get students: %w", err)
}
defer rows.Close()
var students []struct {
ID uuid.UUID
FirstName string
LastName string
}
for rows.Next() {
var student struct {
ID uuid.UUID
FirstName string
LastName string
}
if err := rows.Scan(&student.ID, &student.FirstName, &student.LastName); err != nil {
return nil, fmt.Errorf("failed to scan student: %w", err)
}
students = append(students, student)
}
// Get subject info
var subject models.Subject
err = s.db.Pool.QueryRow(ctx, `SELECT id, school_id, name, short_name, color, is_active, created_at FROM subjects WHERE id = $1`, subjectID).Scan(
&subject.ID, &subject.SchoolID, &subject.Name, &subject.ShortName, &subject.Color, &subject.IsActive, &subject.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get subject: %w", err)
}
var overviews []models.StudentGradeOverview
for _, student := range students {
grades, err := s.GetStudentGradesBySubject(ctx, student.ID, subjectID, schoolYearID, semester)
if err != nil {
continue
}
// Calculate averages
var totalWeight, weightedSum float64
var oralWeight, oralSum float64
var examWeight, examSum float64
for _, grade := range grades {
totalWeight += grade.Weight
weightedSum += grade.Value * grade.Weight
if grade.Type == models.GradeTypeOral || grade.Type == models.GradeTypeParticipation {
oralWeight += grade.Weight
oralSum += grade.Value * grade.Weight
} else if grade.Type == models.GradeTypeExam || grade.Type == models.GradeTypeTest {
examWeight += grade.Weight
examSum += grade.Value * grade.Weight
}
}
var average, oralAverage, examAverage float64
if totalWeight > 0 {
average = weightedSum / totalWeight
}
if oralWeight > 0 {
oralAverage = oralSum / oralWeight
}
if examWeight > 0 {
examAverage = examSum / examWeight
}
overview := models.StudentGradeOverview{
Student: models.Student{
ID: student.ID,
FirstName: student.FirstName,
LastName: student.LastName,
},
Subject: subject,
Grades: grades,
Average: average,
OralAverage: oralAverage,
ExamAverage: examAverage,
Semester: semester,
}
overviews = append(overviews, overview)
}
return overviews, nil
}