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
88 changes: 88 additions & 0 deletions src/modules/courses/course.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import type {
ListCoursesQuery,
CourseIdParams,
PopularCoursesQuery,
EnrollCourseQuery,
ShareCodeParams,
ListReviewsQuery,
CreateReviewBody,
ListEnrolledUsersQuery,
} from "./course.types.js";

export class CourseController {
Expand Down Expand Up @@ -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<void> {
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<void> {
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<void> {
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<void> {
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();
29 changes: 29 additions & 0 deletions src/modules/courses/course.routes.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,6 +13,7 @@ import {
shareCodeParamsSchema,
listReviewsQuerySchema,
createReviewSchema,
listEnrolledUsersQuerySchema,
} from "./course.types.js";
import { joinWaitlistSchema, leaveWaitlistSchema } from "./waitlist.types.js";

Expand Down Expand Up @@ -192,6 +194,33 @@ export async function courseRoutes(app: FastifyInstance): Promise<void> {
(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",
{
Expand Down
99 changes: 99 additions & 0 deletions src/modules/courses/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EnrolledUsersResult> {
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<EnrolledUsersResult>(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<string, { quizCount: number; averageScore: number | null }>();

if (userIds.length > 0) {
const progressRows = await db
.select({
userId: quizSubmissions.userId,
quizCount: count(quizSubmissions.id),
averageScore: sql<number | null>`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,
Expand Down
4 changes: 4 additions & 0 deletions src/modules/courses/course.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -132,6 +133,7 @@ export type ModuleParams = z.infer<typeof moduleParamsSchema>;
export type ListReviewsQuery = z.infer<typeof listReviewsQuerySchema>;
export type ListEnrolledUsersQuery = z.infer<typeof listEnrolledUsersQuerySchema>;
export type CreateReviewBody = z.infer<typeof createReviewSchema>;
export type ListEnrolledUsersQuery = z.infer<typeof listEnrolledUsersQuerySchema>;

export interface CourseSummary {
id: string;
Expand Down Expand Up @@ -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
Expand All @@ -255,6 +258,7 @@ export interface EnrolledUserSummary {
}

export interface EnrolledUsersResult {
users: EnrolledUserEntry[];
users: EnrolledUserSummary[];
total: number;
}
Expand Down
Loading