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 }