Skip to content
Merged
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
7 changes: 5 additions & 2 deletions src/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ type AuditEvent =
| "course.published"
| "course.duplicated"
| "course.reviewed"
| "course.archived"
| "course.enrollment_dropped"
| "course.waitlist.joined"
| "course.waitlist.left"
| "course.waitlist.notified"
| "user.account_deleted"
| "user.data_exported"
| "course.module.created"
| "course.module.updated"
| "course.module.deleted"
| "course.waitlist.joined"
| "course.waitlist.left"
| "webhook.created"
| "webhook.updated"
| "webhook.deleted"
Expand Down
18 changes: 18 additions & 0 deletions src/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,21 @@ export const batchMintRateLimit: RateLimitOptions = {
},
errorResponseBuilder,
};

/**
* Batch quiz generation (#308) can fan out into up to MAX_BATCH_GENERATE_MODULES
* sequential AI service calls per request — each module's own per-module/hour
* counter (assertGenerationAllowed) already caps the underlying AI load, but
* this route-level limit additionally caps how often the batch endpoint
* itself can be hit, mirroring batchMintRateLimit's rationale for other
* multi-step endpoints.
*/
export const quizBatchGenerationRateLimit: RateLimitOptions = {
max: 5,
timeWindow: "1 minute",
keyGenerator: (request: FastifyRequest) => {
const authReq = request as any;
return authReq.authUser?.id ?? request.ip;
},
errorResponseBuilder,
};
32 changes: 32 additions & 0 deletions src/modules/courses/course.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,38 @@ export class CourseController {
});
}

/**
* GET /api/v1/courses/:id/leaderboard
* Top performers for a course, ranked by average quiz score (#324, #311).
* Restored here — course.service.ts's getLeaderboard was left intact but
* this passthrough was collateral damage of an unrelated upstream merge
* (#440) that stripped several CourseController methods.
*/
async leaderboard(
request: FastifyRequest<{ Params: CourseIdParams }>,
reply: FastifyReply
): Promise<void> {
const { id } = request.params;
const leaderboard = await courseService.getLeaderboard(id);

reply.send({ success: true, data: leaderboard });
}

/**
* DELETE /api/v1/courses/:id/enroll
* Drop the caller's enrollment in a course (#310).
*/
async dropEnrollment(
request: FastifyRequest<{ Params: CourseIdParams }>,
reply: FastifyReply
): Promise<void> {
const { id } = request.params;
const { authUser } = request as AuthenticatedRequest;
await courseService.dropEnrollment(authUser.id, id);

reply.send({ success: true, message: "Enrollment dropped" });
}

/**
* GET /api/courses/:id/modules
* List a course's modules with the authenticated (enrolled) user's
Expand Down
14 changes: 14 additions & 0 deletions src/modules/courses/course.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,20 @@ export async function courseRoutes(app: FastifyInstance): Promise<void> {
(request, reply) => courseController.batchEnroll(request, reply)
);

app.delete<{ Params: { id: string } }>(
"/:id/enroll",
{
preHandler: [authGuard, validate({ params: courseIdParamsSchema })],
schema: {
description: "Drop the caller's enrollment in a course (#310)",
tags: ["courses"],
security: [{ bearerAuth: [] }],
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },
} as FastifySchema,
},
(request, reply) => courseController.dropEnrollment(request, reply)
);

app.get<{ Params: { id: string } }>(
"/:id/prerequisites",
{
Expand Down
85 changes: 85 additions & 0 deletions src/modules/courses/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type {
CourseModuleMetadata,
UpdateCourseBody,
CourseModuleWithProgress,
CourseLeaderboardEntry,
CreateModuleBody,
UpdateModuleBody,
ListReviewsQuery,
Expand All @@ -47,6 +48,8 @@ import type {
} from "./course.types.js";

const POPULAR_COURSES_TTL_SECONDS = 300;
const LEADERBOARD_TTL_SECONDS = 300;
const LEADERBOARD_SIZE = 20;

export class CourseService {
async getStats(): Promise<CourseStats> {
Expand Down Expand Up @@ -547,6 +550,88 @@ export class CourseService {
return { contentHashMismatch };
}

/**
* Drop the caller's enrollment in a course (#310) — the companion action
* to enroll() that this codebase didn't previously have, needed to give
* "a spot opens up" any concrete meaning. The row is hard-deleted
* (matching the "drop a course" language already used in enroll()'s cap
* error) rather than soft-cancelled, since nothing in this codebase reads
* a cancelled-but-not-deleted enrollment.
*/
async dropEnrollment(userId: string, courseId: string): Promise<void> {
await withLock(`enroll:${userId}:${courseId}`, async () => {
const [deleted] = await db
.delete(enrollments)
.where(
and(eq(enrollments.userId, userId), eq(enrollments.courseId, courseId)),
)
.returning();

if (!deleted) {
throw new NotFoundError("Enrollment");
}

const invalidations = await Promise.allSettled([
cacheDel(cacheKey("courses", "detail", courseId)),
cacheDel(cacheKey("courses", "stats")),
cacheDel(cacheKey("user", "progress", userId)),
cacheDel(cacheKey("user", "enrollments", userId)),
cacheInvalidatePattern(cacheKeyPattern("user", "activity", userId)),
]);
const failed = invalidations.filter((r) => r.status === "rejected");
if (failed.length > 0) {
logger.warn(
{ userId, courseId, failedCount: failed.length },
"Post-drop cache invalidation had failures — affected views may serve stale data until their TTL expires",
);
}
});

await auditLog("course.enrollment_dropped", { userId, courseId });
logger.info({ userId, courseId }, "Enrollment dropped");

// Runs after the enroll lock releases — notifying a waitlisted user
// should never extend how long the enrollment lock for this drop is
// held.
await this.notifyNextWaitlisted(courseId);
}

/**
* Identify the user at the head of a course's waitlist so they can be
* told a spot opened up (#310) — called after dropEnrollment(). Uses the
* existing waitlistService (added for #320/#323) rather than reading the
* table directly; that service already owns join/leave/position
* bookkeeping. The identified user stays on the waitlist (not removed)
* until they actually enroll, at which point enroll()'s existing call to
* waitlistService.removeFromWaitlist takes them off.
*
* There's currently no user-facing notifications table to write to (it
* was dropped from schema.ts by an unrelated upstream change) — this
* records the event via the audit log instead, so the signal isn't lost
* and can be wired into a real notification channel once one exists
* again. Best-effort: a failure here never fails the caller's drop.
*/
private async notifyNextWaitlisted(courseId: string): Promise<void> {
try {
const next = await waitlistService.getNextOnWaitlist(courseId);
if (!next) return;

await auditLog("course.waitlist.notified", {
userId: next.userId,
courseId,
});
logger.info(
{ userId: next.userId, courseId },
"Identified next waitlisted user for an open spot",
);
} catch (err) {
logger.warn(
{ err, courseId },
"Failed to identify next waitlisted user — the enrollment drop itself still succeeded",
);
}
}

/**
* Active courses ordered by enrollment count descending, for discovery
* (#293). Cached separately from listCourses() since the sort/shape
Expand Down
16 changes: 16 additions & 0 deletions src/modules/quizzes/quiz.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { quizService } from "./quiz.service.js";
import type { AuthenticatedRequest } from "../../middleware/auth.js";
import type {
GenerateQuizBody,
GenerateQuizBatchBody,
SubmitQuizBody,
QuizIdParams,
QuizStatsQuery,
Expand All @@ -24,6 +25,21 @@ export class QuizController {
reply.status(201).send({ success: true, data: quiz });
}

/**
* POST /api/v1/quizzes/generate-batch
* Generate quizzes for multiple modules of a course in one request (#308).
*/
async generateBatch(
request: FastifyRequest<{ Body: GenerateQuizBatchBody }>,
reply: FastifyReply
): Promise<void> {
const { authUser } = request as AuthenticatedRequest;
const data = request.body;
const results = await quizService.generateQuizBatch(authUser.id, data);

reply.status(201).send({ success: true, data: results });
}

/**
* POST /api/quizzes/:id/submit
* Submit answers for a quiz.
Expand Down
37 changes: 37 additions & 0 deletions src/modules/quizzes/quiz.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import type { FastifyInstance, FastifySchema } from "fastify";
import { quizController } from "./quiz.controller.js";
import { authGuard } from "../../middleware/auth.js";
import { validate } from "../../middleware/validation.js";
import { quizBatchGenerationRateLimit } from "../../middleware/rate-limit.js";
import { config } from "../../config/index.js";
import {
generateQuizSchema,
generateQuizBatchSchema,
submitQuizSchema,
quizIdParamsSchema,
quizStatsQuerySchema,
MAX_BATCH_GENERATE_MODULES,
} from "./quiz.types.js";

/**
Expand Down Expand Up @@ -65,6 +68,40 @@ export async function quizRoutes(app: FastifyInstance): Promise<void> {
(request, reply) => quizController.generate(request, reply)
);

app.post<{ Body: import("./quiz.types.js").GenerateQuizBatchBody }>(
"/generate-batch",
{
// Modules are generated sequentially (#308), so the worst case is
// roughly MAX_BATCH_GENERATE_MODULES times a single generation.
config: {
timeoutMs: config.QUIZ_GENERATION_TIMEOUT_MS * MAX_BATCH_GENERATE_MODULES,
rateLimit: quizBatchGenerationRateLimit,
},
preHandler: [validate({ body: generateQuizBatchSchema })],
schema: {
description: "Generate quizzes for multiple modules of a course in one request",
tags: ["quizzes"],
security: [{ bearerAuth: [] }],
body: {
type: "object",
required: ["courseId", "moduleIds"],
properties: {
courseId: { type: "string", format: "uuid" },
moduleIds: {
type: "array",
items: { type: "string", minLength: 1 },
minItems: 1,
maxItems: MAX_BATCH_GENERATE_MODULES,
},
difficulty: { type: "string", enum: ["beginner", "intermediate", "advanced"] },
numQuestions: { type: "integer", minimum: 1, maximum: 20 },
},
},
} as FastifySchema,
},
(request, reply) => quizController.generateBatch(request, reply)
);

app.post<{ Params: { id: string }, Body: import("./quiz.types.js").SubmitQuizBody }>(
"/:id/submit",
{
Expand Down
43 changes: 43 additions & 0 deletions src/modules/quizzes/quiz.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ import {
MAX_RETRIES_PER_MODULE_PER_DAY,
MAX_QUIZ_GENERATIONS_PER_MODULE_PER_HOUR,
type GenerateQuizBody,
type GenerateQuizBatchBody,
type SubmitQuizBody,
type QuizWithQuestions,
type QuizBatchGenerateEntry,
type QuizSubmissionResult,
type QuizQuestion,
type QuizStats,
Expand Down Expand Up @@ -128,6 +130,47 @@ export class QuizService {
};
}

/**
* Generate quizzes for several modules of the same course in one request
* (#308). Each module goes through the same generateQuiz path — enrollment
* check, per-module rate limit, existing-quiz short-circuit, AI generation
* with placeholder fallback — sequentially rather than in parallel so a
* batch request can't fan out into a burst of concurrent AI service calls.
* One module failing (e.g. its own per-module generation rate limit) is
* reported in that module's entry rather than aborting the rest of the
* batch.
*/
async generateQuizBatch(
userId: string,
data: GenerateQuizBatchBody
): Promise<QuizBatchGenerateEntry[]> {
const results: QuizBatchGenerateEntry[] = [];

for (const moduleId of data.moduleIds) {
try {
const quiz = await this.generateQuiz(userId, {
courseId: data.courseId,
moduleId,
difficulty: data.difficulty,
numQuestions: data.numQuestions,
});
results.push({ moduleId, success: true, quiz });
} catch (err) {
logger.warn(
{ err, courseId: data.courseId, moduleId },
"Batch quiz generation failed for module"
);
results.push({
moduleId,
success: false,
error: err instanceof Error ? err.message : "Quiz generation failed",
});
}
}

return results;
}

/**
* Submit answers for a quiz and calculate the score.
* Uses distributed locking + database transaction with row-level lock
Expand Down
23 changes: 23 additions & 0 deletions src/modules/quizzes/quiz.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ export const generateQuizSchema = z.object({
numQuestions: z.coerce.number().int().min(1).max(20).optional(),
});

// Capped at 10 modules per batch request (#308) — generation runs
// sequentially against the AI service, so this also bounds the route's
// worst-case timeout (see QUIZ_BATCH_GENERATION_TIMEOUT_MS).
export const MAX_BATCH_GENERATE_MODULES = 10;

export const generateQuizBatchSchema = z.object({
courseId: z.string().uuid("Invalid course ID"),
moduleIds: z
.array(z.string().min(1))
.min(1, "At least one module ID is required")
.max(MAX_BATCH_GENERATE_MODULES, `Too many modules (max ${MAX_BATCH_GENERATE_MODULES})`),
difficulty: z.enum(["beginner", "intermediate", "advanced"]).optional(),
numQuestions: z.coerce.number().int().min(1).max(20).optional(),
});

export const submitQuizSchema = z.object({
answers: z
.array(
Expand All @@ -55,6 +70,7 @@ export const quizStatsQuerySchema = z.object({
// ─── Types ──────────────────────────────────────────────────────────────────

export type GenerateQuizBody = z.infer<typeof generateQuizSchema>;
export type GenerateQuizBatchBody = z.infer<typeof generateQuizBatchSchema>;
export type SubmitQuizBody = z.infer<typeof submitQuizSchema>;
export type QuizIdParams = z.infer<typeof quizIdParamsSchema>;
export type QuizStatsQuery = z.infer<typeof quizStatsQuerySchema>;
Expand All @@ -76,6 +92,13 @@ export interface QuizWithQuestions {
createdAt: Date;
}

/** One entry of POST /api/v1/quizzes/generate-batch's response (#308). Each
* module is generated independently, so one module's failure (e.g. hitting
* its own per-module rate limit) doesn't block the others in the batch. */
export type QuizBatchGenerateEntry =
| { moduleId: string; success: true; quiz: QuizWithQuestions }
| { moduleId: string; success: false; error: string };

export interface QuizSubmissionResult {
id: string;
score: number;
Expand Down
Loading