feat(core-edu): 完整实现 core-edu 教学核心服务
包含 classes/exams/homework/grades/attendance/scheduling 域、outbox、iam-consumer、redis 配置等完整实现
This commit is contained in:
91
services/core-edu/src/homework/homework-state-machine.ts
Normal file
91
services/core-edu/src/homework/homework-state-machine.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 作业状态机(纯函数)
|
||||
*
|
||||
* 状态流转(ISSUE-003 仲裁 scheme A):
|
||||
* assigned → submitted → graded
|
||||
*
|
||||
* SubmissionStatus(学生提交维度):
|
||||
* not_submitted → submitted → graded
|
||||
*/
|
||||
export type HomeworkStatus = "assigned" | "submitted" | "graded";
|
||||
|
||||
export type HomeworkAction = "submit" | "grade";
|
||||
|
||||
const TRANSITIONS: Record<
|
||||
HomeworkStatus,
|
||||
Partial<Record<HomeworkAction, HomeworkStatus>>
|
||||
> = {
|
||||
assigned: { submit: "submitted" },
|
||||
submitted: { grade: "graded" },
|
||||
graded: {},
|
||||
};
|
||||
|
||||
export function canTransition(
|
||||
from: HomeworkStatus,
|
||||
action: HomeworkAction,
|
||||
): boolean {
|
||||
return TRANSITIONS[from]?.[action] !== undefined;
|
||||
}
|
||||
|
||||
export function transition(
|
||||
from: HomeworkStatus,
|
||||
action: HomeworkAction,
|
||||
): HomeworkStatus {
|
||||
const next = TRANSITIONS[from]?.[action];
|
||||
if (!next) {
|
||||
throw new Error(
|
||||
`Invalid homework state transition: ${from} --${action}-->`,
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isTerminal(status: HomeworkStatus): boolean {
|
||||
return status === "graded";
|
||||
}
|
||||
|
||||
// SubmissionStatus 状态机(用于 exam_submissions / homework_submissions 表的 status 字段)
|
||||
export type SubmissionStatus = "not_submitted" | "submitted" | "graded";
|
||||
|
||||
export type SubmissionAction = "submit" | "grade";
|
||||
|
||||
const SUBMISSION_TRANSITIONS: Record<
|
||||
SubmissionStatus,
|
||||
Partial<Record<SubmissionAction, SubmissionStatus>>
|
||||
> = {
|
||||
not_submitted: { submit: "submitted" },
|
||||
submitted: { grade: "graded" },
|
||||
graded: {},
|
||||
};
|
||||
|
||||
export function canTransitionSubmission(
|
||||
from: SubmissionStatus,
|
||||
action: SubmissionAction,
|
||||
): boolean {
|
||||
return SUBMISSION_TRANSITIONS[from]?.[action] !== undefined;
|
||||
}
|
||||
|
||||
export function transitionSubmission(
|
||||
from: SubmissionStatus,
|
||||
action: SubmissionAction,
|
||||
): SubmissionStatus {
|
||||
const next = SUBMISSION_TRANSITIONS[from]?.[action];
|
||||
if (!next) {
|
||||
throw new Error(
|
||||
`Invalid submission state transition: ${from} --${action}-->`,
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export const HOMEWORK_STATUSES: readonly HomeworkStatus[] = [
|
||||
"assigned",
|
||||
"submitted",
|
||||
"graded",
|
||||
] as const;
|
||||
|
||||
export const SUBMISSION_STATUSES: readonly SubmissionStatus[] = [
|
||||
"not_submitted",
|
||||
"submitted",
|
||||
"graded",
|
||||
] as const;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
HomeworkService,
|
||||
type AssignHomeworkInput,
|
||||
@@ -8,26 +9,70 @@ import {
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
|
||||
import { UnauthorizedError } from "../shared/errors/application-error.js";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
|
||||
@Controller("homework")
|
||||
const assignHomeworkSchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
subjectId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().optional(),
|
||||
dueDate: z.string().min(1),
|
||||
gracePeriod: z.number().int().positive().optional(),
|
||||
schoolId: z.string().min(1),
|
||||
});
|
||||
|
||||
const answerInputSchema = z.object({
|
||||
questionId: z.string().min(1),
|
||||
answer: z.string(),
|
||||
});
|
||||
|
||||
const scoreInputSchema = z.object({
|
||||
questionId: z.string().min(1),
|
||||
score: z.string().min(1),
|
||||
teacherComment: z.string().optional(),
|
||||
});
|
||||
|
||||
const submitHomeworkSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
answers: z.array(answerInputSchema).default([]),
|
||||
});
|
||||
|
||||
const gradeHomeworkSchema = z.object({
|
||||
submissionId: z.string().min(1),
|
||||
scores: z.array(scoreInputSchema).min(1),
|
||||
feedback: z.string().optional(),
|
||||
});
|
||||
|
||||
@Controller("v1/homework")
|
||||
export class HomeworkController {
|
||||
constructor(private readonly homeworkService: HomeworkService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.HOMEWORK_CREATE)
|
||||
async assign(
|
||||
@Body() body: AssignHomeworkInput,
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
}
|
||||
const result = await this.homeworkService.assignHomework({
|
||||
...body,
|
||||
const parsed = assignHomeworkSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError(
|
||||
"Invalid homework input",
|
||||
parsed.error.flatten(),
|
||||
);
|
||||
}
|
||||
const input: AssignHomeworkInput = {
|
||||
...parsed.data,
|
||||
dueDate: new Date(parsed.data.dueDate),
|
||||
createdBy: userId,
|
||||
});
|
||||
};
|
||||
const result = await this.homeworkService.assignHomework(input, userId);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@@ -55,8 +100,42 @@ export class HomeworkController {
|
||||
@RequirePermission(Permissions.HOMEWORK_SUBMIT)
|
||||
async submit(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
await this.homeworkService.submitHomework(id);
|
||||
return { success: true, data: { success: true } };
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { submissionId: string } }> {
|
||||
const parsed = submitHomeworkSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError("Invalid submit input", parsed.error.flatten());
|
||||
}
|
||||
const result = await this.homeworkService.submitHomework(
|
||||
id,
|
||||
parsed.data.studentId,
|
||||
parsed.data.answers,
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Post(":id/grade")
|
||||
@RequirePermission(Permissions.HOMEWORK_GRADE)
|
||||
async grade(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { totalScore: string } }> {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
}
|
||||
const parsed = gradeHomeworkSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError("Invalid grade input", parsed.error.flatten());
|
||||
}
|
||||
const result = await this.homeworkService.gradeHomework(
|
||||
id,
|
||||
parsed.data.submissionId,
|
||||
parsed.data.scores,
|
||||
parsed.data.feedback,
|
||||
userId,
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
}
|
||||
|
||||
100
services/core-edu/src/homework/homework.repository.ts
Normal file
100
services/core-edu/src/homework/homework.repository.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "../config/database.js";
|
||||
import {
|
||||
homework,
|
||||
homeworkSubmissions,
|
||||
homeworkAnswers,
|
||||
type Homework,
|
||||
type NewHomework,
|
||||
type HomeworkSubmission,
|
||||
type NewHomeworkSubmission,
|
||||
type HomeworkAnswer,
|
||||
type NewHomeworkAnswer,
|
||||
} from "./homework.schema.js";
|
||||
|
||||
export class HomeworkRepository {
|
||||
async findById(id: string): Promise<Homework | undefined> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(homework)
|
||||
.where(eq(homework.id, id))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
async findByClassId(classId: string): Promise<Homework[]> {
|
||||
return db.select().from(homework).where(eq(homework.classId, classId));
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<NewHomework>): Promise<void> {
|
||||
await db.update(homework).set(data).where(eq(homework.id, id));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await db.delete(homework).where(eq(homework.id, id));
|
||||
}
|
||||
|
||||
// Submissions
|
||||
async findSubmission(
|
||||
homeworkId: string,
|
||||
studentId: string,
|
||||
): Promise<HomeworkSubmission | undefined> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
eq(homeworkSubmissions.homeworkId, homeworkId),
|
||||
eq(homeworkSubmissions.studentId, studentId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
async findSubmissionById(
|
||||
id: string,
|
||||
): Promise<HomeworkSubmission | undefined> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(homeworkSubmissions)
|
||||
.where(eq(homeworkSubmissions.id, id))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
async createSubmission(
|
||||
submission: NewHomeworkSubmission,
|
||||
tx: typeof db = db,
|
||||
): Promise<void> {
|
||||
await tx.insert(homeworkSubmissions).values(submission);
|
||||
}
|
||||
|
||||
async updateSubmission(
|
||||
id: string,
|
||||
data: Partial<NewHomeworkSubmission>,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(homeworkSubmissions)
|
||||
.set(data)
|
||||
.where(eq(homeworkSubmissions.id, id));
|
||||
}
|
||||
|
||||
// Answers
|
||||
async createAnswers(
|
||||
answers: NewHomeworkAnswer[],
|
||||
tx: typeof db = db,
|
||||
): Promise<void> {
|
||||
if (answers.length === 0) return;
|
||||
await tx.insert(homeworkAnswers).values(answers);
|
||||
}
|
||||
|
||||
async listAnswers(submissionId: string): Promise<HomeworkAnswer[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(homeworkAnswers)
|
||||
.where(eq(homeworkAnswers.submissionId, submissionId));
|
||||
}
|
||||
}
|
||||
|
||||
export const homeworkRepository = new HomeworkRepository();
|
||||
@@ -5,19 +5,98 @@ import {
|
||||
timestamp,
|
||||
char,
|
||||
datetime,
|
||||
} from 'drizzle-orm/mysql-core';
|
||||
int,
|
||||
decimal,
|
||||
boolean,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
export const homework = mysqlTable('core_edu_homework', {
|
||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
||||
classId: char('class_id', { length: 36 }).notNull(),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
description: text('description'),
|
||||
dueDate: datetime('due_date').notNull(),
|
||||
status: varchar('status', { length: 20 }).notNull().default('assigned'),
|
||||
createdBy: char('created_by', { length: 36 }).notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
// 作业主表
|
||||
export const homework = mysqlTable(
|
||||
"core_edu_homework",
|
||||
{
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
classId: char("class_id", { length: 36 }).notNull(),
|
||||
subjectId: char("subject_id", { length: 36 }).notNull(),
|
||||
title: varchar("title", { length: 200 }).notNull(),
|
||||
description: text("description"),
|
||||
dueDate: datetime("due_date").notNull(),
|
||||
gracePeriod: int("grace_period").notNull().default(300), // 秒,仲裁默认 300
|
||||
status: varchar("status", { length: 20 }).notNull().default("assigned"),
|
||||
schoolId: char("school_id", { length: 36 }).notNull(),
|
||||
createdBy: char("created_by", { length: 36 }).notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
idxHomeworkClassStatus: index("idx_homework_class_status").on(
|
||||
table.classId,
|
||||
table.status,
|
||||
),
|
||||
idxHomeworkCreatedBy: index("idx_homework_created_by").on(table.createdBy),
|
||||
}),
|
||||
);
|
||||
|
||||
// 作业提交表
|
||||
export const homeworkSubmissions = mysqlTable(
|
||||
"core_edu_homework_submissions",
|
||||
{
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
homeworkId: char("homework_id", { length: 36 }).notNull(),
|
||||
studentId: char("student_id", { length: 36 }).notNull(),
|
||||
status: varchar("status", { length: 20 })
|
||||
.notNull()
|
||||
.default("not_submitted"),
|
||||
submittedAt: datetime("submitted_at"),
|
||||
gradedAt: datetime("graded_at"),
|
||||
gradedBy: char("graded_by", { length: 36 }),
|
||||
totalScore: decimal("total_score", { precision: 6, scale: 2 }),
|
||||
feedback: text("feedback"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
uniqHwStudent: uniqueIndex("uniq_hw_student").on(
|
||||
table.homeworkId,
|
||||
table.studentId,
|
||||
),
|
||||
idxHwSubmissionsHomeworkId: index("idx_hw_submissions_homework_id").on(
|
||||
table.homeworkId,
|
||||
),
|
||||
idxHwSubmissionsStudentId: index("idx_hw_submissions_student_id").on(
|
||||
table.studentId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// 作业答题表
|
||||
export const homeworkAnswers = mysqlTable(
|
||||
"core_edu_homework_answers",
|
||||
{
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
submissionId: char("submission_id", { length: 36 }).notNull(),
|
||||
questionId: char("question_id", { length: 36 }).notNull(),
|
||||
answer: text("answer"),
|
||||
score: decimal("score", { precision: 6, scale: 2 }),
|
||||
teacherComment: text("teacher_comment"),
|
||||
isCorrect: boolean("is_correct"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
idxHwAnswersSubmissionId: index("idx_hw_answers_submission_id").on(
|
||||
table.submissionId,
|
||||
),
|
||||
idxHwAnswersQuestionId: index("idx_hw_answers_question_id").on(
|
||||
table.questionId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Homework = typeof homework.$inferSelect;
|
||||
export type NewHomework = typeof homework.$inferInsert;
|
||||
export type HomeworkSubmission = typeof homeworkSubmissions.$inferSelect;
|
||||
export type NewHomeworkSubmission = typeof homeworkSubmissions.$inferInsert;
|
||||
export type HomeworkAnswer = typeof homeworkAnswers.$inferSelect;
|
||||
export type NewHomeworkAnswer = typeof homeworkAnswers.$inferInsert;
|
||||
|
||||
@@ -1,56 +1,97 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { db } from "../config/database.js";
|
||||
import { homework } from "./homework.schema.js";
|
||||
import { homeworkRepository } from "./homework.repository.js";
|
||||
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
|
||||
import type { Homework, NewHomework } from "./homework.schema.js";
|
||||
import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js";
|
||||
import { acquireLock, releaseLock } from "../config/redis.js";
|
||||
import {
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
ConflictError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
import type { Homework, NewHomework } from "./homework.schema.js";
|
||||
|
||||
export interface AssignHomeworkInput {
|
||||
classId: string;
|
||||
subjectId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
dueDate: Date | string;
|
||||
gracePeriod?: number;
|
||||
schoolId: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface AnswerInput {
|
||||
questionId: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface ScoreInput {
|
||||
questionId: string;
|
||||
score: string;
|
||||
teacherComment?: string;
|
||||
}
|
||||
|
||||
const LOCK_TTL_SECONDS = 30;
|
||||
|
||||
@Injectable()
|
||||
export class HomeworkService {
|
||||
async assignHomework(input: AssignHomeworkInput): Promise<{ id: string }> {
|
||||
if (!input.classId || !input.title || !input.createdBy) {
|
||||
throw new ValidationError("classId, title, createdBy are required");
|
||||
async assignHomework(
|
||||
input: AssignHomeworkInput,
|
||||
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 record: NewHomework = {
|
||||
id,
|
||||
classId: input.classId,
|
||||
subjectId: input.subjectId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
// Drizzle datetime 列需要 Date 对象;HTTP 请求体里 dueDate 是 ISO 字符串。
|
||||
dueDate:
|
||||
input.dueDate instanceof Date ? input.dueDate : new Date(input.dueDate),
|
||||
gracePeriod: input.gracePeriod ?? 300,
|
||||
status: "assigned",
|
||||
schoolId: input.schoolId,
|
||||
createdBy: input.createdBy,
|
||||
};
|
||||
|
||||
const event = buildEvent({
|
||||
aggregateId: id,
|
||||
eventType: "homework.assigned",
|
||||
payload: {
|
||||
homeworkId: id,
|
||||
classId: input.classId,
|
||||
subjectId: input.subjectId,
|
||||
title: input.title,
|
||||
},
|
||||
userId,
|
||||
});
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(homework).values(record);
|
||||
await outboxRepository.create(
|
||||
{
|
||||
id: randomUUID(),
|
||||
eventId: event.event_id,
|
||||
aggregateId: id,
|
||||
aggregateType: "homework",
|
||||
eventType: "homework.assigned",
|
||||
payload: JSON.stringify({
|
||||
id,
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
}),
|
||||
occurredAt: new Date(event.occurred_at),
|
||||
payload: serializeEvent(event),
|
||||
status: "pending",
|
||||
},
|
||||
tx,
|
||||
@@ -61,11 +102,7 @@ export class HomeworkService {
|
||||
}
|
||||
|
||||
async getHomework(id: string): Promise<Homework> {
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(homework)
|
||||
.where(eq(homework.id, id))
|
||||
.limit(1);
|
||||
const record = await homeworkRepository.findById(id);
|
||||
if (!record) {
|
||||
throw new NotFoundError(`Homework ${id} not found`);
|
||||
}
|
||||
@@ -73,31 +110,178 @@ export class HomeworkService {
|
||||
}
|
||||
|
||||
async listByClass(classId: string): Promise<Homework[]> {
|
||||
return db.select().from(homework).where(eq(homework.classId, classId));
|
||||
return homeworkRepository.findByClassId(classId);
|
||||
}
|
||||
|
||||
async submitHomework(id: string): Promise<void> {
|
||||
const existing = await this.getHomework(id);
|
||||
if (existing.status === "submitted") {
|
||||
throw new ValidationError(`Homework ${id} already submitted`);
|
||||
async submitHomework(
|
||||
homeworkId: string,
|
||||
studentId: string,
|
||||
answers: AnswerInput[],
|
||||
): Promise<{ submissionId: string }> {
|
||||
const hw = await homeworkRepository.findById(homeworkId);
|
||||
if (!hw) {
|
||||
throw new NotFoundError(`Homework ${homeworkId} not found`);
|
||||
}
|
||||
|
||||
// Redis distributed lock for idempotency (P2 feature)
|
||||
const lockKey = `hw:submit:${homeworkId}:${studentId}`;
|
||||
const locked = await acquireLock(lockKey, LOCK_TTL_SECONDS);
|
||||
if (!locked) {
|
||||
// Lock unavailable - check if submission already exists
|
||||
const existing = await homeworkRepository.findSubmission(
|
||||
homeworkId,
|
||||
studentId,
|
||||
);
|
||||
if (
|
||||
existing &&
|
||||
(existing.status === "submitted" || existing.status === "graded")
|
||||
) {
|
||||
throw new ConflictError(
|
||||
`Student ${studentId} already submitted homework ${homeworkId}`,
|
||||
);
|
||||
}
|
||||
throw new ConflictError("Submission in progress, please retry");
|
||||
}
|
||||
|
||||
try {
|
||||
// Double-check after acquiring lock
|
||||
const existing = await homeworkRepository.findSubmission(
|
||||
homeworkId,
|
||||
studentId,
|
||||
);
|
||||
if (
|
||||
existing &&
|
||||
(existing.status === "submitted" || existing.status === "graded")
|
||||
) {
|
||||
throw new ConflictError(
|
||||
`Student ${studentId} already submitted homework ${homeworkId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const submissionId = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
// Check grace period
|
||||
const dueWithGrace = new Date(
|
||||
hw.dueDate.getTime() + hw.gracePeriod * 1000,
|
||||
);
|
||||
const isLate = now > dueWithGrace;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await homeworkRepository.createSubmission(
|
||||
{
|
||||
id: submissionId,
|
||||
homeworkId,
|
||||
studentId,
|
||||
status: "submitted",
|
||||
submittedAt: now,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
// Insert answers
|
||||
if (answers.length > 0) {
|
||||
const answerRecords = answers.map((a) => ({
|
||||
id: randomUUID(),
|
||||
submissionId,
|
||||
questionId: a.questionId,
|
||||
answer: a.answer,
|
||||
}));
|
||||
await homeworkRepository.createAnswers(answerRecords, tx);
|
||||
}
|
||||
|
||||
const event = buildEvent({
|
||||
aggregateId: homeworkId,
|
||||
eventType: "homework.submitted",
|
||||
payload: {
|
||||
homeworkId,
|
||||
submissionId,
|
||||
studentId,
|
||||
isLate,
|
||||
},
|
||||
userId: studentId,
|
||||
});
|
||||
await outboxRepository.create(
|
||||
{
|
||||
id: randomUUID(),
|
||||
eventId: event.event_id,
|
||||
aggregateId: homeworkId,
|
||||
aggregateType: "homework",
|
||||
eventType: "homework.submitted",
|
||||
occurredAt: new Date(event.occurred_at),
|
||||
payload: serializeEvent(event),
|
||||
status: "pending",
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
return { submissionId };
|
||||
} finally {
|
||||
await releaseLock(lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
async gradeHomework(
|
||||
homeworkId: string,
|
||||
submissionId: string,
|
||||
scores: ScoreInput[],
|
||||
feedback: string | undefined,
|
||||
gradedBy: string,
|
||||
): Promise<{ totalScore: string }> {
|
||||
const hw = await homeworkRepository.findById(homeworkId);
|
||||
if (!hw) {
|
||||
throw new NotFoundError(`Homework ${homeworkId} not found`);
|
||||
}
|
||||
const submission =
|
||||
await homeworkRepository.findSubmissionById(submissionId);
|
||||
if (!submission || submission.homeworkId !== homeworkId) {
|
||||
throw new NotFoundError(
|
||||
`Submission ${submissionId} not found for homework ${homeworkId}`,
|
||||
);
|
||||
}
|
||||
if (submission.status === "graded") {
|
||||
throw new ConflictError(`Submission ${submissionId} already graded`);
|
||||
}
|
||||
|
||||
const totalScore = scores.reduce((sum, s) => sum + Number(s.score), 0);
|
||||
const totalScoreStr = totalScore.toFixed(2);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(homework)
|
||||
.set({ status: "submitted" })
|
||||
.where(eq(homework.id, id));
|
||||
await homeworkRepository.updateSubmission(submissionId, {
|
||||
status: "graded",
|
||||
gradedAt: new Date(),
|
||||
gradedBy,
|
||||
totalScore: totalScore.toFixed(2),
|
||||
feedback,
|
||||
});
|
||||
|
||||
const event = buildEvent({
|
||||
aggregateId: homeworkId,
|
||||
eventType: "homework.graded",
|
||||
payload: {
|
||||
homeworkId,
|
||||
submissionId,
|
||||
studentId: submission.studentId,
|
||||
totalScore: totalScoreStr,
|
||||
},
|
||||
userId: gradedBy,
|
||||
});
|
||||
await outboxRepository.create(
|
||||
{
|
||||
id: randomUUID(),
|
||||
aggregateId: id,
|
||||
eventId: event.event_id,
|
||||
aggregateId: homeworkId,
|
||||
aggregateType: "homework",
|
||||
eventType: "homework.submitted",
|
||||
payload: JSON.stringify({ id }),
|
||||
eventType: "homework.graded",
|
||||
occurredAt: new Date(event.occurred_at),
|
||||
payload: serializeEvent(event),
|
||||
status: "pending",
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
return { totalScore: totalScoreStr };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user