diff --git a/src/modules/courses/admin-course.routes.ts b/src/modules/courses/admin-course.routes.ts index 25c09cb..d175286 100644 --- a/src/modules/courses/admin-course.routes.ts +++ b/src/modules/courses/admin-course.routes.ts @@ -10,6 +10,7 @@ import { adminUpdateQuizSchema, authoredQuestionSchema, authoredQuizSchema, + archiveModuleQuizSchema, } from "../quizzes/quiz.types.js"; import { createCourseSchema, @@ -811,6 +812,66 @@ export async function adminCourseRoutes(app: FastifyInstance): Promise { (request, reply) => quizController.deleteModuleQuiz(request, reply), ); + app.post<{ + Params: { id: string; moduleId: string; quizId: string }; + Body: import("../quizzes/quiz.types.js").ArchiveModuleQuizBody; + }>( + "/:id/modules/:moduleId/quizzes/:quizId/archive", + { + preHandler: [ + validate({ + params: adminQuizParamsSchema, + body: archiveModuleQuizSchema, + }), + ], + schema: { + description: + "Archive a quiz: hides it from user-facing quiz lists while preserving the quiz and its submissions. Send { \"archived\": false } to unarchive. A thin wrapper over the same atomic, audit-logged write POST .../quizzes/:quizId performs with an `archived` field — this gives the action its own discoverable URL (admin only, #416)", + tags: ["admin", "courses", "quizzes"], + 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" }, + }, + }, + body: { + type: "object", + properties: { + archived: { type: "boolean", default: true }, + }, + }, + } as FastifySchema, + }, + (request, reply) => quizController.archiveModuleQuiz(request, reply), + ); + + app.get<{ Params: { id: string; moduleId: string; quizId: string } }>( + "/:id/modules/:moduleId/quizzes/:quizId/analytics", + { + preHandler: [validate({ params: adminQuizParamsSchema })], + schema: { + description: + "Detailed analytics for one quiz: question-by-question correct rate and most common wrong answer, score distribution, and attempt patterns (current vs superseded retries). Per-question timing is not tracked at the data layer today and is always reported as unavailable rather than fabricated (admin only, cached 5 minutes, #417)", + tags: ["admin", "courses", "quizzes"], + 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) => quizController.getQuizAnalytics(request, reply), + ); + app.delete<{ Params: { id: string; moduleId: string; contentId: string } }>( "/:id/modules/:moduleId/content/:contentId", { diff --git a/src/modules/courses/course.routes.ts b/src/modules/courses/course.routes.ts index ad1e5e8..798f21f 100644 --- a/src/modules/courses/course.routes.ts +++ b/src/modules/courses/course.routes.ts @@ -503,4 +503,30 @@ export async function courseRoutes(app: FastifyInstance): Promise { }, (request, reply) => waitlistController.getStatus(request, reply) ); + + app.get<{ Params: { id: string; moduleId: string } }>( + "/:id/modules/:moduleId/quiz-history", + { + preHandler: [ + authGuard, + adminGuard, + validate({ params: moduleParamsSchema }), + ], + schema: { + description: + "Aggregate quiz stats for every quiz in a course module across all users: average score, pass rate, total attempts, score distribution (admin only, cached 5 minutes, #415)", + tags: ["courses", "admin", "quizzes"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + required: ["id", "moduleId"], + properties: { + id: { type: "string", format: "uuid" }, + moduleId: { type: "string", minLength: 1, maxLength: 100 }, + }, + }, + } as FastifySchema, + }, + (request, reply) => quizController.getModuleQuizHistory(request, reply) + ); } diff --git a/src/modules/quizzes/quiz.controller.ts b/src/modules/quizzes/quiz.controller.ts index 4ae6b77..0997c36 100644 --- a/src/modules/quizzes/quiz.controller.ts +++ b/src/modules/quizzes/quiz.controller.ts @@ -14,6 +14,7 @@ import type { AdminQuizUpdateBody, AuthoredQuizBody, AuthoredQuestion, + ArchiveModuleQuizBody, } from "./quiz.types.js"; export class QuizController { @@ -244,6 +245,22 @@ export class QuizController { /** * POST /api/v1/admin/courses/:id/modules/:moduleId/quizzes/:quizId/archive + * Archive (or unarchive) a quiz via its own endpoint (admin only, #416). + */ + async archiveModuleQuiz( + request: FastifyRequest<{ + Params: AdminQuizParams; + Body: ArchiveModuleQuizBody; + }>, + reply: FastifyReply + ): Promise { + const { id, moduleId, quizId } = request.params; + const quiz = await quizService.archiveModuleQuiz( + id, + moduleId, + quizId, + request.body.archived, + ); * Archive a quiz without deleting it (admin only, #416). Thin wrapper * around the same archive path updateModuleQuizDetails already supports. */ @@ -261,6 +278,7 @@ export class QuizController { /** * GET /api/v1/courses/:id/modules/:moduleId/quiz-history + * Aggregate quiz stats for every quiz in a course module (admin only, #415). * Aggregate quiz performance across a module's quizzes (admin only, #415). */ async getModuleQuizHistory( @@ -275,6 +293,7 @@ export class QuizController { /** * GET /api/v1/admin/courses/:id/modules/:moduleId/quizzes/:quizId/analytics + * Detailed question-by-question analytics for one quiz (admin only, #417). * Question-by-question analytics for a single quiz (admin only, #417). */ async getQuizAnalytics( diff --git a/src/modules/quizzes/quiz.service.ts b/src/modules/quizzes/quiz.service.ts index fd7ab7f..00ae7da 100644 --- a/src/modules/quizzes/quiz.service.ts +++ b/src/modules/quizzes/quiz.service.ts @@ -33,6 +33,7 @@ import { cacheKey, cacheKeyPattern, cacheInvalidatePattern, + cacheGetOrSet, } from "../../cache/index.js"; import { PASSING_PERCENTAGE, @@ -57,6 +58,7 @@ import { type ModuleQuizHistory, type QuizAnalytics, type QuizQuestionAnalytics, + type ScoreDistribution, } from "./quiz.types.js"; const QUIZ_STATS_TTL_SECONDS = 300; @@ -1101,6 +1103,11 @@ export class QuizService { * mean fanning a SCAN or a per-enrollee write over the whole roster for * every authoring change. Its own 60s TTL bounds the staleness — the same * tradeoff QuizService.submitQuiz already makes for a submission. + * + * Also clears getModuleQuizHistory's (#415) and getQuizAnalytics's (#417) + * 5-minute caches course-wide via pattern match, since a write here + * (archiving via #416 in particular) changes which submissions count + * toward both views and which quizzes are even eligible. */ private async invalidateQuizCaches(courseId: string): Promise { const invalidations = await Promise.allSettled([ @@ -1113,6 +1120,9 @@ export class QuizService { // Enrollment status derives its module list from quizzes. cacheInvalidatePattern(cacheKeyPattern("user", "enrollment-status")), cacheDel(cacheKey("courses", "detail", courseId)), + // #415/#417: module-history and per-quiz analytics aggregates. + cacheInvalidatePattern(cacheKeyPattern("quizzes", "module-history")), + cacheInvalidatePattern(cacheKeyPattern("quizzes", "analytics")), ]); const failed = invalidations.filter((r) => r.status === "rejected"); if (failed.length > 0) { @@ -1581,6 +1591,289 @@ export class QuizService { return row?.value ?? 0; } + /** + * Archive or unarchive a quiz via a dedicated endpoint (#416). + * + * This is a thin wrapper over updateModuleQuizDetails (#413) rather than + * new logic: archiving is already atomic (single UPDATE inside a row + * lock), already hides the quiz from learners (isNull(quizzes.archivedAt) + * filters in generateQuiz/submitQuiz), already preserves submissions + * (nothing is deleted), and already audit-logs via "course.quiz.updated" + * with changes: ["archived"]. #416 asks for the action to live at its own + * URL (POST .../quizzes/:quizId/archive) for discoverability — an admin + * client shouldn't need to know a generic update endpoint doubles as an + * archive action — but the underlying write is intentionally the same + * code path so there is exactly one place that knows how to archive a + * quiz. + */ + async archiveModuleQuiz( + courseId: string, + moduleId: string, + quizId: string, + archived: boolean, + ): Promise { + return this.updateModuleQuizDetails(courseId, moduleId, quizId, { + archived, + }); + } + + /** + * Aggregate quiz stats across every quiz in a course module (#415): + * average score, pass rate, total attempts, and a score distribution. + * Cached for 5 minutes, keyed by courseId+moduleId so different modules + * don't collide (mirrors getQuizStats's cache-aside pattern above). + * + * Scoped to this module's quizzes specifically (unlike getQuizStats, + * which is course- or platform-wide) — a course creator reviewing one + * module's difficulty shouldn't have other modules' scores mixed in. + * Superseded submissions (#295 retries) are excluded for the same reason + * getQuizStats excludes them: a retried quiz's stale attempt would + * otherwise double-count against the same learner's real result. + */ + async getModuleQuizHistory( + courseId: string, + moduleId: string, + ): Promise { + await this.assertModuleBelongsToCourse(courseId, moduleId); + + const cacheKeyString = cacheKey( + "quizzes", + "module-history", + courseId, + moduleId, + ); + + return cacheGetOrSet( + "quizzes", + cacheKeyString, + async () => { + const rows = await db + .select({ + score: quizSubmissions.score, + questions: quizzes.questions, + quizId: quizzes.id, + }) + .from(quizSubmissions) + .innerJoin(quizzes, eq(quizSubmissions.quizId, quizzes.id)) + .where( + and( + eq(quizzes.courseId, courseId), + eq(quizzes.moduleId, moduleId), + eq(quizSubmissions.superseded, false), + ), + ); + + const percentages = rows.map((row) => { + const totalQuestions = Array.isArray(row.questions) + ? row.questions.length + : 0; + return totalQuestions > 0 && row.score != null + ? Math.round((row.score / totalQuestions) * 100) + : 0; + }); + + const quizIds = new Set(rows.map((row) => row.quizId)); + const passCount = percentages.filter( + (p) => p >= PASSING_PERCENTAGE, + ).length; + const totalAttempts = percentages.length; + + return { + courseId, + moduleId, + quizCount: quizIds.size, + totalAttempts, + averageScore: + totalAttempts > 0 + ? Math.round( + percentages.reduce((sum, p) => sum + p, 0) / totalAttempts, + ) + : 0, + passRate: + totalAttempts > 0 + ? Math.round((passCount / totalAttempts) * 100) + : 0, + scoreDistribution: this.buildScoreDistribution(percentages), + }; + }, + QUIZ_STATS_TTL_SECONDS, + ); + } + + /** + * Detailed analytics for one specific quiz (#417): question-by-question + * correct rate and most common wrong answer, score distribution, and + * attempt patterns (current vs superseded, using quizSubmissions.superseded + * the same way retryQuiz sets it). Cached for 5 minutes like + * getModuleQuizHistory. + * + * Unlike getModuleQuizHistory, superseded submissions are NOT excluded + * from the top-level attempt counts (totalAttempts includes them, + * currentAttempts/supersededAttempts break them out) — #417 explicitly + * asks for "attempt patterns (e.g. retry counts)", which requires seeing + * both current and superseded rows rather than filtering superseded ones + * out. Score distribution and the per-question breakdown are computed + * only from current (non-superseded) submissions, matching + * getModuleQuizHistory's and getQuizStats's convention that "the score" + * for a learner is their current attempt, not a stale retried one. + * + * Per-question average time is not derivable: quizSubmissions.answers only + * stores { questionId, selectedIndex } per answer (submitQuizSchema), with + * no per-answer timestamp captured anywhere in submitQuiz. Rather than + * fabricate a number, averageTimeSeconds is always null and + * perQuestionTimingAvailable is false on the response so callers can + * render "not tracked" instead of misreading a missing value as 0. + */ + async getQuizAnalytics( + courseId: string, + moduleId: string, + quizId: string, + ): Promise { + const quiz = await this.assertQuizInModule(courseId, moduleId, quizId); + + const cacheKeyString = cacheKey("quizzes", "analytics", quizId); + + return cacheGetOrSet( + "quizzes", + cacheKeyString, + async () => { + const submissions = await db + .select({ + score: quizSubmissions.score, + answers: quizSubmissions.answers, + superseded: quizSubmissions.superseded, + }) + .from(quizSubmissions) + .where(eq(quizSubmissions.quizId, quizId)); + + const questions = ( + Array.isArray(quiz.questions) ? quiz.questions : [] + ) as StoredQuestion[]; + const totalQuestions = questions.length; + + const current = submissions.filter((s) => !s.superseded); + const superseded = submissions.filter((s) => s.superseded); + + const percentages = current.map((row) => + totalQuestions > 0 && row.score != null + ? Math.round((row.score / totalQuestions) * 100) + : 0, + ); + const passCount = percentages.filter( + (p) => p >= PASSING_PERCENTAGE, + ).length; + + const questionAnalytics = questions.map((question) => + this.buildQuestionAnalytics(question, current), + ); + + return { + quizId, + courseId, + moduleId, + totalAttempts: submissions.length, + currentAttempts: current.length, + supersededAttempts: superseded.length, + averageScore: + percentages.length > 0 + ? Math.round( + percentages.reduce((sum, p) => sum + p, 0) / + percentages.length, + ) + : 0, + passRate: + percentages.length > 0 + ? Math.round((passCount / percentages.length) * 100) + : 0, + scoreDistribution: this.buildScoreDistribution(percentages), + questions: questionAnalytics, + perQuestionTimingAvailable: false, + }; + }, + QUIZ_STATS_TTL_SECONDS, + ); + } + + /** + * Correct rate and most common wrong answer for one question, across a + * set of (non-superseded) submissions. A submission that never answered + * this questionId (skipped, or an unrecognized id per submitQuiz's + * lenient handling of stale questionIds) simply doesn't contribute a row + * — `totalAnswered` is how many submissions actually included this + * question, not the submission count. + */ + private buildQuestionAnalytics( + question: StoredQuestion, + submissions: Array<{ answers: unknown }>, + ): QuizQuestionAnalytics { + let totalAnswered = 0; + let correctCount = 0; + const wrongAnswerCounts = new Map(); + + for (const submission of submissions) { + const answers = Array.isArray(submission.answers) + ? (submission.answers as Array<{ + questionId: string; + selectedIndex: number; + }>) + : []; + const answer = answers.find((a) => a?.questionId === question.id); + if (!answer) continue; + + totalAnswered++; + if (answer.selectedIndex === question.correctIndex) { + correctCount++; + } else { + wrongAnswerCounts.set( + answer.selectedIndex, + (wrongAnswerCounts.get(answer.selectedIndex) ?? 0) + 1, + ); + } + } + + let commonWrongAnswer: QuizQuestionAnalytics["commonWrongAnswer"] = null; + for (const [selectedIndex, count] of wrongAnswerCounts) { + if (!commonWrongAnswer || count > commonWrongAnswer.count) { + commonWrongAnswer = { selectedIndex, count }; + } + } + + return { + questionId: question.id, + totalAnswered, + correctCount, + correctRate: + totalAnswered > 0 ? Math.round((correctCount / totalAnswered) * 100) : 0, + commonWrongAnswer, + averageTimeSeconds: null, + }; + } + + /** Buckets a list of percentage scores (0-100) into + * SCORE_DISTRIBUTION_BUCKETS. Shared by getModuleQuizHistory (#415) and + * getQuizAnalytics (#417) so both endpoints report distribution the same + * way. */ + private buildScoreDistribution(percentages: number[]): ScoreDistribution { + const distribution: ScoreDistribution = { + "0-20": 0, + "21-40": 0, + "41-60": 0, + "61-80": 0, + "81-100": 0, + }; + + for (const percentage of percentages) { + const clamped = Math.max(0, Math.min(100, percentage)); + if (clamped <= 20) distribution["0-20"]++; + else if (clamped <= 40) distribution["21-40"]++; + else if (clamped <= 60) distribution["41-60"]++; + else if (clamped <= 80) distribution["61-80"]++; + else distribution["81-100"]++; + } + + return distribution; + } + /** * Load a quiz and assert it belongs to the course + module in the URL. * Scoping by all three means a quiz id from another course reports 404 diff --git a/src/modules/quizzes/quiz.types.ts b/src/modules/quizzes/quiz.types.ts index c8d5d3a..9621a11 100644 --- a/src/modules/quizzes/quiz.types.ts +++ b/src/modules/quizzes/quiz.types.ts @@ -421,3 +421,84 @@ export interface AdminQuizDeleteResult { claimedRewardsDeleted: number; deletedAt: Date; } + +// ─── Admin: Archive (#416) ─────────────────────────────────────────────────── + +/** POST body for the dedicated archive endpoint (#416). Defaults to `true` + * (archive) — most callers of a POST .../archive route want to archive; + * passing `{ archived: false }` un-archives through the same route so + * there's one endpoint for the whole toggle rather than a second route + * just for the reverse action. */ +export const archiveModuleQuizSchema = z.object({ + archived: z.boolean().optional().default(true), +}); +export type ArchiveModuleQuizBody = z.infer; + +// ─── Admin: Quiz History & Analytics (#415, #417) ──────────────────────────── + +/** Score buckets shared by the module quiz-history (#415) and single-quiz + * analytics (#417) endpoints, so the two "score distribution" shapes read + * the same way in the admin UI. Percentage-based (0-100), five even 20-point + * bands — there's no existing bucketing convention elsewhere in the + * codebase (getFeedbackSummary counts by feedback `type`, not by score), so + * this is a new, deliberately simple default. */ +export const SCORE_DISTRIBUTION_BUCKETS = [ + "0-20", + "21-40", + "41-60", + "61-80", + "81-100", +] as const; +export type ScoreDistributionBucket = (typeof SCORE_DISTRIBUTION_BUCKETS)[number]; +export type ScoreDistribution = Record; + +/** Aggregate stats across every non-superseded submission for every quiz in + * one course module (#415). `quizCount` is how many distinct quizzes (across + * all learners, since AI-generated quizzes are per-learner) contributed. */ +export interface ModuleQuizHistory { + courseId: string; + moduleId: string; + quizCount: number; + totalAttempts: number; + averageScore: number; + passRate: number; + scoreDistribution: ScoreDistribution; +} + +/** Per-question performance within one quiz (#417). `averageTimeSeconds` is + * always null today — see the note on QuizAnalytics. `commonWrongAnswer` is + * the selectedIndex most often chosen by learners who got the question + * wrong, or null if nobody answered it incorrectly (or nobody answered it + * at all). */ +export interface QuizQuestionAnalytics { + questionId: string; + totalAnswered: number; + correctCount: number; + correctRate: number; + commonWrongAnswer: { selectedIndex: number; count: number } | null; + averageTimeSeconds: null; +} + +/** Detailed analytics for one specific quiz (#417): question-by-question + * breakdown, score distribution, and attempt patterns (current vs + * superseded/retried attempts, using quizSubmissions.superseded — see + * QuizService.retryQuiz). `perQuestionTimingAvailable` is always false: + * quizSubmissions.answers only stores { questionId, selectedIndex } (see + * submitQuizSchema) with no per-answer timestamp, so per-question timing + * cannot be derived from data actually captured today. It's surfaced + * explicitly here rather than omitted, so a caller can render "not + * tracked" instead of assuming a missing/zero value means "0 seconds". + */ +export interface QuizAnalytics { + quizId: string; + courseId: string; + moduleId: string; + totalAttempts: number; + currentAttempts: number; + supersededAttempts: number; + averageScore: number; + passRate: number; + scoreDistribution: ScoreDistribution; + questions: QuizQuestionAnalytics[]; + perQuestionTimingAvailable: false; +} diff --git a/tests/unit/quizzes/admin-quiz-analytics.test.ts b/tests/unit/quizzes/admin-quiz-analytics.test.ts new file mode 100644 index 0000000..a029344 --- /dev/null +++ b/tests/unit/quizzes/admin-quiz-analytics.test.ts @@ -0,0 +1,471 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../src/config/database.js", () => { + const mockDb = { + select: vi.fn(), + transaction: vi.fn(), + query: { + quizzes: { findFirst: vi.fn() }, + courses: { findFirst: vi.fn() }, + enrollments: { findFirst: vi.fn() }, + }, + }; + return { db: mockDb }; +}); + +vi.mock("../../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), error: vi.fn(), warn: 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), +})); + +const cacheStore = new Map(); + +vi.mock("../../../src/cache/index.js", () => ({ + cacheGet: vi.fn(async (_namespace: string, key: string) => cacheStore.get(key) ?? null), + cacheSet: vi.fn(async (key: string, value: unknown) => { + cacheStore.set(key, value); + }), + cacheDel: vi.fn().mockResolvedValue(undefined), + cacheKey: (...parts: (string | number)[]) => `chainlearn:${parts.join(":")}`, + cacheKeyPattern: (...parts: (string | number)[]) => `chainlearn:${parts.join(":")}:*`, + cacheInvalidatePattern: vi.fn().mockResolvedValue(undefined), + cacheGetOrSet: vi.fn( + async ( + _namespace: string, + key: string, + fetchFn: () => Promise, + ) => { + const cached = cacheStore.get(key); + if (cached !== undefined) return cached; + const value = await fetchFn(); + cacheStore.set(key, value); + return value; + }, + ), +})); + +import { db } from "../../../src/config/database.js"; +import { auditLog } from "../../../src/audit/index.js"; +import { quizService } from "../../../src/modules/quizzes/quiz.service.js"; +import { NotFoundError } from "../../../src/utils/errors.js"; +import { archiveModuleQuizSchema } from "../../../src/modules/quizzes/quiz.types.js"; + +describe("archiveModuleQuizSchema (#416)", () => { + it("defaults to archived: true when the body is empty", () => { + const result = archiveModuleQuizSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.archived).toBe(true); + } + }); + + it("accepts an explicit archived: false to unarchive", () => { + const result = archiveModuleQuizSchema.safeParse({ archived: false }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.archived).toBe(false); + } + }); + + it("rejects a non-boolean archived value", () => { + const result = archiveModuleQuizSchema.safeParse({ archived: "yes" }); + expect(result.success).toBe(false); + }); +}); + +const mockDb = vi.mocked(db, true); + +function makeSelectChain(result: unknown[]) { + const chain: any = {}; + chain.select = vi.fn().mockReturnValue(chain); + chain.from = vi.fn().mockReturnValue(chain); + chain.innerJoin = vi.fn().mockReturnValue(chain); + chain.where = vi.fn().mockResolvedValue(result); + return chain; +} + +const COURSE = { id: "course-1", modules: [{ id: "m1" }] }; + +describe("QuizService.getModuleQuizHistory (#415)", () => { + beforeEach(() => { + vi.clearAllMocks(); + cacheStore.clear(); + }); + + it("404s when the module doesn't belong to the course", async () => { + (mockDb.query.courses.findFirst as any).mockResolvedValue(COURSE); + + await expect( + quizService.getModuleQuizHistory("course-1", "not-a-module"), + ).rejects.toThrow(); + }); + + it("404s when the course doesn't exist", async () => { + (mockDb.query.courses.findFirst as any).mockResolvedValue(undefined); + + await expect( + quizService.getModuleQuizHistory("missing-course", "m1"), + ).rejects.toThrow(NotFoundError); + }); + + it("computes aggregate stats and score distribution across all quizzes in the module", async () => { + (mockDb.query.courses.findFirst as any).mockResolvedValue(COURSE); + const rows = [ + { score: 5, questions: [{}, {}, {}, {}, {}], quizId: "quiz-1" }, // 100% + { score: 4, questions: [{}, {}, {}, {}, {}], quizId: "quiz-1" }, // 80% + { score: 1, questions: [{}, {}, {}, {}, {}], quizId: "quiz-2" }, // 20% + { score: 3, questions: [{}, {}, {}, {}, {}], quizId: "quiz-2" }, // 60% + ]; + mockDb.select.mockReturnValue(makeSelectChain(rows)); + + const history = await quizService.getModuleQuizHistory("course-1", "m1"); + + expect(history.courseId).toBe("course-1"); + expect(history.moduleId).toBe("m1"); + expect(history.quizCount).toBe(2); + expect(history.totalAttempts).toBe(4); + expect(history.averageScore).toBe(Math.round((100 + 80 + 20 + 60) / 4)); + expect(history.passRate).toBe(Math.round((2 / 4) * 100)); // 100% and 80% pass (>=70) + expect(history.scoreDistribution).toEqual({ + "0-20": 1, + "21-40": 0, + "41-60": 1, + "61-80": 1, + "81-100": 1, + }); + }); + + it("returns zeroed stats for a module with zero submissions instead of crashing", async () => { + (mockDb.query.courses.findFirst as any).mockResolvedValue(COURSE); + mockDb.select.mockReturnValue(makeSelectChain([])); + + const history = await quizService.getModuleQuizHistory("course-1", "m1"); + + expect(history).toEqual({ + courseId: "course-1", + moduleId: "m1", + quizCount: 0, + totalAttempts: 0, + averageScore: 0, + passRate: 0, + scoreDistribution: { + "0-20": 0, + "21-40": 0, + "41-60": 0, + "61-80": 0, + "81-100": 0, + }, + }); + }); + + it("serves cached results on a repeated call without re-querying the database", async () => { + (mockDb.query.courses.findFirst as any).mockResolvedValue(COURSE); + mockDb.select.mockReturnValue( + makeSelectChain([{ score: 5, questions: [{}, {}, {}, {}, {}], quizId: "quiz-1" }]), + ); + + await quizService.getModuleQuizHistory("course-1", "m1"); + mockDb.select.mockClear(); + + const cached = await quizService.getModuleQuizHistory("course-1", "m1"); + + expect(mockDb.select).not.toHaveBeenCalled(); + expect(cached.totalAttempts).toBe(1); + }); + + it("keeps different modules in separate cache entries", async () => { + (mockDb.query.courses.findFirst as any).mockResolvedValue({ + id: "course-1", + modules: [{ id: "m1" }, { id: "m2" }], + }); + mockDb.select.mockReturnValue(makeSelectChain([])); + await quizService.getModuleQuizHistory("course-1", "m1"); + + mockDb.select.mockReturnValue( + makeSelectChain([{ score: 5, questions: [{}, {}, {}, {}, {}], quizId: "quiz-2" }]), + ); + const other = await quizService.getModuleQuizHistory("course-1", "m2"); + + expect(other.totalAttempts).toBe(1); + }); +}); + +describe("QuizService.getQuizAnalytics (#417)", () => { + const QUIZ = { + id: "quiz-1", + courseId: "course-1", + moduleId: "m1", + questions: [ + { id: "q1", text: "Q1", options: ["a", "b", "c"], correctIndex: 0 }, + { id: "q2", text: "Q2", options: ["a", "b"], correctIndex: 1 }, + ], + }; + + beforeEach(() => { + vi.clearAllMocks(); + cacheStore.clear(); + }); + + it("404s for a quiz that doesn't belong to the course/module", async () => { + mockDb.select.mockReturnValue(makeSelectChain([])); + + await expect( + quizService.getQuizAnalytics("course-1", "m1", "missing-quiz"), + ).rejects.toThrow(NotFoundError); + }); + + it("computes per-question correct rate, common wrong answer, and score distribution", async () => { + // First select() call is assertQuizInModule's lookup; second is the + // quizSubmissions fetch. Both go through db.select in this service. + let call = 0; + mockDb.select.mockImplementation(() => { + call++; + if (call === 1) return makeSelectChain([QUIZ]); + return makeSelectChain([ + // q1 correct (0), q2 correct (1) -> score 2/2 = 100% + { + score: 2, + answers: [ + { questionId: "q1", selectedIndex: 0 }, + { questionId: "q2", selectedIndex: 1 }, + ], + superseded: false, + }, + // q1 wrong (1), q2 wrong (0) -> score 0/2 = 0% + { + score: 0, + answers: [ + { questionId: "q1", selectedIndex: 1 }, + { questionId: "q2", selectedIndex: 0 }, + ], + superseded: false, + }, + // q1 wrong (1) again -> reinforces selectedIndex 1 as the common wrong answer + { + score: 1, + answers: [ + { questionId: "q1", selectedIndex: 1 }, + { questionId: "q2", selectedIndex: 1 }, + ], + superseded: false, + }, + // A superseded (retried-over) submission — excluded from score stats + // and per-question breakdown, but counted in attempt patterns. + { + score: 0, + answers: [{ questionId: "q1", selectedIndex: 2 }], + superseded: true, + }, + ]); + }); + + const analytics = await quizService.getQuizAnalytics( + "course-1", + "m1", + "quiz-1", + ); + + expect(analytics.quizId).toBe("quiz-1"); + expect(analytics.totalAttempts).toBe(4); + expect(analytics.currentAttempts).toBe(3); + expect(analytics.supersededAttempts).toBe(1); + expect(analytics.perQuestionTimingAvailable).toBe(false); + + const q1 = analytics.questions.find((q) => q.questionId === "q1")!; + expect(q1.totalAnswered).toBe(3); + expect(q1.correctCount).toBe(1); + expect(q1.correctRate).toBe(Math.round((1 / 3) * 100)); + expect(q1.commonWrongAnswer).toEqual({ selectedIndex: 1, count: 2 }); + expect(q1.averageTimeSeconds).toBeNull(); + + const q2 = analytics.questions.find((q) => q.questionId === "q2")!; + expect(q2.totalAnswered).toBe(3); + expect(q2.correctCount).toBe(2); + expect(q2.commonWrongAnswer).toEqual({ selectedIndex: 0, count: 1 }); + + // Scores from current submissions only: 100%, 0%, 50% -> avg 50 + expect(analytics.averageScore).toBe(Math.round((100 + 0 + 50) / 3)); + }); + + it("returns sensible empty stats for a quiz with zero submissions instead of crashing", async () => { + let call = 0; + mockDb.select.mockImplementation(() => { + call++; + if (call === 1) return makeSelectChain([QUIZ]); + return makeSelectChain([]); + }); + + const analytics = await quizService.getQuizAnalytics( + "course-1", + "m1", + "quiz-1", + ); + + expect(analytics.totalAttempts).toBe(0); + expect(analytics.currentAttempts).toBe(0); + expect(analytics.averageScore).toBe(0); + expect(analytics.passRate).toBe(0); + expect(analytics.questions).toHaveLength(2); + for (const question of analytics.questions) { + expect(question.totalAnswered).toBe(0); + expect(question.correctCount).toBe(0); + expect(question.correctRate).toBe(0); + expect(question.commonWrongAnswer).toBeNull(); + } + }); + + it("serves the cached aggregate on a repeated call without re-querying quizSubmissions", async () => { + // getQuizAnalytics always re-verifies the quiz exists in this + // course/module (assertQuizInModule) before consulting the cache — that + // guard is cheap and keeps a deleted/moved quiz from serving a stale + // cached analytics payload. What the cache actually saves is the + // quizSubmissions aggregation query below it. + let quizLookups = 0; + let submissionAggregations = 0; + mockDb.select.mockImplementation(() => { + quizLookups++; + // The Nth call to db.select() alternates: odd calls are + // assertQuizInModule's quiz lookup, even calls (only reached on a + // cache miss) are the submissions aggregation. + if (quizLookups % 2 === 1) { + return makeSelectChain([QUIZ]); + } + submissionAggregations++; + return makeSelectChain([]); + }); + + await quizService.getQuizAnalytics("course-1", "m1", "quiz-1"); + expect(quizLookups).toBe(2); // quiz lookup + submissions aggregation + expect(submissionAggregations).toBe(1); + + const cached = await quizService.getQuizAnalytics( + "course-1", + "m1", + "quiz-1", + ); + + // Exactly one more select() happened (the quiz-existence re-check). + // The submissions aggregation was served from cache, not re-queried. + expect(quizLookups).toBe(3); + expect(submissionAggregations).toBe(1); + expect(cached.quizId).toBe("quiz-1"); + }); +}); + +describe("QuizService.archiveModuleQuiz (#416)", () => { + beforeEach(() => { + vi.clearAllMocks(); + cacheStore.clear(); + }); + + function mockTransaction(existingQuiz: Record | undefined) { + mockDb.transaction.mockImplementation(async (fn: any) => { + const tx: any = {}; + const forUpdate = vi.fn().mockResolvedValue( + existingQuiz ? [existingQuiz] : [], + ); + tx.select = vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ for: forUpdate }), + }), + }); + tx.update = vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([ + { ...existingQuiz, archivedAt: new Date("2026-01-01") }, + ]), + }), + }), + }); + return fn(tx); + }); + } + + it("archives a quiz by delegating to the #413 update path and audit-logs it", async () => { + const existing = { + id: "quiz-1", + courseId: "course-1", + moduleId: "m1", + questions: [{ id: "q1" }], + metadata: {}, + archivedAt: null, + }; + mockTransaction(existing); + mockDb.select.mockReturnValue(makeSelectChain([{ value: 0 }])); + + const result = await quizService.archiveModuleQuiz( + "course-1", + "m1", + "quiz-1", + true, + ); + + expect(result.archivedAt).not.toBeNull(); + expect(auditLog).toHaveBeenCalledWith( + "course.quiz.updated", + expect.objectContaining({ + quizId: "quiz-1", + courseId: "course-1", + moduleId: "m1", + changes: ["archived"], + }), + ); + }); + + it("unarchives a quiz when archived: false is passed", async () => { + const existing = { + id: "quiz-1", + courseId: "course-1", + moduleId: "m1", + questions: [{ id: "q1" }], + metadata: {}, + archivedAt: new Date("2025-01-01"), + }; + mockDb.transaction.mockImplementation(async (fn: any) => { + const tx: any = {}; + tx.select = vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + for: vi.fn().mockResolvedValue([existing]), + }), + }), + }); + tx.update = vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi + .fn() + .mockResolvedValue([{ ...existing, archivedAt: null }]), + }), + }), + }); + return fn(tx); + }); + mockDb.select.mockReturnValue(makeSelectChain([{ value: 0 }])); + + const result = await quizService.archiveModuleQuiz( + "course-1", + "m1", + "quiz-1", + false, + ); + + expect(result.archivedAt).toBeNull(); + }); + + it("404s when the quiz doesn't belong to the course/module", async () => { + mockTransaction(undefined); + + await expect( + quizService.archiveModuleQuiz("course-1", "m1", "missing-quiz", true), + ).rejects.toThrow(NotFoundError); + }); +});