Initial commit: breakpilot-core - Shared Infrastructure
Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
505
consent-service/internal/services/attendance_service.go
Normal file
505
consent-service/internal/services/attendance_service.go
Normal file
@@ -0,0 +1,505 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user