包含 classes/exams/homework/grades/attendance/scheduling 域、outbox、iam-consumer、redis 配置等完整实现
117 lines
3.2 KiB
TypeScript
117 lines
3.2 KiB
TypeScript
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<Attendance> {
|
|
const record = await attendanceRepository.findById(id);
|
|
if (!record) {
|
|
throw new NotFoundError(`Attendance ${id} not found`);
|
|
}
|
|
return record;
|
|
}
|
|
|
|
async listByStudent(studentId: string): Promise<Attendance[]> {
|
|
return attendanceRepository.findByStudentId(studentId);
|
|
}
|
|
|
|
async listByClass(classId: string): Promise<Attendance[]> {
|
|
return attendanceRepository.findByClassId(classId);
|
|
}
|
|
}
|