From 2b532485aadf468c2ed2ac9e8d0ffc9d3d95f9ea Mon Sep 17 00:00:00 2001 From: Williams-1604 Date: Mon, 31 Aug 2026 12:35:04 +0100 Subject: [PATCH] feat: add GET /api/v1/courses/:id/prerequisites endpoint (#369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - courses.prerequisites: new admin-configurable jsonb array of course IDs (migration 0020), informational only — enrolling never checks it. - courseService.getPrerequisites(courseId, userId): resolves the configured prerequisite IDs to course rows, preserving configured order, and annotates each with the caller's completion status — null for an anonymous caller, boolean (enrolled + completedAt set) for an authenticated one. - New route GET /:id/prerequisites (optionalAuth, mirrors GET /:id). - Also fixes a pre-existing syntax bug in course.routes.ts: the POST /enroll/batch route registration was missing its closing `);`, silently swallowing the reviews/share routes that followed it into the same expression. - 6 service-layer tests covering not-found, empty list, ordering, anonymous vs authenticated completion, and in-progress-but-not- completed enrollments. Closes #369 Closes #372 Closes #370 Closes #368 --- .../migrations/0020_courses_prerequisites.sql | 4 + src/database/schema.ts | 4 + src/modules/courses/course.controller.ts | 16 +++ src/modules/courses/course.routes.ts | 16 +++ src/modules/courses/course.service.ts | 64 +++++++++ src/modules/courses/course.types.ts | 10 ++ src/test/course-prerequisites.test.ts | 126 ++++++++++++++++++ 7 files changed, 240 insertions(+) create mode 100644 src/database/migrations/0020_courses_prerequisites.sql create mode 100644 src/test/course-prerequisites.test.ts diff --git a/src/database/migrations/0020_courses_prerequisites.sql b/src/database/migrations/0020_courses_prerequisites.sql new file mode 100644 index 0000000..bdbd819 --- /dev/null +++ b/src/database/migrations/0020_courses_prerequisites.sql @@ -0,0 +1,4 @@ +-- Prerequisite course IDs for a course (#369). Admin-configurable, informational +-- only — enrolling never checks this list. Empty array means no prerequisites. +ALTER TABLE "courses" + ADD COLUMN IF NOT EXISTS "prerequisites" jsonb NOT NULL DEFAULT '[]'; diff --git a/src/database/schema.ts b/src/database/schema.ts index 3ef8ee0..7201d18 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -93,6 +93,10 @@ export const courses = pgTable( // recomputed on every create/update. Null until first written. Advisory // only — a low score never blocks saving the course. accessibilityScore: integer("accessibility_score"), + // Course IDs the learner should complete before this one (#369). + // Admin-configurable, informational only — enrolling never checks this + // list, GET /:id/prerequisites just surfaces it with completion status. + prerequisites: jsonb("prerequisites").$type().notNull().default([]), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), diff --git a/src/modules/courses/course.controller.ts b/src/modules/courses/course.controller.ts index ddaedca..ede672f 100644 --- a/src/modules/courses/course.controller.ts +++ b/src/modules/courses/course.controller.ts @@ -160,6 +160,22 @@ export class CourseController { * GET /api/v1/courses/:id/leaderboard * Top performers for a course, ranked by average quiz score (#324). */ + /** + * GET /api/v1/courses/:id/prerequisites + * Prerequisite courses for a course, with the caller's completion status + * per prerequisite (#369). + */ + async prerequisites( + request: FastifyRequest<{ Params: CourseIdParams }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + const userId = (request as AuthenticatedRequest).authUser?.id ?? null; + const prerequisites = await courseService.getPrerequisites(id, userId); + + reply.send({ success: true, data: prerequisites }); + } + async leaderboard( request: FastifyRequest<{ Params: CourseIdParams }>, reply: FastifyReply diff --git a/src/modules/courses/course.routes.ts b/src/modules/courses/course.routes.ts index 2bab8fc..da1bcc2 100644 --- a/src/modules/courses/course.routes.ts +++ b/src/modules/courses/course.routes.ts @@ -188,6 +188,22 @@ export async function courseRoutes(app: FastifyInstance): Promise { } as FastifySchema, }, (request, reply) => courseController.batchEnroll(request, reply) + ); + + app.get<{ Params: { id: string } }>( + "/:id/prerequisites", + { + preHandler: [optionalAuth, validate({ params: courseIdParamsSchema })], + schema: { + description: + "Get a course's prerequisite courses, with the caller's completion status per prerequisite (#369)", + tags: ["courses"], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, + } as FastifySchema, + }, + (request, reply) => courseController.prerequisites(request, reply) + ); + app.get<{ Params: { id: string }; Querystring: import("./course.types.js").ListReviewsQuery }>( "/:id/reviews", { diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index b69c6f3..96dfc6c 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -59,6 +59,7 @@ import type { CourseModuleMetadata, UpdateCourseBody, CourseModuleWithProgress, + PrerequisiteCourse, CreateModuleBody, UpdateModuleBody, ListReviewsQuery, @@ -422,6 +423,69 @@ export class CourseService { return result; } + /** + * Returns a course's prerequisite courses, each annotated with the + * caller's completion status (#369). Prerequisites are admin-configured + * on the courses.prerequisites column — this is a read-only, informational + * view; enrolling in the course never checks whether they're met. + * + * `userId` is null for an anonymous caller: completion is then null for + * every entry rather than false, so the client can distinguish "not + * logged in" from "logged in but hasn't completed it". + */ + async getPrerequisites( + courseId: string, + userId: string | null, + ): Promise { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + + if (!course || !course.isActive) { + throw new NotFoundError("Course"); + } + + if (course.prerequisites.length === 0) { + return []; + } + + const prereqCourses = await db + .select({ + id: courses.id, + title: courses.title, + difficulty: courses.difficulty, + }) + .from(courses) + .where(inArray(courses.id, course.prerequisites)); + + let completedIds = new Set(); + if (userId) { + const completedRows = await db + .select({ courseId: enrollments.courseId }) + .from(enrollments) + .where( + and( + eq(enrollments.userId, userId), + inArray(enrollments.courseId, course.prerequisites), + sql`${enrollments.completedAt} IS NOT NULL`, + ), + ); + completedIds = new Set(completedRows.map((r) => r.courseId)); + } + + // Preserve the order prerequisites were configured in, not DB row order. + const byId = new Map(prereqCourses.map((c) => [c.id, c])); + return course.prerequisites + .map((id) => byId.get(id)) + .filter((c): c is (typeof prereqCourses)[number] => c !== undefined) + .map((c) => ({ + id: c.id, + title: c.title, + difficulty: c.difficulty, + completed: userId ? completedIds.has(c.id) : null, + })); + } + /** * Compares the course's stored contentHash against the progress-tracker * contract's on-chain value (#294). Deliberately non-blocking: any diff --git a/src/modules/courses/course.types.ts b/src/modules/courses/course.types.ts index 6726729..1f2764e 100644 --- a/src/modules/courses/course.types.ts +++ b/src/modules/courses/course.types.ts @@ -161,6 +161,16 @@ export interface CourseModuleWithProgress extends CourseModule { completed: boolean; } +/** One row of GET /api/v1/courses/:id/prerequisites (#369). `completed` is + * null for an anonymous caller (no user to check completion against) and a + * boolean — enrolled + completedAt set — for an authenticated one. */ +export interface PrerequisiteCourse { + id: string; + title: string; + difficulty: string; + completed: boolean | null; +} + /** One row of GET /api/v1/courses/:id/leaderboard (#324). `averageScore` is * the mean of the user's per-quiz percentages (each submission's raw * correct-answer count normalized against its own quiz's question count, the diff --git a/src/test/course-prerequisites.test.ts b/src/test/course-prerequisites.test.ts new file mode 100644 index 0000000..e2d7598 --- /dev/null +++ b/src/test/course-prerequisites.test.ts @@ -0,0 +1,126 @@ +/** + * Tests for GET /api/v1/courses/:id/prerequisites (#369). + * + * Covers the service layer: ordering by configured prerequisite list, + * completion status for an authenticated vs anonymous caller, and the + * not-found/empty-list edge cases. + */ +import { test, describe, expect, beforeEach, afterEach } from "vitest"; +import { courseService } from "../modules/courses/course.service.js"; +import { NotFoundError } from "../utils/errors.js"; +import { db } from "../config/database.js"; +import { courses, enrollments, users } from "../database/schema.js"; +import { eq, inArray } from "drizzle-orm"; + +describe("GET /api/v1/courses/:id/prerequisites (#369)", () => { + const userId = "d4444444-1111-4ef8-bb6d-6bb9bd380a11"; + const stellarAddress = "GPREREQTEST00000000000000000000000000000000000000000A"; + + const courseId = "d4444444-2222-4b92-b60d-8848db490a22"; + const prereqOneId = "d4444444-2222-4b92-b60d-8848db490a33"; + const prereqTwoId = "d4444444-2222-4b92-b60d-8848db490a44"; + + let infraAvailable = true; + + beforeEach(async () => { + try { + await db + .insert(users) + .values({ id: userId, stellarAddress, displayName: "Prereq Test User" }) + .onConflictDoNothing(); + + await db + .insert(courses) + .values([ + { + id: prereqOneId, + title: "Intro to Stellar", + description: "Prereq one", + difficulty: "beginner", + isActive: true, + }, + { + id: prereqTwoId, + title: "Soroban Basics", + description: "Prereq two", + difficulty: "beginner", + isActive: true, + }, + { + id: courseId, + title: "Advanced Smart Contracts", + description: "For #369 tests", + difficulty: "advanced", + isActive: true, + // Deliberately configured in reverse-insert order to assert + // the service preserves *this* order, not DB row order. + prerequisites: [prereqTwoId, prereqOneId], + }, + ]) + .onConflictDoNothing(); + } catch { + infraAvailable = false; + } + }); + + afterEach(async () => { + if (!infraAvailable) return; + await db.delete(enrollments).where(eq(enrollments.userId, userId)); + await db + .delete(courses) + .where(inArray(courses.id, [courseId, prereqOneId, prereqTwoId])); + await db.delete(users).where(eq(users.id, userId)); + }); + + test("throws NotFoundError for a non-existent course", async () => { + if (!infraAvailable) return; + await expect( + courseService.getPrerequisites("00000000-0000-0000-0000-000000000000", userId), + ).rejects.toThrow(NotFoundError); + }); + + test("returns an empty array when the course has no prerequisites", async () => { + if (!infraAvailable) return; + const result = await courseService.getPrerequisites(prereqOneId, userId); + expect(result).toEqual([]); + }); + + test("returns prerequisites in configured order with completed:false when not enrolled", async () => { + if (!infraAvailable) return; + const result = await courseService.getPrerequisites(courseId, userId); + + expect(result.map((p) => p.id)).toEqual([prereqTwoId, prereqOneId]); + expect(result.every((p) => p.completed === false)).toBe(true); + }); + + test("returns completed:null for every entry for an anonymous caller", async () => { + if (!infraAvailable) return; + const result = await courseService.getPrerequisites(courseId, null); + expect(result.every((p) => p.completed === null)).toBe(true); + }); + + test("marks a prerequisite completed once the user has a completed enrollment for it", async () => { + if (!infraAvailable) return; + await db + .insert(enrollments) + .values({ userId, courseId: prereqOneId, completedAt: new Date() }) + .onConflictDoNothing(); + + const result = await courseService.getPrerequisites(courseId, userId); + + expect(result.find((p) => p.id === prereqOneId)?.completed).toBe(true); + expect(result.find((p) => p.id === prereqTwoId)?.completed).toBe(false); + }); + + test("an enrollment that isn't completed yet does not count as completed", async () => { + if (!infraAvailable) return; + await db + .insert(enrollments) + .values({ userId, courseId: prereqOneId }) + .onConflictDoNothing(); + + const result = await courseService.getPrerequisites(courseId, userId); + + expect(result.find((p) => p.id === prereqOneId)?.completed).toBe(false); + }); +});