From bdff2d40f2f925262a899bc1c0f5cdb087f6b053 Mon Sep 17 00:00:00 2001 From: GOPAE Date: Mon, 31 Aug 2026 17:20:47 +0100 Subject: [PATCH] feat(courses): add GET /api/admin/courses/:id/enrolled-users endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-only, paginated endpoint returning a course's enrolled users with per-user quiz progress: userId, displayName, stellarAddress, enrolledAt, completedAt, quizCount, averageScore. - Mirrors the existing getCourseReviews pagination/caching pattern in course.service.ts (count + paginated select, cache-aside via cacheGet/cacheSet, 30s TTL per the acceptance criteria). - Route added to admin-course.routes.ts alongside the other admin course routes, inheriting the file-level authGuard + adminGuard hooks. - quizCount/averageScore are computed from quiz_submissions joined to quizzes scoped to this course (quizzes.courseId), non-superseded submissions only, for just the userIds on the current page. - averageScore is an average of quiz_submissions.score, which is a raw correct-answer count (see QuizService.submitQuiz), not a percentage — documented on EnrolledUserSummary since quizzes aren't fixed-length in this codebase, so it isn't comparable across quizzes with different question counts. Computing a normalized percentage average would need each submission's quiz question count folded into the aggregation, which is a larger change than this issue asks for; noted as a possible follow-up rather than silently approximated here. Tested against real Postgres + Redis (docker): 4 new tests in src/test/course-enrolled-users.test.ts — quizCount/averageScore correct for both a user with a submission and one without, pagination + ordering (most-recently-enrolled first), cache-aside behavior (a row added after the first call isn't reflected until the cache entry expires), and NotFoundError for a nonexistent course. eslint clean on all touched files. `npm run typecheck` was not usable to verify this — it fails with pre-existing syntax errors in three unrelated files, confirmed present on main before this change (via git stash). Closes #340 --- .../courses/admin-course.controller.ts | 26 +++ src/modules/courses/admin-course.routes.ts | 31 +++ src/modules/courses/course.service.ts | 111 +++++++++++ src/modules/courses/course.types.ts | 36 ++++ src/test/course-enrolled-users.test.ts | 180 ++++++++++++++++++ 5 files changed, 384 insertions(+) create mode 100644 src/test/course-enrolled-users.test.ts diff --git a/src/modules/courses/admin-course.controller.ts b/src/modules/courses/admin-course.controller.ts index d8b505a..baf3d4f 100644 --- a/src/modules/courses/admin-course.controller.ts +++ b/src/modules/courses/admin-course.controller.ts @@ -7,6 +7,7 @@ import type { CreateModuleBody, UpdateModuleBody, ModuleParams, + ListEnrolledUsersQuery, } from "./course.types.js"; export class AdminCourseController { @@ -120,6 +121,31 @@ export class AdminCourseController { reply.send({ success: true, message: "Module deleted" }); } + + /** + * GET /api/admin/courses/:id/enrolled-users + * Paginated list of users enrolled in a course, with progress (#340). + */ + async listEnrolledUsers( + request: FastifyRequest<{ + Params: CourseIdParams; + Querystring: ListEnrolledUsersQuery; + }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + const result = await courseService.getEnrolledUsers(id, request.query); + + reply.send({ + success: true, + data: result.users, + pagination: { + page: request.query.page, + limit: request.query.limit, + total: result.total, + }, + }); + } } export const adminCourseController = new AdminCourseController(); diff --git a/src/modules/courses/admin-course.routes.ts b/src/modules/courses/admin-course.routes.ts index 8c945e5..4115b90 100644 --- a/src/modules/courses/admin-course.routes.ts +++ b/src/modules/courses/admin-course.routes.ts @@ -9,6 +9,7 @@ import { createModuleSchema, updateModuleSchema, moduleParamsSchema, + listEnrolledUsersQuerySchema, } from "./course.types.js"; /** Admin-only course management (#292). Every route requires an admin user. */ @@ -246,4 +247,34 @@ export async function adminCourseRoutes(app: FastifyInstance): Promise { }, (request, reply) => adminCourseController.removeModule(request, reply) ); + + app.get<{ + Params: { id: string }; + Querystring: import("./course.types.js").ListEnrolledUsersQuery; + }>( + "/:id/enrolled-users", + { + preHandler: [ + validate({ + params: courseIdParamsSchema, + querystring: listEnrolledUsersQuerySchema, + }), + ], + schema: { + description: + "List a course's enrolled users (paginated) with their quiz progress (admin only)", + tags: ["admin", "courses"], + security: [{ bearerAuth: [] }], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, + querystring: { + type: "object", + properties: { + page: { type: "integer", minimum: 1, default: 1 }, + limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, + }, + }, + } as FastifySchema, + }, + (request, reply) => adminCourseController.listEnrolledUsers(request, reply) + ); } diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index b69c6f3..d965db1 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -65,6 +65,8 @@ import type { CreateReviewBody, CourseReview, CourseReviewsResult, + ListEnrolledUsersQuery, + EnrolledUsersResult, } from "./course.types.js"; const POPULAR_COURSES_TTL_SECONDS = 300; @@ -1143,6 +1145,115 @@ export class CourseService { }; } + /** + * Admin: paginated list of a course's enrolled users with their + * quiz-progress summary (#340). quizCount/averageScore are computed from + * quiz_submissions joined to quizzes scoped to this course, excluding + * superseded submissions (a retried quiz's earlier submission is kept + * for history but no longer counts as "the" submission — same rule + * reward logic elsewhere in this service follows). + */ + async getEnrolledUsers( + courseId: string, + query: ListEnrolledUsersQuery, + ): Promise { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + if (!course) { + throw new NotFoundError("Course"); + } + + const namespace = "courses"; + const cacheKeyString = cacheKey( + namespace, + "enrolled-users", + courseId, + query.page, + query.limit, + ); + + const cached = await cacheGet( + namespace, + cacheKeyString, + ); + if (cached) return cached; + + const offset = (query.page - 1) * query.limit; + + const [[totalResult], enrolledRows] = await Promise.all([ + db + .select({ value: count() }) + .from(enrollments) + .where(eq(enrollments.courseId, courseId)), + db + .select({ + userId: users.id, + displayName: users.displayName, + stellarAddress: users.stellarAddress, + enrolledAt: enrollments.enrolledAt, + completedAt: enrollments.completedAt, + }) + .from(enrollments) + .innerJoin(users, eq(enrollments.userId, users.id)) + .where(eq(enrollments.courseId, courseId)) + .orderBy(desc(enrollments.enrolledAt)) + .limit(query.limit) + .offset(offset), + ]); + + const userIds = enrolledRows.map((row) => row.userId); + + const progressRows = userIds.length + ? await db + .select({ + userId: quizSubmissions.userId, + quizCount: count(), + averageScore: sql`AVG(${quizSubmissions.score})`, + }) + .from(quizSubmissions) + .innerJoin(quizzes, eq(quizSubmissions.quizId, quizzes.id)) + .where( + and( + eq(quizzes.courseId, courseId), + eq(quizSubmissions.superseded, false), + inArray(quizSubmissions.userId, userIds), + ), + ) + .groupBy(quizSubmissions.userId) + : []; + + const progressByUser = new Map( + progressRows.map((row) => [ + row.userId, + { + quizCount: row.quizCount, + averageScore: + row.averageScore != null + ? Number(Number(row.averageScore).toFixed(2)) + : null, + }, + ]), + ); + + const result: EnrolledUsersResult = { + users: enrolledRows.map((row) => ({ + userId: row.userId, + displayName: row.displayName, + stellarAddress: row.stellarAddress, + enrolledAt: row.enrolledAt, + completedAt: row.completedAt, + quizCount: progressByUser.get(row.userId)?.quizCount ?? 0, + averageScore: progressByUser.get(row.userId)?.averageScore ?? null, + })), + total: totalResult?.value ?? 0, + }; + + await cacheSet(cacheKeyString, result, 30); + + return result; + } + /** * Create or update the caller's review for a course (one review per user * per course — a repeat submission overwrites the previous rating/text). diff --git a/src/modules/courses/course.types.ts b/src/modules/courses/course.types.ts index 6726729..678b3c6 100644 --- a/src/modules/courses/course.types.ts +++ b/src/modules/courses/course.types.ts @@ -100,6 +100,13 @@ export const listReviewsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), }); +// ─── Admin: Enrolled Users Request Schema (#340) ──────────────────────────── + +export const listEnrolledUsersQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(50).default(20), +}); + export const createReviewSchema = z.object({ rating: z.coerce.number().int().min(1).max(5), reviewText: z @@ -123,6 +130,7 @@ export type CreateModuleBody = z.infer; export type UpdateModuleBody = z.infer; export type ModuleParams = z.infer; export type ListReviewsQuery = z.infer; +export type ListEnrolledUsersQuery = z.infer; export type CreateReviewBody = z.infer; export interface CourseSummary { @@ -213,6 +221,34 @@ export interface CourseReviewsResult { totalReviews: number; } +// #340: one row per user enrolled in a course, with their quiz-progress +// summary for that course. quizCount/averageScore are scoped to quizzes +// belonging to this course (via quizzes.courseId), non-superseded +// submissions only — mirrors how reward logic elsewhere treats a +// superseded submission as no longer "the" submission for its quiz. +export interface EnrolledUserSummary { + userId: string; + displayName: string | null; + stellarAddress: string; + enrolledAt: Date; + completedAt: Date | null; + quizCount: number; + /** + * Average of quiz_submissions.score across this user's non-superseded + * submissions for this course. score is the raw correct-answer count + * for that submission's quiz (see QuizService.submitQuiz), NOT a + * percentage — quizzes in this codebase aren't a fixed length, so this + * is not comparable across quizzes with different question counts. + * null when the user has no submissions for this course yet. + */ + averageScore: number | null; +} + +export interface EnrolledUsersResult { + users: EnrolledUserSummary[]; + total: number; +} + export interface CourseStats { totalCourses: number; enrollmentsByDifficulty: Record<"beginner" | "intermediate" | "advanced", number>; diff --git a/src/test/course-enrolled-users.test.ts b/src/test/course-enrolled-users.test.ts new file mode 100644 index 0000000..45d060d --- /dev/null +++ b/src/test/course-enrolled-users.test.ts @@ -0,0 +1,180 @@ +import { test, describe, expect, beforeEach, afterEach } from "vitest"; +import { db } from "../config/database.js"; +import { redis } from "../config/redis.js"; +import { courseService } from "../modules/courses/course.service.js"; +import { quizService } from "../modules/quizzes/quiz.service.js"; +import { NotFoundError } from "../utils/errors.js"; +import { courses, enrollments, users, quizzes } from "../database/schema.js"; +import { eq } from "drizzle-orm"; + +describe("CourseService.getEnrolledUsers (#340)", () => { + const courseId = "c1c2d3e4-1111-4ef8-bb6d-6bb9bd380a30"; + const userAId = "c1c2d3e4-2222-4ef8-bb6d-6bb9bd380a30"; + const userBId = "c1c2d3e4-3333-4ef8-bb6d-6bb9bd380a30"; + const moduleId = "module-1"; + + let infraAvailable = true; + + beforeEach(async () => { + try { + await redis.flushdb(); + + await db + .insert(courses) + .values({ + id: courseId, + title: "Enrolled Users Test Course", + description: "For #340 tests", + difficulty: "beginner", + isActive: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values([ + { + id: userAId, + stellarAddress: "GAAXL3624V2V6R3E4W67ZXLN76K4E3U5V62M3X7A4P5R6S7T8U9V0W1A", + displayName: "Enrolled User A", + }, + { + id: userBId, + stellarAddress: "GBBXL3624V2V6R3E4W67ZXLN76K4E3U5V62M3X7A4P5R6S7T8U9V0W1B", + displayName: "Enrolled User B", + }, + ]) + .onConflictDoNothing(); + + // User A enrolled first, so with orderBy(enrolledAt desc) B is page 1. + await db + .insert(enrollments) + .values({ userId: userAId, courseId }) + .onConflictDoNothing(); + await new Promise((resolve) => setTimeout(resolve, 10)); + await db + .insert(enrollments) + .values({ userId: userBId, courseId }) + .onConflictDoNothing(); + } catch { + infraAvailable = false; + } + }); + + afterEach(async () => { + if (!infraAvailable) return; + await db.delete(enrollments).where(eq(enrollments.courseId, courseId)); + await db.delete(quizzes).where(eq(quizzes.courseId, courseId)); + await db.delete(courses).where(eq(courses.id, courseId)); + await db.delete(users).where(eq(users.id, userAId)); + await db.delete(users).where(eq(users.id, userBId)); + }); + + test("returns quizCount/averageScore only for users who submitted, null/0 for those who haven't", async () => { + if (!infraAvailable) return; + + const [quiz] = await db + .insert(quizzes) + .values({ + courseId, + moduleId, + questions: [ + { id: "q1", text: "2+2?", options: ["3", "4"], correctIndex: 1 }, + ], + generatedFor: userAId, + }) + .returning(); + + // User A answers correctly. quizSubmissions.score stores the raw + // correct-answer count (see quiz.service.ts submitQuiz), not a + // percentage — 1 correct out of this quiz's 1 question -> score 1. + await quizService.submitQuiz(userAId, quiz.id, { + answers: [{ questionId: "q1", selectedIndex: 1 }], + }); + + const result = await courseService.getEnrolledUsers(courseId, { + page: 1, + limit: 20, + }); + + expect(result.total).toBe(2); + expect(result.users).toHaveLength(2); + + const rowA = result.users.find((u) => u.userId === userAId); + const rowB = result.users.find((u) => u.userId === userBId); + + expect(rowA).toBeDefined(); + expect(rowA?.quizCount).toBe(1); + expect(rowA?.averageScore).toBe(1); + + expect(rowB).toBeDefined(); + expect(rowB?.quizCount).toBe(0); + expect(rowB?.averageScore).toBeNull(); + }); + + test("paginates and orders by enrolledAt descending (most recently enrolled first)", async () => { + if (!infraAvailable) return; + + const page1 = await courseService.getEnrolledUsers(courseId, { + page: 1, + limit: 1, + }); + + expect(page1.total).toBe(2); + expect(page1.users).toHaveLength(1); + expect(page1.users[0].userId).toBe(userBId); // enrolled second -> most recent + + const page2 = await courseService.getEnrolledUsers(courseId, { + page: 2, + limit: 1, + }); + + expect(page2.total).toBe(2); + expect(page2.users).toHaveLength(1); + expect(page2.users[0].userId).toBe(userAId); + }); + + test("caches the result for the given (courseId, page, limit) — a DB row added after the first call isn't reflected until the cache expires", async () => { + if (!infraAvailable) return; + + const first = await courseService.getEnrolledUsers(courseId, { + page: 1, + limit: 20, + }); + expect(first.total).toBe(2); + + const userCId = "c1c2d3e4-4444-4ef8-bb6d-6bb9bd380a30"; + await db + .insert(users) + .values({ + id: userCId, + stellarAddress: "GCCXL3624V2V6R3E4W67ZXLN76K4E3U5V62M3X7A4P5R6S7T8U9V0W1C", + displayName: "Enrolled User C", + }) + .onConflictDoNothing(); + await db + .insert(enrollments) + .values({ userId: userCId, courseId }) + .onConflictDoNothing(); + + const second = await courseService.getEnrolledUsers(courseId, { + page: 1, + limit: 20, + }); + expect(second.total).toBe(2); // still cached + + await db.delete(enrollments).where(eq(enrollments.userId, userCId)); + await db.delete(users).where(eq(users.id, userCId)); + }); + + test("throws NotFoundError for a nonexistent course", async () => { + if (!infraAvailable) return; + + await expect( + courseService.getEnrolledUsers( + "00000000-0000-0000-0000-000000000000", + { page: 1, limit: 20 }, + ), + ).rejects.toThrow(NotFoundError); + }); +});