diff --git a/src/modules/courses/admin-course.controller.ts b/src/modules/courses/admin-course.controller.ts index f3f2e22..ff1b056 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 { @@ -136,6 +137,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 dd2f20a..65002cc 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. */ @@ -261,4 +262,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 c9d3f20..8821300 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -38,6 +38,12 @@ import type { CourseModuleWithProgress, CreateModuleBody, UpdateModuleBody, + ListReviewsQuery, + CreateReviewBody, + CourseReview, + CourseReviewsResult, + ListEnrolledUsersQuery, + EnrolledUsersResult, } from "./course.types.js"; const POPULAR_COURSES_TTL_SECONDS = 300; @@ -580,6 +586,681 @@ export class CourseService { return rows.map((course) => ({ ...course, isEnrolled: false })); } + /** + * Per-course leaderboard (#324): the top {@link LEADERBOARD_SIZE} learners + * for a course ranked by their average quiz score. Each submission's raw + * correct-answer count is normalized against its own quiz's question count + * before averaging (quizzes vary from 1–20 questions), matching + * getQuizStats. Superseded submissions (#295) and ungraded ones don't + * count. Cached for 5 minutes — course-level competition doesn't need to + * be real-time, and this aggregates every submission for the course. + */ + async getLeaderboard(courseId: string): Promise { + const namespace = "courses"; + const cacheKeyString = cacheKey(namespace, "leaderboard", courseId); + + const cached = await cacheGet( + namespace, + cacheKeyString, + ); + if (cached) return cached; + + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + if (!course || !course.isActive) { + throw new NotFoundError("Course"); + } + + const rows = await db + .select({ + userId: quizSubmissions.userId, + displayName: users.displayName, + score: quizSubmissions.score, + questions: quizzes.questions, + }) + .from(quizSubmissions) + .innerJoin(quizzes, eq(quizSubmissions.quizId, quizzes.id)) + .innerJoin(users, eq(quizSubmissions.userId, users.id)) + .where( + and( + eq(quizzes.courseId, courseId), + eq(quizSubmissions.superseded, false), + isNull(users.deletedAt), + ), + ); + + const perUser = new Map< + string, + { displayName: string | null; percentageSum: number; quizzesTaken: number } + >(); + + for (const row of rows) { + const totalQuestions = Array.isArray(row.questions) + ? row.questions.length + : 0; + if (totalQuestions === 0 || row.score == null) continue; + + const percentage = Math.round((row.score / totalQuestions) * 100); + const entry = perUser.get(row.userId) ?? { + displayName: row.displayName, + percentageSum: 0, + quizzesTaken: 0, + }; + entry.percentageSum += percentage; + entry.quizzesTaken += 1; + perUser.set(row.userId, entry); + } + + const leaderboard: CourseLeaderboardEntry[] = [...perUser.entries()] + .map(([userId, e]) => ({ + userId, + displayName: e.displayName, + averageScore: Math.round(e.percentageSum / e.quizzesTaken), + quizzesTaken: e.quizzesTaken, + })) + .sort( + (a, b) => + b.averageScore - a.averageScore || + b.quizzesTaken - a.quizzesTaken, + ) + .slice(0, LEADERBOARD_SIZE) + .map((e, i) => ({ rank: i + 1, ...e })); + + await cacheSet(cacheKeyString, leaderboard, LEADERBOARD_TTL_SECONDS); + + return leaderboard; + } + + // ─── Course Sharing / Referrals (#325) ───────────────────────────────── + + /** 10-char base62 referral token from 8 random bytes. */ + private generateReferralCode(): string { + const alphabet = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + const bytes = crypto.randomBytes(10); + let code = ""; + for (const b of bytes) code += alphabet[b % alphabet.length]; + return code; + } + + private buildShareUrl(courseId: string, referralCode: string): string { + const base = config.PUBLIC_BASE_URL?.replace(/\/$/, "") ?? ""; + return `${base}/api/v1/courses/${courseId}?ref=${referralCode}`; + } + + private async toShareLink( + row: typeof courseShares.$inferSelect, + ): Promise { + const url = this.buildShareUrl(row.courseId, row.referralCode); + return { + courseId: row.courseId, + referralCode: row.referralCode, + url, + qrCode: await QRCode.toDataURL(url, { margin: 1, width: 240 }), + clickCount: row.clickCount, + enrollmentCount: row.enrollmentCount, + }; + } + + /** + * Get (or lazily create) the caller's referral link for a course (#325). + * The link is stable — calling this repeatedly returns the same code and + * its accumulated click / enrollment counts. Scoped by a per-user, + * per-course lock so two concurrent first-time calls can't both insert. + */ + async createShareLink( + userId: string, + courseId: string, + ): Promise { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + if (!course || !course.isActive) { + throw new NotFoundError("Course"); + } + + return withLock(`course-share:${userId}:${courseId}`, async () => { + const existing = await db.query.courseShares.findFirst({ + where: and( + eq(courseShares.userId, userId), + eq(courseShares.courseId, courseId), + ), + }); + if (existing) return this.toShareLink(existing); + + // Retry on the (astronomically unlikely) referral_code collision. + for (let attempt = 0; attempt < 5; attempt++) { + try { + const [row] = await db + .insert(courseShares) + .values({ + userId, + courseId, + referralCode: this.generateReferralCode(), + }) + .returning(); + await auditLog("course.shared", { userId, courseId }); + return this.toShareLink(row); + } catch (err) { + const code = (err as { code?: string }).code; + // 23505 = unique_violation. A concurrent insert of the same + // (user, course) pair means we should return their row. + if (code === "23505") { + const row = await db.query.courseShares.findFirst({ + where: and( + eq(courseShares.userId, userId), + eq(courseShares.courseId, courseId), + ), + }); + if (row) return this.toShareLink(row); + continue; // else it was a code collision — regenerate + } + throw err; + } + } + throw new Error("Could not allocate a unique referral code"); + }); + } + + /** + * Resolve a referral code to its course, counting the click (#325). Used + * by the public share link so opening it is tracked. A missing/stale code + * 404s rather than silently redirecting. + */ + async resolveShareLink( + referralCode: string, + viewerId: string | null, + ): Promise { + const share = await db.query.courseShares.findFirst({ + where: eq(courseShares.referralCode, referralCode), + }); + if (!share) { + throw new NotFoundError("Share link"); + } + + // Don't inflate the metric when the sharer opens their own link. + if (viewerId !== share.userId) { + await db + .update(courseShares) + .set({ clickCount: sql`${courseShares.clickCount} + 1` }) + .where(eq(courseShares.id, share.id)); + } + + return { + referralCode: share.referralCode, + sharedByUserId: share.userId, + course: await this.getCourseDetail(share.courseId, viewerId), + }; + } + + /** + * Credit a referral with an enrollment (#325). Best-effort — called after + * a successful enroll(); a bad or self-referral code is ignored rather + * than failing the enrollment. + */ + private async trackReferralEnrollment( + referralCode: string, + courseId: string, + enrolleeId: string, + ): Promise { + try { + const result = await db + .update(courseShares) + .set({ enrollmentCount: sql`${courseShares.enrollmentCount} + 1` }) + .where( + and( + eq(courseShares.referralCode, referralCode), + eq(courseShares.courseId, courseId), + ne(courseShares.userId, enrolleeId), + ), + ) + .returning({ userId: courseShares.userId }); + + if (result.length > 0) { + await auditLog("course.referral_enrolled", { + userId: enrolleeId, + courseId, + }); + } + } catch (err) { + logger.warn( + { err, courseId, referralCode }, + "Failed to record referral enrollment — enrollment itself succeeded", + ); + } + } + + /** + * Generate personalized course recommendations for a user (#328). + * Heuristic: recommend courses one difficulty level above completed courses, + * filtered by similar tags. Falls back to popular courses for new users. + * Cached per user for 1 hour — recommendation quality doesn't need to be + * real-time, and the query aggregates enrollment/completion data. + */ + async getRecommendedCourses( + userId: string, + limit: number = 10, + ): Promise { + const namespace = "courses"; + const cacheKeyString = cacheKey(namespace, "recommended", userId, limit); + + const cached = await cacheGet[]>( + namespace, + cacheKeyString, + ); + if (cached) { + return cached.map((course) => ({ ...course, isEnrolled: false })); + } + + // Get user's enrolled courses with their completions + const enrolledRows = await db + .select({ + courseId: enrollments.courseId, + difficulty: courses.difficulty, + tags: courses.tags, + completed: enrollments.completedAt, + }) + .from(enrollments) + .innerJoin(courses, eq(enrollments.courseId, courses.id)) + .where(eq(enrollments.userId, userId)); + + // If user is new (no enrollments), return popular courses + if (enrolledRows.length === 0) { + const popular = await this.getPopularCourses(limit); + await cacheSet(cacheKeyString, popular, 3600); + return popular; + } + + // Analyze completed courses to determine recommendation criteria + const completedCourses = enrolledRows.filter((r) => r.completed !== null); + const enrolledCourseIds = new Set(enrolledRows.map((r) => r.courseId)); + + // Collect tags from enrolled courses + const userTags = new Set(); + for (const row of enrolledRows) { + const tags = row.tags as string[] | null; + if (tags) { + for (const tag of tags) userTags.add(tag); + } + } + + // Determine target difficulty: one level above highest completed + let targetDifficulty: string | null = null; + if (completedCourses.length > 0) { + const difficulties = completedCourses.map((c) => c.difficulty); + if (difficulties.includes("beginner")) { + targetDifficulty = "intermediate"; + } else if (difficulties.includes("intermediate")) { + targetDifficulty = "advanced"; + } + // If all completed are advanced, keep targetDifficulty null (will show all difficulties) + } + + // Build recommendation query + const conditions = [ + eq(courses.isActive, true), + sql`${courses.id} NOT IN ${enrolledCourseIds.size > 0 ? sql`(${sql.join(Array.from(enrolledCourseIds).map((id) => sql`${id}`), sql`, `)})` : sql`('')`}`, + ]; + + if (targetDifficulty) { + conditions.push(eq(courses.difficulty, targetDifficulty)); + } + + const candidateRows = await db + .select({ + id: courses.id, + title: courses.title, + description: courses.description, + difficulty: courses.difficulty, + tags: courses.tags, + isActive: courses.isActive, + }) + .from(courses) + .where(and(...conditions)) + .limit(limit * 3); // Get more candidates to allow tag-based sorting + + // Score courses by tag overlap + const scored = candidateRows.map((course) => { + const courseTags = (course.tags as string[] | null) ?? []; + const tagOverlap = courseTags.filter((tag) => userTags.has(tag)).length; + return { course, tagOverlap }; + }); + + // Sort by tag overlap (descending), then take the limit + scored.sort((a, b) => b.tagOverlap - a.tagOverlap); + const topCourses = scored.slice(0, limit).map((s) => s.course); + + // Get enrollment counts for the recommended courses + const courseIds = topCourses.map((c) => c.id); + const enrollmentCounts = new Map(); + + if (courseIds.length > 0) { + const counts = await db + .select({ + courseId: enrollments.courseId, + value: count(), + }) + .from(enrollments) + .where(inArray(enrollments.courseId, courseIds)) + .groupBy(enrollments.courseId); + + for (const c of counts) { + enrollmentCounts.set(c.courseId, c.value); + } + } + + const recommendations = topCourses.map((course) => ({ + id: course.id, + title: course.title, + description: course.description, + difficulty: course.difficulty, + isActive: course.isActive, + enrolledCount: enrollmentCounts.get(course.id) ?? 0, + })); + + // If we got fewer than requested, pad with popular courses + if (recommendations.length < limit) { + const popular = await this.getPopularCourses(limit - recommendations.length); + const popularFiltered = popular.filter( + (p) => !enrolledCourseIds.has(p.id) && !recommendations.find((r) => r.id === p.id), + ); + recommendations.push(...popularFiltered); + } + + await cacheSet(cacheKeyString, recommendations, 3600); + + return recommendations.map((course) => ({ ...course, isEnrolled: false })); + } + + // ─── Course Reviews ───────────────────────────────────────────────────── + + /** + * Average rating + review count for a course, cached separately from the + * paginated review list itself so getCourseDetail (which only needs the + * summary, not every review) can reuse it cheaply. Invalidated together + * with the course detail cache whenever a review is created/updated. + */ + private async getReviewStats( + courseId: string, + ): Promise<{ averageRating: number | null; reviewCount: number }> { + const namespace = "courses"; + const cacheKeyString = cacheKey(namespace, "review-stats", courseId); + + const cached = await cacheGet<{ + averageRating: number | null; + reviewCount: number; + }>(namespace, cacheKeyString); + if (cached) return cached; + + const [row] = await db + .select({ + average: sql`AVG(${courseReviews.rating})`, + total: count(), + }) + .from(courseReviews) + .where(eq(courseReviews.courseId, courseId)); + + const stats = { + averageRating: + row?.average != null ? Number(Number(row.average).toFixed(2)) : null, + reviewCount: row?.total ?? 0, + }; + + await cacheSet(cacheKeyString, stats, 300); + + return stats; + } + + private async invalidateReviewCaches(courseId: string): Promise { + const invalidations = await Promise.allSettled([ + cacheDel(cacheKey("courses", "review-stats", courseId)), + cacheDel(cacheKey("courses", "detail", courseId)), + cacheInvalidatePattern(cacheKeyPattern("courses", "reviews", courseId)), + ]); + const failed = invalidations.filter((r) => r.status === "rejected"); + if (failed.length > 0) { + logger.warn( + { courseId, failedCount: failed.length }, + "Post-review cache invalidation had failures — affected views may serve stale data until their TTL expires", + ); + } + } + + /** Paginated review list for a course, alongside its average rating. */ + async getCourseReviews( + courseId: string, + query: ListReviewsQuery, + ): Promise { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + if (!course || !course.isActive) { + throw new NotFoundError("Course"); + } + + const namespace = "courses"; + const cacheKeyString = cacheKey( + namespace, + "reviews", + courseId, + query.page, + query.limit, + ); + + let listData = await cacheGet<{ reviews: CourseReview[]; total: number }>( + namespace, + cacheKeyString, + ); + + if (!listData) { + const offset = (query.page - 1) * query.limit; + + const [[totalResult], rows] = await Promise.all([ + db + .select({ value: count() }) + .from(courseReviews) + .where(eq(courseReviews.courseId, courseId)), + db + .select({ + id: courseReviews.id, + userId: courseReviews.userId, + displayName: users.displayName, + rating: courseReviews.rating, + reviewText: courseReviews.reviewText, + createdAt: courseReviews.createdAt, + updatedAt: courseReviews.updatedAt, + }) + .from(courseReviews) + .innerJoin(users, eq(courseReviews.userId, users.id)) + .where(eq(courseReviews.courseId, courseId)) + .orderBy(desc(courseReviews.createdAt)) + .limit(query.limit) + .offset(offset), + ]); + + listData = { reviews: rows, total: totalResult?.value ?? 0 }; + await cacheSet(cacheKeyString, listData, 60); + } + + const stats = await this.getReviewStats(courseId); + + return { + reviews: listData.reviews, + total: listData.total, + averageRating: stats.averageRating, + totalReviews: stats.reviewCount, + }; + } + + /** + * 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). + * Restricted to users who hold a completion credential for the course, + * since minting one already requires a passing quiz submission. + */ + async upsertReview( + userId: string, + courseId: string, + data: CreateReviewBody, + ): Promise { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + if (!course || !course.isActive) { + throw new NotFoundError("Course"); + } + + const credential = await db.query.credentials.findFirst({ + where: and( + eq(credentials.userId, userId), + eq(credentials.courseId, courseId), + ), + }); + if (!credential) { + throw new ForbiddenError( + "Must complete the course before reviewing it", + ); + } + + const reviewText = data.reviewText ?? null; + const [row] = await db + .insert(courseReviews) + .values({ userId, courseId, rating: data.rating, reviewText }) + .onConflictDoUpdate({ + target: [courseReviews.userId, courseReviews.courseId], + set: { rating: data.rating, reviewText, updatedAt: new Date() }, + }) + .returning(); + + await this.invalidateReviewCaches(courseId); + await auditLog("course.reviewed", { userId, courseId, rating: data.rating }); + logger.info({ userId, courseId, rating: data.rating }, "Course review saved"); + + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + + return { + id: row.id, + userId: row.userId, + displayName: user?.displayName ?? null, + rating: row.rating, + reviewText: row.reviewText, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + // ─── Admin ────────────────────────────────────────────────────────────── private async invalidateCourseCaches(courseId?: string): Promise { diff --git a/src/modules/courses/course.types.ts b/src/modules/courses/course.types.ts index 1f2764e..8e8d807 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 { @@ -223,6 +231,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); + }); +});