Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/modules/courses/admin-course.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
UpdateModuleBody,
ModuleParams,
ListEnrolledUsersQuery,
EnrollmentTrendsQuery,
} from "./course.types.js";

export class AdminCourseController {
Expand Down Expand Up @@ -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<void> {
const { id } = request.params;
const result = await courseService.getEnrollmentTrends(id, request.query);

reply.send({ success: true, data: result });
}
}

export const adminCourseController = new AdminCourseController();
31 changes: 31 additions & 0 deletions src/modules/courses/admin-course.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
updateModuleSchema,
moduleParamsSchema,
listEnrolledUsersQuerySchema,
enrollmentTrendsQuerySchema,
} from "./course.types.js";

/** Admin-only course management (#292). Every route requires an admin user. */
Expand Down Expand Up @@ -321,4 +322,34 @@ export async function adminCourseRoutes(app: FastifyInstance): Promise<void> {
},
(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)
);
}
87 changes: 87 additions & 0 deletions src/modules/courses/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ import type {
CourseAnalytics,
EnrollmentTrendPoint,
ModuleDifficulty,
EnrollmentTrendsQuery,
EnrollmentTrendsResult,
EnrollmentTrendDataPoint,
CourseSyllabus,
SyllabusModule,
} from "./course.types.js";
Expand Down Expand Up @@ -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<EnrollmentTrendsResult> {
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<EnrollmentTrendsResult>(namespace, ck);
if (cached) return cached;

const intervalMap: Record<string, string> = {
"7d": "7 days",
"30d": "30 days",
"90d": "90 days",
};
const truncMap: Record<string, string> = {
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<string>`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();
20 changes: 20 additions & 0 deletions src/modules/courses/course.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof enrollmentTrendsQuerySchema>;

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;
Expand Down
Loading