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
4 changes: 4 additions & 0 deletions src/database/migrations/0020_courses_prerequisites.sql
Original file line number Diff line number Diff line change
@@ -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 '[]';
4 changes: 4 additions & 0 deletions src/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,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<string[]>().notNull().default([]),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
Expand Down
16 changes: 16 additions & 0 deletions src/modules/courses/course.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
Expand Down
14 changes: 14 additions & 0 deletions src/modules/courses/course.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,20 @@ export async function courseRoutes(app: FastifyInstance): Promise<void> {
(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",
{
Expand Down
64 changes: 64 additions & 0 deletions src/modules/courses/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
CourseModuleMetadata,
UpdateCourseBody,
CourseModuleWithProgress,
PrerequisiteCourse,
CreateModuleBody,
UpdateModuleBody,
ListReviewsQuery,
Expand Down Expand Up @@ -422,6 +423,69 @@
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<PrerequisiteCourse[]> {
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<string>();
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
Expand Down Expand Up @@ -1367,7 +1431,7 @@
}

await this.invalidateCourseCaches(courseId);
await auditLog("course.archived", { courseId });

Check failure on line 1434 in src/modules/courses/course.service.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Argument of type '"course.archived"' is not assignable to parameter of type 'AuditEvent'.
logger.info({ courseId }, "Course archived");
}

Expand Down
10 changes: 10 additions & 0 deletions src/modules/courses/course.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 126 additions & 0 deletions src/test/course-prerequisites.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading