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>
238 lines
7.5 KiB
Go
238 lines
7.5 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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|