diff --git a/src/modules/courses/admin-course.controller.ts b/src/modules/courses/admin-course.controller.ts index 0d895bb..7565c2e 100644 --- a/src/modules/courses/admin-course.controller.ts +++ b/src/modules/courses/admin-course.controller.ts @@ -10,6 +10,7 @@ import type { UpdateModuleBody, ModuleParams, ListEnrolledUsersQuery, + EnrollmentTrendsQuery, } from "./course.types.js"; export class AdminCourseController { @@ -225,6 +226,23 @@ export class AdminCourseController { }, }); } + + /** + * GET /api/v1/admin/courses/:id/enrollment-trends + * Enrollment trends for a course over time (#391). + */ + async enrollmentTrends( + request: FastifyRequest<{ + Params: CourseIdParams; + Querystring: EnrollmentTrendsQuery; + }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + const result = await courseService.getEnrollmentTrends(id, request.query); + + reply.send({ success: true, data: result }); + } } export const adminCourseController = new AdminCourseController(); diff --git a/src/modules/courses/admin-course.routes.ts b/src/modules/courses/admin-course.routes.ts index 83dd31f..a009d85 100644 --- a/src/modules/courses/admin-course.routes.ts +++ b/src/modules/courses/admin-course.routes.ts @@ -10,6 +10,7 @@ import { updateModuleSchema, moduleParamsSchema, listEnrolledUsersQuerySchema, + enrollmentTrendsQuerySchema, } from "./course.types.js"; /** Admin-only course management (#292). Every route requires an admin user. */ @@ -321,4 +322,34 @@ export async function adminCourseRoutes(app: FastifyInstance): Promise { }, (request, reply) => adminCourseController.listEnrolledUsers(request, reply) ); + + app.get<{ + Params: { id: string }; + Querystring: import("./course.types.js").EnrollmentTrendsQuery; + }>( + "/:id/enrollment-trends", + { + preHandler: [ + validate({ + params: courseIdParamsSchema, + querystring: enrollmentTrendsQuerySchema, + }), + ], + schema: { + description: + "Enrollment trends for a course over time with configurable range (7d/30d/90d) and granularity (daily/weekly/monthly) (admin only, cached 1 hour, #391)", + tags: ["admin", "courses"], + security: [{ bearerAuth: [] }], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, + querystring: { + type: "object", + properties: { + range: { type: "string", enum: ["7d", "30d", "90d"], default: "30d" }, + granularity: { type: "string", enum: ["daily", "weekly", "monthly"], default: "daily" }, + }, + }, + } as FastifySchema, + }, + (request, reply) => adminCourseController.enrollmentTrends(request, reply) + ); } diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index 8b30cf6..a92f5a7 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -57,6 +57,9 @@ import type { CourseAnalytics, EnrollmentTrendPoint, ModuleDifficulty, + EnrollmentTrendsQuery, + EnrollmentTrendsResult, + EnrollmentTrendDataPoint, CourseSyllabus, SyllabusModule, } from "./course.types.js"; @@ -2151,6 +2154,90 @@ export class CourseService { logger.warn({ err, courseId, reportId }, "Failed to notify admins of course report"); } } + + // ─── Enrollment Trends (#391) ─────────────────────────────────────────── + + /** + * Enrollment trends for a course over time (#391). Admin endpoint that + * returns a time series of enrollment counts at the chosen granularity + * (daily/weekly/monthly) within the chosen range (7d/30d/90d). Cached + * for 1 hour — matching getCourseAnalytics's TTL since the query shape + * is similar (aggregating across all enrollments for a course). + */ + async getEnrollmentTrends( + courseId: string, + query: EnrollmentTrendsQuery, + ): Promise { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + if (!course) { + throw new NotFoundError("Course"); + } + + const namespace = "courses"; + const ck = cacheKey( + namespace, + "enrollment-trends", + courseId, + query.range, + query.granularity, + ); + const cached = await cacheGet(namespace, ck); + if (cached) return cached; + + const intervalMap: Record = { + "7d": "7 days", + "30d": "30 days", + "90d": "90 days", + }; + const truncMap: Record = { + daily: "day", + weekly: "week", + monthly: "month", + }; + + const interval = intervalMap[query.range]; + const trunc = truncMap[query.granularity]; + + const [trendRows] = await Promise.all([ + db + .select({ + date: sql`date_trunc('${sql.raw(trunc)}', ${enrollments.enrolledAt})::date`, + count: count(), + }) + .from(enrollments) + .where( + and( + eq(enrollments.courseId, courseId), + sql`${enrollments.enrolledAt} >= now() - interval '${sql.raw(interval)}'`, + ), + ) + .groupBy(sql`date_trunc('${sql.raw(trunc)}', ${enrollments.enrolledAt})`) + .orderBy(sql`date_trunc('${sql.raw(trunc)}', ${enrollments.enrolledAt})`), + db + .select({ value: count() }) + .from(enrollments) + .where(eq(enrollments.courseId, courseId)), + ]); + + const trends: EnrollmentTrendDataPoint[] = trendRows.map((row) => ({ + date: row.date, + count: row.count, + })); + + const result: EnrollmentTrendsResult = { + courseId, + range: query.range, + granularity: query.granularity, + trends, + totalEnrollments: trendRows.reduce((sum, r) => sum + r.count, 0), + generatedAt: new Date(), + }; + + await cacheSet(ck, result, 3600); + return result; + } } export const courseService = new CourseService(); diff --git a/src/modules/courses/course.types.ts b/src/modules/courses/course.types.ts index 96b2b28..e0fe23c 100644 --- a/src/modules/courses/course.types.ts +++ b/src/modules/courses/course.types.ts @@ -410,6 +410,26 @@ export interface CourseReportResult { createdAt: Date; } +// ─── Enrollment Trends (#391) ─────────────────────────────────────────────── + +export const enrollmentTrendsQuerySchema = z.object({ + range: z.enum(["7d", "30d", "90d"]).default("30d"), + granularity: z.enum(["daily", "weekly", "monthly"]).default("daily"), +}); + +export type EnrollmentTrendsQuery = z.infer; + +export interface EnrollmentTrendDataPoint { + date: string; + count: number; +} + +export interface EnrollmentTrendsResult { + courseId: string; + range: string; + granularity: string; + trends: EnrollmentTrendDataPoint[]; + totalEnrollments: number; /** One module entry in the syllabus response. */ export interface SyllabusModule { order: number;