Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -44,6 +45,7 @@ type AuditEvent =

interface AuditFields {
userId?: string;
quizId?: string;
submissionId?: string;
credentialId?: string;
courseId?: string;
Expand Down
20 changes: 20 additions & 0 deletions src/modules/courses/admin-course.controller.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -112,7 +113,7 @@
reply: FastifyReply
): Promise<void> {
const { id } = request.params;
await courseService.archiveCourse(id);

Check failure on line 116 in src/modules/courses/admin-course.controller.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Property 'archiveCourse' does not exist on type 'CourseService'.

reply.send({ success: true, message: "Course archived" });
}
Expand All @@ -126,7 +127,7 @@
reply: FastifyReply
): Promise<void> {
const { id } = request.params;
const course = await courseService.publishCourse(id);

Check failure on line 130 in src/modules/courses/admin-course.controller.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Property 'publishCourse' does not exist on type 'CourseService'.

reply.send({ success: true, data: course });
}
Expand All @@ -140,7 +141,7 @@
reply: FastifyReply
): Promise<void> {
const { id } = request.params;
const course = await courseService.duplicateCourse(id);

Check failure on line 144 in src/modules/courses/admin-course.controller.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Property 'duplicateCourse' does not exist on type 'CourseService'. Did you mean 'updateCourse'?

reply.status(201).send({ success: true, data: course });
}
Expand Down Expand Up @@ -261,6 +262,25 @@

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<void> {
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();
24 changes: 23 additions & 1 deletion src/modules/courses/admin-course.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,28 @@ export async function adminCourseRoutes(app: FastifyInstance): Promise<void> {
},
} 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)
);
}
62 changes: 60 additions & 2 deletions src/modules/quizzes/quiz.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<number>`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();
155 changes: 155 additions & 0 deletions tests/unit/quizzes/admin-delete-quiz.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>) => 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<string, unknown> = {}) {
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");
});
});
Loading