diff --git a/src/modules/courses/course.controller.ts b/src/modules/courses/course.controller.ts index dc28c66..4aeee1e 100644 --- a/src/modules/courses/course.controller.ts +++ b/src/modules/courses/course.controller.ts @@ -5,6 +5,11 @@ import type { ListCoursesQuery, CourseIdParams, PopularCoursesQuery, + EnrollCourseQuery, + ShareCodeParams, + ListReviewsQuery, + CreateReviewBody, + ListEnrolledUsersQuery, } from "./course.types.js"; export class CourseController { @@ -160,6 +165,89 @@ export class CourseController { reply.send({ success: true, data: courses }); } + + /** + * GET /api/v1/courses/recommended + * Get personalized course recommendations based on enrollment history (#328). + */ + async recommended( + request: FastifyRequest<{ Querystring: PopularCoursesQuery }>, + reply: FastifyReply + ): Promise { + const { authUser } = request as AuthenticatedRequest; + const { limit } = request.query; + const recommendations = await courseService.getRecommendedCourses(authUser.id, limit); + + reply.send({ success: true, data: recommendations }); + } + + /** + * GET /api/v1/courses/:id/reviews + * List a course's reviews (paginated), alongside its average rating. + */ + async reviews( + request: FastifyRequest<{ Params: CourseIdParams; Querystring: ListReviewsQuery }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + const result = await courseService.getCourseReviews(id, request.query); + + reply.send({ + success: true, + data: result.reviews, + pagination: { + page: request.query.page, + limit: request.query.limit, + total: result.total, + }, + summary: { + averageRating: result.averageRating, + totalReviews: result.totalReviews, + }, + }); + } + + /** + * 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 — + * a repeat submission updates the existing review. + */ + async createReview( + request: FastifyRequest<{ Params: CourseIdParams; Body: CreateReviewBody }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + const { authUser } = request as AuthenticatedRequest; + const review = await courseService.upsertReview(authUser.id, id, request.body); + + reply.status(201).send({ success: true, data: review }); + } } export const courseController = new CourseController(); diff --git a/src/modules/courses/course.routes.ts b/src/modules/courses/course.routes.ts index cc71708..ebf0993 100644 --- a/src/modules/courses/course.routes.ts +++ b/src/modules/courses/course.routes.ts @@ -1,5 +1,6 @@ import type { FastifyInstance, FastifySchema } from "fastify"; import { courseController } from "./course.controller.js"; +import { authGuard, adminGuard, optionalAuth } from "../../middleware/auth.js"; import { waitlistController } from "./waitlist.controller.js"; import { authGuard, optionalAuth } from "../../middleware/auth.js"; import { validate } from "../../middleware/validation.js"; @@ -12,6 +13,7 @@ import { shareCodeParamsSchema, listReviewsQuerySchema, createReviewSchema, + listEnrolledUsersQuerySchema, } from "./course.types.js"; import { joinWaitlistSchema, leaveWaitlistSchema } from "./waitlist.types.js"; @@ -192,6 +194,33 @@ 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.delete<{ Params: { id: string } }>( "/:id/enroll", { diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index a3d0cb2..225ffd6 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -1112,6 +1112,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 8e8d807..a78b267 100644 --- a/src/modules/courses/course.types.ts +++ b/src/modules/courses/course.types.ts @@ -100,6 +100,7 @@ export const listReviewsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), }); +// ─── Enrolled Users (admin, #355) ─────────────────────────────────────────── // ─── Admin: Enrolled Users Request Schema (#340) ──────────────────────────── export const listEnrolledUsersQuerySchema = z.object({ @@ -132,6 +133,7 @@ export type ModuleParams = z.infer; export type ListReviewsQuery = z.infer; export type ListEnrolledUsersQuery = z.infer; export type CreateReviewBody = z.infer; +export type ListEnrolledUsersQuery = z.infer; export interface CourseSummary { id: string; @@ -231,6 +233,7 @@ export interface CourseReviewsResult { totalReviews: number; } +export interface EnrolledUserEntry { // #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 @@ -255,6 +258,7 @@ export interface EnrolledUserSummary { } export interface EnrolledUsersResult { + users: EnrolledUserEntry[]; users: EnrolledUserSummary[]; total: number; }