diff --git a/src/modules/courses/course.controller.ts b/src/modules/courses/course.controller.ts index 5031f67..f5f2f4a 100644 --- a/src/modules/courses/course.controller.ts +++ b/src/modules/courses/course.controller.ts @@ -264,6 +264,21 @@ export class CourseController { reply.status(201).send({ success: true, data: review }); } + + /** + * GET /api/v1/courses/:id/syllabus + * Returns the full course syllabus with module descriptions, estimated + * duration, and learning objectives (#373). Cached for 5 minutes. + */ + async syllabus( + request: FastifyRequest<{ Params: CourseIdParams }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + const syllabus = await courseService.getSyllabus(id); + + reply.send({ success: true, data: syllabus }); + } } export const courseController = new CourseController(); diff --git a/src/modules/courses/course.routes.ts b/src/modules/courses/course.routes.ts index 87f582a..471423a 100644 --- a/src/modules/courses/course.routes.ts +++ b/src/modules/courses/course.routes.ts @@ -148,6 +148,19 @@ export async function courseRoutes(app: FastifyInstance): Promise { (request, reply) => courseController.getById(request, reply) ); + app.get<{ Params: { id: string } }>( + "/:id/syllabus", + { + preHandler: [validate({ params: courseIdParamsSchema })], + schema: { + description: "Get the full course syllabus with module descriptions, estimated duration, and learning objectives (#373)", + tags: ["courses"], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, + } as FastifySchema, + }, + (request, reply) => courseController.syllabus(request, reply) + ); + app.get<{ Params: { id: string } }>( "/:id/modules", { diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index 49805b8..8b30cf6 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -57,6 +57,8 @@ import type { CourseAnalytics, EnrollmentTrendPoint, ModuleDifficulty, + CourseSyllabus, + SyllabusModule, } from "./course.types.js"; const POPULAR_COURSES_TTL_SECONDS = 300; @@ -1933,6 +1935,136 @@ export class CourseService { return analytics; } + /** + * Full course syllabus: all modules in order, each with description, + * estimated duration, and learning objectives derived from the module + * content metadata (#373). Cached for 5 minutes. + * + * Module data comes from two sources, in priority order: + * 1. `courses.courseModules` jsonb — rich metadata (id, title, + * description, estimatedDurationMinutes) set during course authoring + * 2. Fallback: derive module IDs from the quizzes table (moduleId + * column), same approach getCourseDetail uses. These have no + * description or duration, so those fields are null. + * + * Learning objectives are derived from each module's quiz questions if + * available — the question text often contains the learning target. + * When no quizzes exist for a module, objectives is an empty array. + */ + async getSyllabus(courseId: string): Promise { + const namespace = "courses"; + const ck = cacheKey(namespace, "syllabus", courseId); + + const cached = await cacheGet(namespace, ck); + if (cached) return cached; + + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + if (!course || !course.isActive) { + throw new NotFoundError("Course"); + } + + // Build ordered module list from courseModules jsonb, or fall back to + // quiz-derived module IDs when the rich metadata isn't present. + const moduleMetadata = this.normalizeCourseModules(course.courseModules); + let syllabusModules: SyllabusModule[]; + + if (moduleMetadata.length > 0) { + syllabusModules = moduleMetadata.map((m, i) => ({ + order: i + 1, + id: m.id, + title: m.title, + description: m.description ?? null, + estimatedDurationMinutes: m.estimatedDurationMinutes ?? null, + learningObjectives: [], + })); + } else { + const moduleRows = await db + .select({ moduleId: quizzes.moduleId }) + .from(quizzes) + .where(eq(quizzes.courseId, courseId)) + .groupBy(quizzes.moduleId) + .orderBy(quizzes.moduleId); + + syllabusModules = moduleRows.map((row, i) => ({ + order: i + 1, + id: row.moduleId, + title: row.moduleId, + description: null, + estimatedDurationMinutes: null, + learningObjectives: [], + })); + } + + // Derive learning objectives from quiz questions for each module. + // Quiz question prompts represent the concrete skills a module teaches, + // making them a reasonable proxy for learning objectives. + if (syllabusModules.length > 0) { + const moduleIds = syllabusModules.map((m) => m.id); + const quizRows = await db + .select({ + moduleId: quizzes.moduleId, + questions: quizzes.questions, + }) + .from(quizzes) + .where( + and(eq(quizzes.courseId, courseId), inArray(quizzes.moduleId, moduleIds)), + ); + + // Collect question prompts per module (first N questions per module + // to keep the objectives list focused). + const QUESTIONS_PER_MODULE = 5; + const objectivesByModule = new Map(); + + for (const quiz of quizRows) { + const existing = objectivesByModule.get(quiz.moduleId) ?? []; + const questionArr = Array.isArray(quiz.questions) ? quiz.questions : []; + + for (const q of questionArr) { + if (existing.length >= QUESTIONS_PER_MODULE) break; + const prompt = + typeof q === "object" && q !== null && "prompt" in q + ? String((q as Record).prompt) + : typeof q === "string" + ? q + : null; + if (prompt && !existing.includes(prompt)) { + existing.push(prompt); + } + } + objectivesByModule.set(quiz.moduleId, existing); + } + + // Merge objectives back into the syllabus modules + for (const sm of syllabusModules) { + sm.learningObjectives = objectivesByModule.get(sm.id) ?? []; + } + } + + const totalEstimatedDurationMinutes = syllabusModules.reduce( + (sum, m) => + m.estimatedDurationMinutes !== null + ? sum + m.estimatedDurationMinutes + : sum, + 0, + ); + + const syllabus: CourseSyllabus = { + courseId: course.id, + title: course.title, + difficulty: course.difficulty, + modules: syllabusModules, + totalEstimatedDurationMinutes: + totalEstimatedDurationMinutes > 0 ? totalEstimatedDurationMinutes : null, + generatedAt: new Date(), + }; + + await cacheSet(ck, syllabus, 300); + + return syllabus; + } + /** * Report a course for inappropriate content, errors, or other issues. * One report per user per course — a repeat report from the same user diff --git a/src/modules/courses/course.types.ts b/src/modules/courses/course.types.ts index a49e0b2..96b2b28 100644 --- a/src/modules/courses/course.types.ts +++ b/src/modules/courses/course.types.ts @@ -409,3 +409,23 @@ export interface CourseReportResult { status: string; createdAt: Date; } + +/** One module entry in the syllabus response. */ +export interface SyllabusModule { + order: number; + id: string; + title: string; + description: string | null; + estimatedDurationMinutes: number | null; + learningObjectives: string[]; +} + +/** Response of GET /api/v1/courses/:id/syllabus (#373). */ +export interface CourseSyllabus { + courseId: string; + title: string; + difficulty: string; + modules: SyllabusModule[]; + totalEstimatedDurationMinutes: number | null; + generatedAt: Date; +}