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" ) // AttendanceService handles attendance tracking and notifications type AttendanceService struct { db *database.DB matrix *matrix.MatrixService } // NewAttendanceService creates a new attendance service func NewAttendanceService(db *database.DB, matrixService *matrix.MatrixService) *AttendanceService { return &AttendanceService{ db: db, matrix: matrixService, } } // ======================================== // Attendance Recording // ======================================== // RecordAttendance records a student's attendance for a specific lesson func (s *AttendanceService) RecordAttendance(ctx context.Context, req models.RecordAttendanceRequest, recordedByUserID uuid.UUID) (*models.AttendanceRecord, error) { studentID, err := uuid.Parse(req.StudentID) if err != nil { return nil, fmt.Errorf("invalid student ID: %w", err) } slotID, err := uuid.Parse(req.SlotID) if err != nil { return nil, fmt.Errorf("invalid slot ID: %w", err) } date, err := time.Parse("2006-01-02", req.Date) if err != nil { return nil, fmt.Errorf("invalid date format: %w", err) } record := &models.AttendanceRecord{ ID: uuid.New(), StudentID: studentID, Date: date, SlotID: slotID, Status: req.Status, RecordedBy: recordedByUserID, Note: req.Note, CreatedAt: time.Now(), UpdatedAt: time.Now(), } query := ` INSERT INTO attendance_records (id, student_id, date, slot_id, status, recorded_by, note, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (student_id, date, slot_id) DO UPDATE SET status = EXCLUDED.status, note = EXCLUDED.note, updated_at = EXCLUDED.updated_at RETURNING id` err = s.db.Pool.QueryRow(ctx, query, record.ID, record.StudentID, record.Date, record.SlotID, record.Status, record.RecordedBy, record.Note, record.CreatedAt, record.UpdatedAt, ).Scan(&record.ID) if err != nil { return nil, fmt.Errorf("failed to record attendance: %w", err) } // If student is absent, send notification to parents if record.Status == models.AttendanceAbsent || record.Status == models.AttendancePending { go s.notifyParentsOfAbsence(context.Background(), record) } return record, nil } // RecordBulkAttendance records attendance for multiple students at once func (s *AttendanceService) RecordBulkAttendance(ctx context.Context, classID uuid.UUID, date string, slotID uuid.UUID, records []struct { StudentID string Status string Note *string }, recordedByUserID uuid.UUID) error { parsedDate, err := time.Parse("2006-01-02", date) if err != nil { return fmt.Errorf("invalid date format: %w", err) } for _, rec := range records { studentID, err := uuid.Parse(rec.StudentID) if err != nil { continue } query := ` INSERT INTO attendance_records (id, student_id, date, slot_id, status, recorded_by, note, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) ON CONFLICT (student_id, date, slot_id) DO UPDATE SET status = EXCLUDED.status, note = EXCLUDED.note, updated_at = NOW()` _, err = s.db.Pool.Exec(ctx, query, uuid.New(), studentID, parsedDate, slotID, rec.Status, recordedByUserID, rec.Note, ) if err != nil { return fmt.Errorf("failed to record attendance for student %s: %w", rec.StudentID, err) } // Notify parents if absent if rec.Status == models.AttendanceAbsent || rec.Status == models.AttendancePending { go s.notifyParentsOfAbsenceByStudentID(context.Background(), studentID, parsedDate, slotID) } } return nil } // GetAttendanceByClass gets attendance records for a class on a specific date func (s *AttendanceService) GetAttendanceByClass(ctx context.Context, classID uuid.UUID, date string) (*models.ClassAttendanceOverview, error) { parsedDate, err := time.Parse("2006-01-02", date) if err != nil { return nil, fmt.Errorf("invalid date format: %w", err) } // Get class info classQuery := `SELECT id, school_id, school_year_id, name, grade, section, room, is_active FROM classes WHERE id = $1` class := &models.Class{} err = s.db.Pool.QueryRow(ctx, classQuery, classID).Scan( &class.ID, &class.SchoolID, &class.SchoolYearID, &class.Name, &class.Grade, &class.Section, &class.Room, &class.IsActive, ) if err != nil { return nil, fmt.Errorf("failed to get class: %w", err) } // Get total students var totalStudents int err = s.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM students WHERE class_id = $1 AND is_active = true`, classID).Scan(&totalStudents) if err != nil { return nil, fmt.Errorf("failed to count students: %w", err) } // Get attendance records for the date recordsQuery := ` SELECT ar.id, ar.student_id, ar.date, ar.slot_id, ar.status, ar.recorded_by, ar.note, ar.created_at, ar.updated_at FROM attendance_records ar JOIN students s ON ar.student_id = s.id WHERE s.class_id = $1 AND ar.date = $2 ORDER BY ar.slot_id` rows, err := s.db.Pool.Query(ctx, recordsQuery, classID, parsedDate) if err != nil { return nil, fmt.Errorf("failed to get attendance records: %w", err) } defer rows.Close() var records []models.AttendanceRecord presentCount := 0 absentCount := 0 lateCount := 0 seenStudents := make(map[uuid.UUID]bool) for rows.Next() { var record models.AttendanceRecord err := rows.Scan( &record.ID, &record.StudentID, &record.Date, &record.SlotID, &record.Status, &record.RecordedBy, &record.Note, &record.CreatedAt, &record.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan attendance record: %w", err) } records = append(records, record) // Count unique students for summary (use first slot's status) if !seenStudents[record.StudentID] { seenStudents[record.StudentID] = true switch record.Status { case models.AttendancePresent: presentCount++ case models.AttendanceAbsent, models.AttendanceAbsentExcused, models.AttendanceAbsentUnexcused, models.AttendancePending: absentCount++ case models.AttendanceLate, models.AttendanceLateExcused: lateCount++ } } } return &models.ClassAttendanceOverview{ Class: *class, Date: parsedDate, TotalStudents: totalStudents, PresentCount: presentCount, AbsentCount: absentCount, LateCount: lateCount, Records: records, }, nil } // GetStudentAttendance gets attendance history for a student func (s *AttendanceService) GetStudentAttendance(ctx context.Context, studentID uuid.UUID, startDate, endDate time.Time) ([]models.AttendanceRecord, error) { query := ` SELECT id, student_id, timetable_entry_id, date, slot_id, status, recorded_by, note, created_at, updated_at FROM attendance_records WHERE student_id = $1 AND date >= $2 AND date <= $3 ORDER BY date DESC, slot_id` rows, err := s.db.Pool.Query(ctx, query, studentID, startDate, endDate) if err != nil { return nil, fmt.Errorf("failed to get student attendance: %w", err) } defer rows.Close() var records []models.AttendanceRecord for rows.Next() { var record models.AttendanceRecord err := rows.Scan( &record.ID, &record.StudentID, &record.TimetableEntryID, &record.Date, &record.SlotID, &record.Status, &record.RecordedBy, &record.Note, &record.CreatedAt, &record.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan attendance record: %w", err) } records = append(records, record) } return records, nil } // ======================================== // Absence Reports (Parent-initiated) // ======================================== // ReportAbsence allows parents to report a student's absence func (s *AttendanceService) ReportAbsence(ctx context.Context, req models.ReportAbsenceRequest, reportedByUserID uuid.UUID) (*models.AbsenceReport, error) { studentID, err := uuid.Parse(req.StudentID) if err != nil { return nil, fmt.Errorf("invalid student ID: %w", err) } startDate, err := time.Parse("2006-01-02", req.StartDate) if err != nil { return nil, fmt.Errorf("invalid start date format: %w", err) } endDate, err := time.Parse("2006-01-02", req.EndDate) if err != nil { return nil, fmt.Errorf("invalid end date format: %w", err) } report := &models.AbsenceReport{ ID: uuid.New(), StudentID: studentID, StartDate: startDate, EndDate: endDate, Reason: req.Reason, ReasonCategory: req.ReasonCategory, Status: "reported", ReportedBy: reportedByUserID, ReportedAt: time.Now(), CreatedAt: time.Now(), UpdatedAt: time.Now(), } query := ` INSERT INTO absence_reports (id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id` err = s.db.Pool.QueryRow(ctx, query, report.ID, report.StudentID, report.StartDate, report.EndDate, report.Reason, report.ReasonCategory, report.Status, report.ReportedBy, report.ReportedAt, report.CreatedAt, report.UpdatedAt, ).Scan(&report.ID) if err != nil { return nil, fmt.Errorf("failed to create absence report: %w", err) } return report, nil } // ConfirmAbsence allows teachers to confirm/excuse an absence func (s *AttendanceService) ConfirmAbsence(ctx context.Context, reportID uuid.UUID, confirmedByUserID uuid.UUID, status string) error { query := ` UPDATE absence_reports SET status = $1, confirmed_by = $2, confirmed_at = NOW(), updated_at = NOW() WHERE id = $3` result, err := s.db.Pool.Exec(ctx, query, status, confirmedByUserID, reportID) if err != nil { return fmt.Errorf("failed to confirm absence: %w", err) } if result.RowsAffected() == 0 { return fmt.Errorf("absence report not found") } return nil } // GetAbsenceReports gets absence reports for a student func (s *AttendanceService) GetAbsenceReports(ctx context.Context, studentID uuid.UUID) ([]models.AbsenceReport, error) { query := ` SELECT id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, confirmed_by, confirmed_at, medical_certificate, certificate_uploaded, matrix_notification_sent, email_notification_sent, created_at, updated_at FROM absence_reports WHERE student_id = $1 ORDER BY start_date DESC` rows, err := s.db.Pool.Query(ctx, query, studentID) if err != nil { return nil, fmt.Errorf("failed to get absence reports: %w", err) } defer rows.Close() var reports []models.AbsenceReport for rows.Next() { var report models.AbsenceReport err := rows.Scan( &report.ID, &report.StudentID, &report.StartDate, &report.EndDate, &report.Reason, &report.ReasonCategory, &report.Status, &report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt, &report.MedicalCertificate, &report.CertificateUploaded, &report.MatrixNotificationSent, &report.EmailNotificationSent, &report.CreatedAt, &report.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan absence report: %w", err) } reports = append(reports, report) } return reports, nil } // GetPendingAbsenceReports gets all unconfirmed absence reports for a class func (s *AttendanceService) GetPendingAbsenceReports(ctx context.Context, classID uuid.UUID) ([]models.AbsenceReport, error) { query := ` SELECT ar.id, ar.student_id, ar.start_date, ar.end_date, ar.reason, ar.reason_category, ar.status, ar.reported_by, ar.reported_at, ar.confirmed_by, ar.confirmed_at, ar.medical_certificate, ar.certificate_uploaded, ar.matrix_notification_sent, ar.email_notification_sent, ar.created_at, ar.updated_at FROM absence_reports ar JOIN students s ON ar.student_id = s.id WHERE s.class_id = $1 AND ar.status = 'reported' ORDER BY ar.start_date DESC` rows, err := s.db.Pool.Query(ctx, query, classID) if err != nil { return nil, fmt.Errorf("failed to get pending absence reports: %w", err) } defer rows.Close() var reports []models.AbsenceReport for rows.Next() { var report models.AbsenceReport err := rows.Scan( &report.ID, &report.StudentID, &report.StartDate, &report.EndDate, &report.Reason, &report.ReasonCategory, &report.Status, &report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt, &report.MedicalCertificate, &report.CertificateUploaded, &report.MatrixNotificationSent, &report.EmailNotificationSent, &report.CreatedAt, &report.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan absence report: %w", err) } reports = append(reports, report) } return reports, nil } // ======================================== // Attendance Statistics // ======================================== // GetStudentAttendanceStats gets attendance statistics for a student func (s *AttendanceService) GetStudentAttendanceStats(ctx context.Context, studentID uuid.UUID, schoolYearID uuid.UUID) (map[string]interface{}, error) { query := ` SELECT COUNT(*) as total_records, COUNT(CASE WHEN status = 'present' THEN 1 END) as present_count, COUNT(CASE WHEN status IN ('absent', 'excused', 'unexcused', 'pending_confirmation') THEN 1 END) as absent_count, COUNT(CASE WHEN status = 'unexcused' THEN 1 END) as unexcused_count, COUNT(CASE WHEN status IN ('late', 'late_excused') THEN 1 END) as late_count FROM attendance_records ar JOIN timetable_slots ts ON ar.slot_id = ts.id JOIN schools sch ON ts.school_id = sch.id JOIN school_years sy ON sy.school_id = sch.id AND sy.id = $2 WHERE ar.student_id = $1 AND ar.date >= sy.start_date AND ar.date <= sy.end_date` var totalRecords, presentCount, absentCount, unexcusedCount, lateCount int err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID).Scan( &totalRecords, &presentCount, &absentCount, &unexcusedCount, &lateCount, ) if err != nil { return nil, fmt.Errorf("failed to get attendance stats: %w", err) } var attendanceRate float64 if totalRecords > 0 { attendanceRate = float64(presentCount) / float64(totalRecords) * 100 } return map[string]interface{}{ "total_records": totalRecords, "present_count": presentCount, "absent_count": absentCount, "unexcused_count": unexcusedCount, "late_count": lateCount, "attendance_rate": attendanceRate, }, nil } // ======================================== // Parent Notifications // ======================================== func (s *AttendanceService) notifyParentsOfAbsence(ctx context.Context, record *models.AttendanceRecord) { if s.matrix == nil { return } // Get student info var studentFirstName, studentLastName, matrixDMRoom string err := s.db.Pool.QueryRow(ctx, ` SELECT first_name, last_name, matrix_dm_room FROM students WHERE id = $1`, record.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom) if err != nil || matrixDMRoom == "" { return } // Get slot info var slotNumber int err = s.db.Pool.QueryRow(ctx, `SELECT slot_number FROM timetable_slots WHERE id = $1`, record.SlotID).Scan(&slotNumber) if err != nil { return } studentName := studentFirstName + " " + studentLastName dateStr := record.Date.Format("02.01.2006") // Send Matrix notification err = s.matrix.SendAbsenceNotification(ctx, matrixDMRoom, studentName, dateStr, slotNumber) if err != nil { fmt.Printf("Failed to send absence notification: %v\n", err) return } // Update notification status s.db.Pool.Exec(ctx, ` UPDATE attendance_records SET updated_at = NOW() WHERE id = $1`, record.ID) // Log the notification s.createAbsenceNotificationLog(ctx, record.ID, studentName, dateStr, slotNumber) } func (s *AttendanceService) notifyParentsOfAbsenceByStudentID(ctx context.Context, studentID uuid.UUID, date time.Time, slotID uuid.UUID) { record := &models.AttendanceRecord{ StudentID: studentID, Date: date, SlotID: slotID, } s.notifyParentsOfAbsence(ctx, record) } func (s *AttendanceService) createAbsenceNotificationLog(ctx context.Context, recordID uuid.UUID, studentName, dateStr string, slotNumber int) { // Get parent IDs for this student query := ` SELECT p.id FROM parents p JOIN student_parents sp ON p.id = sp.parent_id JOIN attendance_records ar ON sp.student_id = ar.student_id WHERE ar.id = $1` rows, err := s.db.Pool.Query(ctx, query, recordID) if err != nil { return } defer rows.Close() message := fmt.Sprintf("Abwesenheitsmeldung: %s war am %s in der %d. Stunde nicht anwesend.", studentName, dateStr, slotNumber) for rows.Next() { var parentID uuid.UUID if err := rows.Scan(&parentID); err != nil { continue } // Insert notification log s.db.Pool.Exec(ctx, ` INSERT INTO absence_notifications (id, attendance_record_id, parent_id, channel, message_content, sent_at, created_at) VALUES ($1, $2, $3, 'matrix', $4, NOW(), NOW())`, uuid.New(), recordID, parentID, message) } }