import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { Injectable } from "@nestjs/common"; import { db } from "../config/database.js"; import { exams } from "./exams.schema.js"; import { examsRepository } from "./exams.repository.js"; import { outboxRepository } from "../shared/outbox/outbox.repository.js"; import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js"; import { canTransition, transition, type ExamStatus, type ExamAction, } from "./exam-state-machine.js"; import type { Exam, NewExam } from "./exams.schema.js"; import { NotFoundError, ValidationError, ConflictError, ApplicationError, CoreEduErrorCode, } from "../shared/errors/application-error.js"; export interface CreateExamInput { classId: string; subjectId: string; title: string; description?: string; examDate: Date | string; duration: number; totalScore: string; schoolId: string; createdBy: string; } export interface UpdateExamInput { title?: string; description?: string; examDate?: Date; duration?: number; totalScore?: string; } export interface AnswerInput { questionId: string; answer: string; } export interface ScoreInput { questionId: string; score: string; teacherComment?: string; } @Injectable() export class ExamsService { async createExam( input: CreateExamInput, userId?: string, ): Promise<{ id: string }> { if ( !input.classId || !input.title || !input.createdBy || !input.subjectId ) { throw new ValidationError( "classId, subjectId, title, createdBy are required", ); } const id = randomUUID(); const exam: NewExam = { id, classId: input.classId, subjectId: input.subjectId, title: input.title, description: input.description, examDate: input.examDate instanceof Date ? input.examDate : new Date(input.examDate), duration: input.duration, totalScore: input.totalScore, status: "draft", statusChangedAt: new Date(), schoolId: input.schoolId, createdBy: input.createdBy, }; const event = buildEvent({ aggregateId: id, eventType: "exam.created", payload: { examId: id, classId: input.classId, subjectId: input.subjectId, title: input.title, }, userId, }); await db.transaction(async (tx) => { await tx.insert(exams).values(exam); await outboxRepository.create( { id: randomUUID(), eventId: event.event_id, aggregateId: id, aggregateType: "exam", eventType: "exam.created", occurredAt: new Date(event.occurred_at), payload: serializeEvent(event), status: "pending", }, tx, ); }); return { id }; } async getExam(id: string): Promise { const exam = await examsRepository.findById(id); if (!exam) { throw new NotFoundError(`Exam ${id} not found`); } return exam; } async listExamsByClass(classId: string): Promise { return examsRepository.findByClassId(classId); } async updateExam(id: string, data: UpdateExamInput): Promise { const existing = await examsRepository.findById(id); if (!existing) { throw new NotFoundError(`Exam ${id} not found`); } if (existing.status !== "draft") { throw new ConflictError( `Cannot update exam in status ${existing.status} (only draft allows edits)`, ); } await db.transaction(async (tx) => { await tx.update(exams).set(data).where(eq(exams.id, id)); const event = buildEvent({ aggregateId: id, eventType: "exam.updated", payload: { examId: id, changes: data }, }); await outboxRepository.create( { id: randomUUID(), eventId: event.event_id, aggregateId: id, aggregateType: "exam", eventType: "exam.updated", occurredAt: new Date(event.occurred_at), payload: serializeEvent(event), status: "pending", }, tx, ); }); } async deleteExam(id: string): Promise { const existing = await examsRepository.findById(id); if (!existing) { throw new NotFoundError(`Exam ${id} not found`); } await db.transaction(async (tx) => { await tx.delete(exams).where(eq(exams.id, id)); const event = buildEvent({ aggregateId: id, eventType: "exam.deleted", payload: { examId: id }, }); await outboxRepository.create( { id: randomUUID(), eventId: event.event_id, aggregateId: id, aggregateType: "exam", eventType: "exam.deleted", occurredAt: new Date(event.occurred_at), payload: serializeEvent(event), status: "pending", }, tx, ); }); } async publishExam(id: string, publishedBy: string): Promise { const exam = await examsRepository.findById(id); if (!exam) { throw new NotFoundError(`Exam ${id} not found`); } const currentStatus = exam.status as ExamStatus; this.assertTransition(currentStatus, "publish"); const newStatus = transition(currentStatus, "publish"); await db.transaction(async (tx) => { await tx .update(exams) .set({ status: newStatus, statusChangedAt: new Date(), statusChangedBy: publishedBy, }) .where(eq(exams.id, id)); const event = buildEvent({ aggregateId: id, eventType: "exam.published", payload: { examId: id, classId: exam.classId, subjectId: exam.subjectId, }, userId: publishedBy, }); await outboxRepository.create( { id: randomUUID(), eventId: event.event_id, aggregateId: id, aggregateType: "exam", eventType: "exam.published", occurredAt: new Date(event.occurred_at), payload: serializeEvent(event), status: "pending", }, tx, ); }); } async submitExam( examId: string, studentId: string, _answers: AnswerInput[], ): Promise<{ submissionId: string }> { const exam = await examsRepository.findById(examId); if (!exam) { throw new NotFoundError(`Exam ${examId} not found`); } // Check existing submission (idempotency via unique index) const existing = await examsRepository.findSubmission(examId, studentId); if ( existing && (existing.status === "submitted" || existing.status === "graded") ) { throw new ConflictError( `Student ${studentId} already submitted exam ${examId}`, ); } const submissionId = randomUUID(); await db.transaction(async (tx) => { await examsRepository.createSubmission( { id: submissionId, examId, studentId, status: "submitted", submittedAt: new Date(), }, tx, ); // ISSUE-006 决策 #6: exam.submitted 事件只带 submission_id const event = buildEvent({ aggregateId: examId, eventType: "exam.submitted", payload: { examId, submissionId, studentId, }, userId: studentId, }); await outboxRepository.create( { id: randomUUID(), eventId: event.event_id, aggregateId: examId, aggregateType: "exam", eventType: "exam.submitted", occurredAt: new Date(event.occurred_at), payload: serializeEvent(event), status: "pending", }, tx, ); }); return { submissionId }; } async gradeExam( examId: string, submissionId: string, scores: ScoreInput[], gradedBy: string, ): Promise<{ totalScore: string }> { const exam = await examsRepository.findById(examId); if (!exam) { throw new NotFoundError(`Exam ${examId} not found`); } const submission = await examsRepository.findSubmissionById(submissionId); if (!submission || submission.examId !== examId) { throw new NotFoundError( `Submission ${submissionId} not found for exam ${examId}`, ); } if (submission.status === "graded") { throw new ConflictError(`Submission ${submissionId} already graded`); } // Calculate total score const totalScore = scores.reduce((sum, s) => sum + Number(s.score), 0); const totalScoreStr = totalScore.toFixed(2); await db.transaction(async (tx) => { await examsRepository.updateSubmission(submissionId, { status: "graded", gradedAt: new Date(), gradedBy, totalScore: totalScore.toFixed(2), }); const event = buildEvent({ aggregateId: examId, eventType: "exam.graded", payload: { examId, submissionId, studentId: submission.studentId, totalScore: totalScoreStr, }, userId: gradedBy, }); await outboxRepository.create( { id: randomUUID(), eventId: event.event_id, aggregateId: examId, aggregateType: "exam", eventType: "exam.graded", occurredAt: new Date(event.occurred_at), payload: serializeEvent(event), status: "pending", }, tx, ); }); return { totalScore: totalScoreStr }; } async archiveExam(id: string, archivedBy: string): Promise { const exam = await examsRepository.findById(id); if (!exam) { throw new NotFoundError(`Exam ${id} not found`); } // ISSUE-006 决策 #7: archived 仅做软删除 if (exam.status === "archived") { return; // Already archived } if (exam.status !== "graded") { throw new ConflictError( `Cannot archive exam in status ${exam.status} (only graded allows archive)`, ); } await db.transaction(async (tx) => { await tx .update(exams) .set({ status: "archived", statusChangedAt: new Date(), statusChangedBy: archivedBy, archivedAt: new Date(), }) .where(eq(exams.id, id)); }); } private assertTransition(from: ExamStatus, action: ExamAction): void { if (!canTransition(from, action)) { throw new ApplicationError( CoreEduErrorCode.EXAM_INVALID_TRANSITION, `Invalid exam state transition: ${from} --${action}-->`, 409, { from, action }, ); } } }