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 }