From 4572e1ca963f851d69b80d801af86a75233caef4 Mon Sep 17 00:00:00 2001 From: anuoluwapo Date: Mon, 31 Aug 2026 17:32:15 +0100 Subject: [PATCH] feat: add GET /api/v1/courses/:id/enrolled-users admin endpoint - #355: New admin-only endpoint returning a paginated list of users enrolled in a course, joined with per-user progress: userId, displayName, stellarAddress, enrolledAt, completedAt, quizCount, averageScore (computed from quizSubmissions joined to quizzes for this course). Guarded by authGuard + adminGuard, matching the existing admin route pattern. Cached 30s per (courseId, page, limit), mirroring getCourseReviews's cache-aside pattern. Closes #355 Closes #343 Closes #349 Closes #361 --- src/modules/courses/course.controller.ts | 27 ++++++ src/modules/courses/course.routes.ts | 32 ++++++- src/modules/courses/course.service.ts | 101 +++++++++++++++++++++++ src/modules/courses/course.types.ts | 23 ++++++ 4 files changed, 182 insertions(+), 1 deletion(-) diff --git a/src/modules/courses/course.controller.ts b/src/modules/courses/course.controller.ts index ede672f..35e36c4 100644 --- a/src/modules/courses/course.controller.ts +++ b/src/modules/courses/course.controller.ts @@ -9,6 +9,7 @@ import type { ShareCodeParams, ListReviewsQuery, CreateReviewBody, + ListEnrolledUsersQuery, } from "./course.types.js"; export class CourseController { @@ -241,6 +242,32 @@ export class CourseController { }); } + /** + * GET /api/v1/courses/:id/enrolled-users + * Admin-only: paginated list of users enrolled in a course with their + * progress (quiz count, average score, completion status) (#355). + */ + async enrolledUsers( + 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, + }, + }); + } + /** * POST /api/v1/courses/:id/reviews * Rate and review a completed course. One review per user per course — diff --git a/src/modules/courses/course.routes.ts b/src/modules/courses/course.routes.ts index da1bcc2..a4b67ac 100644 --- a/src/modules/courses/course.routes.ts +++ b/src/modules/courses/course.routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance, FastifySchema } from "fastify"; import { courseController } from "./course.controller.js"; -import { authGuard, optionalAuth } from "../../middleware/auth.js"; +import { authGuard, adminGuard, optionalAuth } from "../../middleware/auth.js"; import { validate } from "../../middleware/validation.js"; import { listCoursesSchema, @@ -11,6 +11,7 @@ import { shareCodeParamsSchema, listReviewsQuerySchema, createReviewSchema, + listEnrolledUsersQuerySchema, } from "./course.types.js"; export async function courseRoutes(app: FastifyInstance): Promise { @@ -190,6 +191,35 @@ export async function courseRoutes(app: FastifyInstance): Promise { (request, reply) => courseController.batchEnroll(request, reply) ); + app.get<{ Params: { id: string }; Querystring: import("./course.types.js").ListEnrolledUsersQuery }>( + "/:id/enrolled-users", + { + preHandler: [ + authGuard, + adminGuard, + validate({ + params: courseIdParamsSchema, + querystring: listEnrolledUsersQuerySchema, + }), + ], + schema: { + description: + "List users enrolled in a course with their progress, paginated (admin only, #355)", + 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) => courseController.enrolledUsers(request, reply) + ); + app.get<{ Params: { id: string } }>( "/:id/prerequisites", { diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index 20d4636..793441d 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -66,6 +66,8 @@ import type { CreateReviewBody, CourseReview, CourseReviewsResult, + ListEnrolledUsersQuery, + EnrolledUsersResult, } from "./course.types.js"; const POPULAR_COURSES_TTL_SECONDS = 300; @@ -1141,6 +1143,105 @@ export class CourseService { } } + /** + * Paginated list of users enrolled in a course, with progress data, for + * course-creator admins (#355). Cached for 30s per (courseId, page, limit). + */ + 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], rows] = await Promise.all([ + db + .select({ value: count() }) + .from(enrollments) + .where(eq(enrollments.courseId, courseId)), + db + .select({ + userId: enrollments.userId, + 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 = rows.map((r) => r.userId); + const progressByUser = new Map(); + + if (userIds.length > 0) { + const progressRows = await db + .select({ + userId: quizSubmissions.userId, + quizCount: count(quizSubmissions.id), + averageScore: sql`AVG(${quizSubmissions.score})`, + }) + .from(quizSubmissions) + .innerJoin(quizzes, eq(quizSubmissions.quizId, quizzes.id)) + .where( + and( + eq(quizzes.courseId, courseId), + inArray(quizSubmissions.userId, userIds), + ), + ) + .groupBy(quizSubmissions.userId); + + for (const row of progressRows) { + progressByUser.set(row.userId, { + quizCount: row.quizCount, + averageScore: + row.averageScore === null ? null : Number(row.averageScore), + }); + } + } + + const result: EnrolledUsersResult = { + users: rows.map((r) => { + const progress = progressByUser.get(r.userId); + return { + userId: r.userId, + displayName: r.displayName, + stellarAddress: r.stellarAddress, + enrolledAt: r.enrolledAt, + completedAt: r.completedAt, + quizCount: progress?.quizCount ?? 0, + averageScore: progress?.averageScore ?? null, + }; + }), + total: totalResult?.value ?? 0, + }; + + await cacheSet(cacheKeyString, result, 30); + return result; + } + /** Paginated review list for a course, alongside its average rating. */ async getCourseReviews( courseId: string, diff --git a/src/modules/courses/course.types.ts b/src/modules/courses/course.types.ts index 1f2764e..1fda9d6 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), }); +// ─── Enrolled Users (admin, #355) ─────────────────────────────────────────── + +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 @@ -124,6 +131,7 @@ export type UpdateModuleBody = z.infer; export type ModuleParams = z.infer; export type ListReviewsQuery = z.infer; export type CreateReviewBody = z.infer; +export type ListEnrolledUsersQuery = z.infer; export interface CourseSummary { id: string; @@ -223,6 +231,21 @@ export interface CourseReviewsResult { totalReviews: number; } +export interface EnrolledUserEntry { + userId: string; + displayName: string | null; + stellarAddress: string; + enrolledAt: Date; + completedAt: Date | null; + quizCount: number; + averageScore: number | null; +} + +export interface EnrolledUsersResult { + users: EnrolledUserEntry[]; + total: number; +} + export interface CourseStats { totalCourses: number; enrollmentsByDifficulty: Record<"beginner" | "intermediate" | "advanced", number>;