diff --git a/src/audit/index.ts b/src/audit/index.ts index c321ea2..be18f3c 100644 --- a/src/audit/index.ts +++ b/src/audit/index.ts @@ -34,6 +34,7 @@ type AuditEvent = | "course.module.deleted" | "course.module.reordered" | "quiz.feedback.submitted" + | "quiz.deleted_by_admin" | "announcement.created" | "announcement.updated" | "announcement.deleted" @@ -44,6 +45,7 @@ type AuditEvent = interface AuditFields { userId?: string; + quizId?: string; submissionId?: string; credentialId?: string; courseId?: string; diff --git a/src/modules/courses/admin-course.controller.ts b/src/modules/courses/admin-course.controller.ts index d86f6f8..43f8767 100644 --- a/src/modules/courses/admin-course.controller.ts +++ b/src/modules/courses/admin-course.controller.ts @@ -1,5 +1,6 @@ import type { FastifyRequest, FastifyReply } from "fastify"; import { courseService } from "./course.service.js"; +import { quizService } from "../quizzes/quiz.service.js"; import { ValidationError } from "../../utils/errors.js"; import { importCourseSchema } from "./course.types.js"; import type { @@ -261,6 +262,25 @@ export class AdminCourseController { reply.send({ success: true, data: modules }); } + + /** + * DELETE /api/v1/admin/courses/:id/modules/:moduleId/quizzes/:quizId + * Delete a quiz and all its submissions atomically (#414). + */ + async deleteQuiz( + request: FastifyRequest<{ + Params: { id: string; moduleId: string; quizId: string }; + }>, + reply: FastifyReply + ): Promise { + const { id, moduleId, quizId } = request.params; + const result = await quizService.deleteQuizByAdmin(id, moduleId, quizId); + + reply.send({ + success: true, + data: { deletedSubmissions: result.deletedSubmissions }, + }); + } } export const adminCourseController = new AdminCourseController(); diff --git a/src/modules/courses/admin-course.routes.ts b/src/modules/courses/admin-course.routes.ts index 1162124..7fc2ea8 100644 --- a/src/modules/courses/admin-course.routes.ts +++ b/src/modules/courses/admin-course.routes.ts @@ -380,6 +380,28 @@ export async function adminCourseRoutes(app: FastifyInstance): Promise { }, } as FastifySchema, }, - (request, reply) => adminCourseController.enrollmentTrends(request, reply) + (request, reply) => adminCourseController.enrollmentTrends(request, reply), + ); + + app.delete<{ Params: { id: string; moduleId: string; quizId: string } }>( + "/:id/modules/:moduleId/quizzes/:quizId", + { + schema: { + description: + "Delete a quiz and all its submissions atomically (admin only, #414)", + tags: ["admin", "courses"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + required: ["id", "moduleId", "quizId"], + properties: { + id: { type: "string", format: "uuid" }, + moduleId: { type: "string", minLength: 1, maxLength: 100 }, + quizId: { type: "string", format: "uuid" }, + }, + }, + } as FastifySchema, + }, + (request, reply) => adminCourseController.deleteQuiz(request, reply) ); } diff --git a/src/modules/quizzes/quiz.service.ts b/src/modules/quizzes/quiz.service.ts index 3d56f5e..9dfa33f 100644 --- a/src/modules/quizzes/quiz.service.ts +++ b/src/modules/quizzes/quiz.service.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { eq, and } from "drizzle-orm"; +import { eq, and, sql } from "drizzle-orm"; import { db } from "../../config/database.js"; import { quizzes, quizSubmissions, quizFeedback, enrollments } from "../../database/schema.js"; import { @@ -935,6 +935,64 @@ export class QuizService { ...(incorrectFeedback && { incorrectFeedback }), })); } + + /** + * Delete a quiz together with all of its submissions, in one DB + * transaction (#414). Foreign-key cascades already remove submissions and + * feedback when the quiz row goes away, but the transaction makes the + * whole delete atomic and lets us count the submissions first for the + * audit entry — a plain cascade delete would leave no trace of how many + * attempts were destroyed. + */ + async deleteQuizByAdmin(courseId: string, moduleId: string, quizId: string): Promise<{ + deletedSubmissions: number; + }> { + return withLock(`quiz-delete:${quizId}`, async () => { + const result = await db.transaction(async (tx) => { + const [quiz] = await tx + .select() + .from(quizzes) + .where(eq(quizzes.id, quizId)); + + if (!quiz) { + throw new NotFoundError("Quiz"); + } + if (quiz.courseId !== courseId || quiz.moduleId !== moduleId) { + throw new NotFoundError("Quiz not in this course/module"); + } + + const [submissionCount] = await tx + .select({ value: sql`count(*)`.mapWith(Number) }) + .from(quizSubmissions) + .where(eq(quizSubmissions.quizId, quizId)); + + await tx.delete(quizzes).where(eq(quizzes.id, quizId)); + + return { deletedSubmissions: submissionCount?.value ?? 0 }; + }); + + await auditLog("quiz.deleted_by_admin", { + courseId, + moduleId, + quizId, + total: result.deletedSubmissions, + }); + logger.info( + { courseId, moduleId, quizId, deletedSubmissions: result.deletedSubmissions }, + "Quiz deleted by admin" + ); + + // Cached per-module/per-user keys can't be enumerated ahead of time — + // every submitter's progress/stats may embed this quiz. Invalidate the + // aggregate stats cache (course-scoped and global) and let the 30s/60s + // per-user keys age out on their own, same as retryQuiz does. + await cacheInvalidatePattern(cacheKeyPattern("quizzes", "stats")); + await cacheDel(cacheKey("quizzes", "stats", courseId)); + await cacheDel(cacheKey("quizzes", "stats", "all")); + + return result; + }); + } } -export const quizService = new QuizService(); +export const quizService = new QuizService(); \ No newline at end of file diff --git a/tests/unit/quizzes/admin-delete-quiz.test.ts b/tests/unit/quizzes/admin-delete-quiz.test.ts new file mode 100644 index 0000000..81fb523 --- /dev/null +++ b/tests/unit/quizzes/admin-delete-quiz.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../src/config/database.js", () => { + const mockDb = { + select: vi.fn(), + delete: vi.fn(), + transaction: vi.fn(), + }; + return { db: mockDb }; +}); + +vi.mock("../../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +vi.mock("../../../src/utils/lock.js", () => ({ + withLock: vi.fn(async (_key: string, fn: () => Promise) => fn()), +})); + +vi.mock("../../../src/audit/index.js", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../../src/cache/index.js", () => ({ + cacheGet: vi.fn().mockResolvedValue(null), + cacheSet: vi.fn().mockResolvedValue(undefined), + cacheDel: vi.fn().mockResolvedValue(undefined), + cacheInvalidatePattern: vi.fn().mockResolvedValue(undefined), + cacheKey: (...parts: (string | number)[]) => parts.join(":"), + cacheKeyPattern: (...parts: (string | number)[]) => `${parts.join(":")}:*`, +})); + +vi.mock("../../../src/config/redis.js", () => ({ + redis: { incr: vi.fn(), expire: vi.fn(), ttl: vi.fn() }, +})); + +vi.mock("../../../src/modules/quizzes/ai-client.js", () => ({ + generateQuizFromAI: vi.fn(), +})); + +vi.mock("../../../src/services/webhook-dispatcher.js", () => ({ + dispatchWebhook: vi.fn(), +})); + +vi.mock("../../../src/stellar/signatures.js", () => ({ + createQuizProof: vi.fn(), +})); + +import { db } from "../../../src/config/database.js"; +import { auditLog } from "../../../src/audit/index.js"; +import { cacheInvalidatePattern, cacheDel } from "../../../src/cache/index.js"; +import { quizService } from "../../../src/modules/quizzes/quiz.service.js"; +import { NotFoundError } from "../../../src/utils/errors.js"; + +const mockDb = vi.mocked(db); + +function quizRow(overrides: Record = {}) { + return { + id: "quiz-1", + courseId: "course-1", + moduleId: "m1", + questions: [], + generatedFor: null, + createdAt: new Date(), + ...overrides, + }; +} + +describe("QuizService.deleteQuizByAdmin (#414)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("deletes the quiz inside a transaction and audits the submission count", async () => { + const submissionCountRows = [{ value: 5 }]; + const txChain = { + select: vi.fn().mockReturnValue({ + from: vi.fn() + .mockReturnValueOnce({ + where: vi.fn().mockResolvedValue([quizRow()]), + }) + .mockReturnValueOnce({ + where: vi.fn().mockResolvedValue(submissionCountRows), + }), + }), + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }; + mockDb.transaction.mockImplementationOnce(async (fn: any) => fn(txChain)); + + const result = await quizService.deleteQuizByAdmin("course-1", "m1", "quiz-1"); + + expect(result).toEqual({ deletedSubmissions: 5 }); + expect(txChain.delete).toHaveBeenCalled(); + expect(auditLog).toHaveBeenCalledWith( + "quiz.deleted_by_admin", + expect.objectContaining({ courseId: "course-1", moduleId: "m1", quizId: "quiz-1", total: 5 }), + ); + }); + + it("throws NotFoundError when the quiz does not exist", async () => { + const txChain = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), + delete: vi.fn(), + }; + mockDb.transaction.mockImplementationOnce(async (fn: any) => fn(txChain)); + + await expect( + quizService.deleteQuizByAdmin("course-1", "m1", "missing-quiz"), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + it("throws NotFoundError when the quiz belongs to a different course/module", async () => { + const txChain = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([quizRow({ courseId: "other-course", moduleId: "m2" })]), + }), + }), + delete: vi.fn(), + }; + mockDb.transaction.mockImplementationOnce(async (fn: any) => fn(txChain)); + + await expect( + quizService.deleteQuizByAdmin("course-1", "m1", "quiz-1"), + ).rejects.toBeInstanceOf(NotFoundError); + // Guard path — the delete must never be reached. + expect(txChain.delete).not.toHaveBeenCalled(); + }); + + it("invalidates the aggregate quiz-stats caches after a delete", async () => { + const txChain = { + select: vi.fn().mockReturnValue({ + from: vi.fn() + .mockReturnValueOnce({ where: vi.fn().mockResolvedValue([quizRow()]) }) + .mockReturnValueOnce({ where: vi.fn().mockResolvedValue([{ value: 0 }]) }), + }), + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }; + mockDb.transaction.mockImplementationOnce(async (fn: any) => fn(txChain)); + + await quizService.deleteQuizByAdmin("course-1", "m1", "quiz-1"); + + expect(cacheInvalidatePattern).toHaveBeenCalledWith("quizzes:stats:*"); + expect(cacheDel).toHaveBeenCalledWith("quizzes:stats:course-1"); + expect(cacheDel).toHaveBeenCalledWith("quizzes:stats:all"); + }); +});