import { randomUUID } from "node:crypto"; import { Injectable } from "@nestjs/common"; import { db } from "../config/database.js"; import { attendanceRepository } from "./attendance.repository.js"; import { outboxRepository } from "../shared/outbox/outbox.repository.js"; import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js"; import { isValidAttendanceStatus } from "./attendance-status.js"; import { NotFoundError, ValidationError, ConflictError, } from "../shared/errors/application-error.js"; import type { Attendance } from "./attendance.schema.js"; export interface RecordAttendanceInput { scheduleId: string; studentId: string; status: string; remark?: string; recordedBy: string; schoolId: string; } @Injectable() export class AttendanceService { async recordAttendance( input: RecordAttendanceInput, userId?: string, ): Promise<{ id: string }> { if ( !input.scheduleId || !input.studentId || !input.recordedBy || !input.schoolId ) { throw new ValidationError( "scheduleId, studentId, recordedBy, schoolId are required", ); } if (!isValidAttendanceStatus(input.status)) { throw new ValidationError( `Invalid attendance status: ${input.status}. Must be one of: present, absent, late, leave`, ); } // Idempotency: check existing record const existing = await attendanceRepository.findByScheduleAndStudent( input.scheduleId, input.studentId, ); if (existing) { throw new ConflictError( `Attendance already recorded for student ${input.studentId} in schedule ${input.scheduleId}`, ); } const id = randomUUID(); const event = buildEvent({ aggregateId: id, eventType: "attendance.recorded", payload: { attendanceId: id, scheduleId: input.scheduleId, studentId: input.studentId, status: input.status, }, userId, }); await db.transaction(async (tx) => { await attendanceRepository.create( { id, scheduleId: input.scheduleId, studentId: input.studentId, status: input.status, remark: input.remark, recordedBy: input.recordedBy, schoolId: input.schoolId, }, tx, ); await outboxRepository.create( { id: randomUUID(), eventId: event.event_id, aggregateId: id, aggregateType: "attendance", eventType: "attendance.recorded", occurredAt: new Date(event.occurred_at), payload: serializeEvent(event), status: "pending", }, tx, ); }); return { id }; } async getAttendance(id: string): Promise { const record = await attendanceRepository.findById(id); if (!record) { throw new NotFoundError(`Attendance ${id} not found`); } return record; } async listByStudent(studentId: string): Promise { return attendanceRepository.findByStudentId(studentId); } async listByClass(classId: string): Promise { return attendanceRepository.findByClassId(classId); } }