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 } // ======================================== // Grade Statistics // ======================================== // GetStudentGradeAverage calculates the overall grade average for a student func (s *GradeService) GetStudentGradeAverage(ctx context.Context, studentID, schoolYearID uuid.UUID, semester int) (float64, error) { query := ` SELECT COALESCE(SUM(value * weight) / NULLIF(SUM(weight), 0), 0) FROM grades WHERE student_id = $1 AND school_year_id = $2 AND semester = $3 AND is_visible = true` var average float64 err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID, semester).Scan(&average) if err != nil { return 0, fmt.Errorf("failed to calculate average: %w", err) } return average, nil } // GetSubjectGradeStatistics gets grade statistics for a subject in a class func (s *GradeService) GetSubjectGradeStatistics(ctx context.Context, classID, subjectID, schoolYearID uuid.UUID, semester int) (map[string]interface{}, error) { query := ` SELECT COUNT(DISTINCT g.student_id) as student_count, AVG(g.value) as class_average, MIN(g.value) as best_grade, MAX(g.value) as worst_grade, COUNT(*) as total_grades FROM grades g JOIN students s ON g.student_id = s.id WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true` var studentCount, totalGrades int var classAverage, bestGrade, worstGrade float64 err := s.db.Pool.QueryRow(ctx, query, classID, subjectID, schoolYearID, semester).Scan( &studentCount, &classAverage, &bestGrade, &worstGrade, &totalGrades, ) if err != nil { return nil, fmt.Errorf("failed to get statistics: %w", err) } // Grade distribution (for German grades 1-6) distributionQuery := ` SELECT COUNT(CASE WHEN value >= 1 AND value < 1.5 THEN 1 END) as grade_1, COUNT(CASE WHEN value >= 1.5 AND value < 2.5 THEN 1 END) as grade_2, COUNT(CASE WHEN value >= 2.5 AND value < 3.5 THEN 1 END) as grade_3, COUNT(CASE WHEN value >= 3.5 AND value < 4.5 THEN 1 END) as grade_4, COUNT(CASE WHEN value >= 4.5 AND value < 5.5 THEN 1 END) as grade_5, COUNT(CASE WHEN value >= 5.5 THEN 1 END) as grade_6 FROM grades g JOIN students s ON g.student_id = s.id WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true AND g.type IN ('exam', 'test')` var g1, g2, g3, g4, g5, g6 int err = s.db.Pool.QueryRow(ctx, distributionQuery, classID, subjectID, schoolYearID, semester).Scan( &g1, &g2, &g3, &g4, &g5, &g6, ) if err != nil { // Non-fatal, continue without distribution g1, g2, g3, g4, g5, g6 = 0, 0, 0, 0, 0, 0 } return map[string]interface{}{ "student_count": studentCount, "class_average": classAverage, "best_grade": bestGrade, "worst_grade": worstGrade, "total_grades": totalGrades, "distribution": map[string]int{ "1": g1, "2": g2, "3": g3, "4": g4, "5": g5, "6": g6, }, }, nil } // ======================================== // Grade Comments // ======================================== // AddGradeComment adds a comment to a grade func (s *GradeService) AddGradeComment(ctx context.Context, gradeID, teacherID uuid.UUID, comment string, isPrivate bool) (*models.GradeComment, error) { gradeComment := &models.GradeComment{ ID: uuid.New(), GradeID: gradeID, TeacherID: teacherID, Comment: comment, IsPrivate: isPrivate, CreatedAt: time.Now(), } query := ` INSERT INTO grade_comments (id, grade_id, teacher_id, comment, is_private, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id` err := s.db.Pool.QueryRow(ctx, query, gradeComment.ID, gradeComment.GradeID, gradeComment.TeacherID, gradeComment.Comment, gradeComment.IsPrivate, gradeComment.CreatedAt, ).Scan(&gradeComment.ID) if err != nil { return nil, fmt.Errorf("failed to add grade comment: %w", err) } return gradeComment, nil } // GetGradeComments gets comments for a grade func (s *GradeService) GetGradeComments(ctx context.Context, gradeID uuid.UUID, includePrivate bool) ([]models.GradeComment, error) { query := ` SELECT id, grade_id, teacher_id, comment, is_private, created_at FROM grade_comments WHERE grade_id = $1` if !includePrivate { query += ` AND is_private = false` } query += ` ORDER BY created_at DESC` rows, err := s.db.Pool.Query(ctx, query, gradeID) if err != nil { return nil, fmt.Errorf("failed to get grade comments: %w", err) } defer rows.Close() var comments []models.GradeComment for rows.Next() { var comment models.GradeComment err := rows.Scan( &comment.ID, &comment.GradeID, &comment.TeacherID, &comment.Comment, &comment.IsPrivate, &comment.CreatedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan grade comment: %w", err) } comments = append(comments, comment) } return comments, nil } // ======================================== // Parent Notifications // ======================================== func (s *GradeService) notifyParentsOfNewGrade(ctx context.Context, grade *models.Grade) { if s.matrix == nil { return } // Get student info and Matrix room var studentFirstName, studentLastName, matrixDMRoom string err := s.db.Pool.QueryRow(ctx, ` SELECT first_name, last_name, matrix_dm_room FROM students WHERE id = $1`, grade.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom) if err != nil || matrixDMRoom == "" { return } // Get subject name var subjectName string err = s.db.Pool.QueryRow(ctx, `SELECT name FROM subjects WHERE id = $1`, grade.SubjectID).Scan(&subjectName) if err != nil { return } studentName := studentFirstName + " " + studentLastName gradeType := s.getGradeTypeDisplayName(grade.Type) // Send Matrix notification err = s.matrix.SendGradeNotification(ctx, matrixDMRoom, studentName, subjectName, gradeType, grade.Value) if err != nil { fmt.Printf("Failed to send grade notification: %v\n", err) } } func (s *GradeService) getGradeTypeDisplayName(gradeType string) string { switch gradeType { case models.GradeTypeExam: return "Klassenarbeit" case models.GradeTypeTest: return "Test" case models.GradeTypeOral: return "Mündliche Note" case models.GradeTypeHomework: return "Hausaufgabe" case models.GradeTypeProject: return "Projekt" case models.GradeTypeParticipation: return "Mitarbeit" case models.GradeTypeSemester: return "Halbjahreszeugnis" case models.GradeTypeFinal: return "Zeugnisnote" default: return gradeType } }