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
1 change: 1 addition & 0 deletions docs/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ regardless of which part of the doc set you're working from.
| [`06-reports.mdx`](06-reports.mdx) | Chapter / course / mentee reports, confusion flags, at-risk rule, export |
| [`07-architecture.mdx`](07-architecture.mdx) | Stack, repo layout, why-this-stack, runner/editor/auth research |
| [`08-data-model.mdx`](08-data-model.mdx) | Prisma schema, response payload shapes, submission flow |
| [`organization-api.mdx`](organization-api.mdx) | Organization API authentication and the full learner module/chapter progress report |
| [`09-security.mdx`](09-security.mdx) | Server-only answers, gating enforcement, MDX sandboxing, rate limits |
| [`10-deferred-v2.mdx`](10-deferred-v2.mdx) | Explicitly out of scope for v1 |
| [`11-decisions-and-open-questions.mdx`](11-decisions-and-open-questions.mdx) | Decision log + unresolved product questions |
Expand Down
124 changes: 124 additions & 0 deletions docs/organization-api.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Organization API: learner progress report

The organization API lets an authorized partner read progress for a learner enrolled through that
organization. The learner must already have an enrollment for the requested course.

## Request

```http
GET /integrations/v1/progress?course_id=<course-id>&learner_email=<learner-email>
x-buildment-access-key: <organization-access-key>
x-buildment-secret-key: <organization-secret>
```

Both credentials are required. The API only returns an enrollment owned by the authenticated
organization; a missing or out-of-scope enrollment returns `404`.

## Response

The response is snake case. `modules` contains the learner's course release, with every chapter in
that release, including chapters the learner has not started. Chapter progress and scores are
summaries; learner answers and submission contents are not exposed.

```json
{
"enrollment_id": "enrollment-id",
"course_id": "course-id",
"course_slug": "course-slug",
"course_title": "Course title",
"learner_email": "learner@example.com",
"lifecycle": "ACTIVE",
"enrollment_status": "IN_PROGRESS",
"status": "IN_PROGRESS",
"overall_progress": 50,
"completed_chapters": 1,
"total_chapters": 2,
"completed_lessons": 1,
"total_lessons": 2,
"total_modules": 1,
"total_score": 8,
"max_score": 10,
"pending_reviews": 0,
"time_spent_seconds": 3600,
"started_at": "2026-11-06T09:00:00.000Z",
"last_active_at": "2026-11-06T10:00:00.000Z",
"completed_at": null,
"current_chapter": {
"id": "chapter-b",
"title": "Chapter B",
"module_id": "module-1",
"module_title": "Module 1",
"status": "IN_PROGRESS",
"progress": 25
},
"modules": [
{
"id": "module-1",
"title": "Module 1",
"order": 1,
"status": "IN_PROGRESS",
"progress_percent": 63,
"completed_chapters": 1,
"total_chapters": 2,
"time_spent_seconds": 3600,
"chapters": [
{
"id": "chapter-a",
"title": "Chapter A",
"order": 1,
"module_id": "module-1",
"module_title": "Module 1",
"status": "COMPLETED",
"completed": true,
"progress_percent": 100,
"blocks_completed": 4,
"blocks_total": 4,
"score": 8,
"max_score": 10,
"time_spent_seconds": 2400,
"started_at": "2026-11-06T09:00:00.000Z",
"completed_at": "2026-11-06T09:40:00.000Z"
},
{
"id": "chapter-b",
"title": "Chapter B",
"order": 2,
"module_id": "module-1",
"module_title": "Module 1",
"status": "IN_PROGRESS",
"completed": false,
"progress_percent": 25,
"blocks_completed": 1,
"blocks_total": 4,
"score": 0,
"max_score": 0,
"time_spent_seconds": 1200,
"started_at": "2026-11-06T09:45:00.000Z",
"completed_at": null
}
]
}
],
"unassigned_chapters": [],
"completed": false,
"report_generated_at": "2026-11-06T10:01:00.000Z"
}
```

### Field notes

- `status` is the course progress state: `NOT_STARTED`, `IN_PROGRESS`, or `COMPLETED`. The
separate `lifecycle` reports access state, such as `PENDING_ACCOUNT` or `PAYMENT_REQUIRED`.
- `modules[].chapters[]` is the source for chapter-level milestones. Each chapter's `status` is
`NOT_STARTED`, `IN_PROGRESS`, or `COMPLETED`; `completed` is a boolean convenience field.
- Missing progress records are reported as `NOT_STARTED` with zero counts and no timestamps.
- Chapter progress is 100 for a completed chapter. Otherwise, it is the rounded ratio of completed
blocks to total blocks (or zero when no progress is recorded).
- `progress_percent` on a module is the rounded average of its chapter percentages.
- `completed_lessons` and `total_lessons` are kept as compatibility aliases for
`completed_chapters` and `total_chapters`.
- `time_spent_seconds` sums tracked chapter study time. Use seconds as the canonical unit.
- `unassigned_chapters` is for active course chapters without a module in courses that do not use
pinned releases.
- `report_generated_at` records when Edorax assembled this response. The endpoint is pull-based;
partners refresh it when they need updated progress.
6 changes: 6 additions & 0 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ Procfile Heroku APP_BASE=server: release = migrate, web = node dist/
directly. Email bodies are EJS templates in `services/email/templates`. See
[`docs/12-notifications.mdx`](../docs/12-notifications.mdx).

## Organization integrations

The public partner contract is under `/integrations/v1`. See the [organization progress report
contract](../docs/organization-api.mdx) for the authenticated learner progress request and its
module/chapter response fields.

## Course images (Cloudflare R2)

Set `R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, and `R2_SECRET_ACCESS_KEY` in the API environment. The endpoint is the account S3 endpoint, without the bucket suffix. Use an R2 S3 token scoped to the `buildment` bucket. Never put the secret in the frontend environment or source code.
Expand Down
2 changes: 1 addition & 1 deletion server/src/controllers/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const ENDPOINTS = [
path: "/integrations/v1/enrollments/cancel",
description: "Cancel org enrollment (org_slug, email, course_id, optional user_id / mentor_id)",
},
{ method: "GET", path: "/integrations/v1/progress", description: "Progress for org enrollment" },
{ method: "GET", path: "/integrations/v1/progress", description: "Enrollment report with module and chapter progress, completion, scores, and study time" },
{ method: "GET", path: "/integrations/v1/course-requests", description: "List course access requests for your org" },
{ method: "POST", path: "/integrations/v1/course-requests", description: "Request access to a published course" },
] as const;
Expand Down
190 changes: 169 additions & 21 deletions server/src/services/integrations/progress.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,208 @@
import { prisma } from "../../db/prisma.js";
import { NotFoundError } from "../../utils/errors.js";
import type { AuthenticatedOrganization } from "../../types/auth.js";
import { releaseForEnrollment } from "../courses/releases.js";

/** Course progress for one organisation learner, in the organisation API's shape. */
type ChapterProgressRow = {
chapterId: string;
status: "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED";
blocksCompleted: number;
blocksTotal: number;
score: number;
maxScore: number;
timeSpentSeconds: number;
startedAt: Date | null;
completedAt: Date | null;
};

function iso(value: Date | null) {
return value?.toISOString() ?? null;
}

function chapterStatus(progress: ChapterProgressRow | undefined) {
return progress?.status ?? "NOT_STARTED";
}

function chapterProgressPercent(progress: ChapterProgressRow | undefined) {
if (!progress) return 0;
if (progress.status === "COMPLETED") return 100;
return progress.blocksTotal > 0
? Math.round((100 * progress.blocksCompleted) / progress.blocksTotal)
: 0;
}

/** Course and chapter progress for one organization learner, in the organization API shape. */
export async function getLearnerProgress(
org: AuthenticatedOrganization,
input: { courseId: string; learnerEmail: string }
) {
const { courseId, learnerEmail } = input;
const { courseId } = input;
const learnerEmail = input.learnerEmail.trim().toLowerCase();

const enrollment = await prisma.enrollment.findFirst({
where: {
organizationId: org.id,
courseId,
OR: [{ learnerProfile: { email: learnerEmail } }, { user: { email: learnerEmail } }],
},
include: {
course: { select: { id: true, slug: true, title: true } },
select: {
id: true,
lifecycle: true,
status: true,
percentComplete: true,
totalScore: true,
maxScore: true,
pendingReviews: true,
startedAt: true,
lastActiveAt: true,
completedAt: true,
releaseId: true,
variantSlug: true,
course: {
select: {
id: true,
slug: true,
title: true,
familySlug: true,
modules: {
where: { archivedAt: null },
orderBy: { order: "asc" },
select: {
id: true,
title: true,
order: true,
chapters: {
where: { archivedAt: null, publishedAt: { not: null } },
orderBy: { order: "asc" },
select: { id: true, title: true, order: true },
},
},
},
chapters: {
where: { moduleId: null, archivedAt: null, publishedAt: { not: null } },
orderBy: { order: "asc" },
select: { id: true, title: true, order: true },
},
},
},
chapterProgress: {
where: { chapter: { archivedAt: null } },
include: { chapter: { select: { id: true, title: true, order: true } } },
orderBy: { chapter: { order: "asc" } },
select: {
chapterId: true,
status: true,
blocksCompleted: true,
blocksTotal: true,
score: true,
maxScore: true,
timeSpentSeconds: true,
startedAt: true,
completedAt: true,
},
},
},
});
if (!enrollment) throw new NotFoundError("Enrollment not found");

const totalLessons = await prisma.chapter.count({ where: { courseId, archivedAt: null } });
const completedLessons = enrollment.chapterProgress.filter((p) => p.status === "COMPLETED").length;
const inProgress = enrollment.chapterProgress.find((p) => p.status === "IN_PROGRESS");
const currentChapter = inProgress ?? enrollment.chapterProgress.find((p) => p.status !== "COMPLETED");
const pinnedRelease = await releaseForEnrollment(
enrollment.course.id,
{ releaseId: enrollment.releaseId, variantSlug: enrollment.variantSlug },
enrollment.course.familySlug
);
const moduleSource = pinnedRelease?.modules ?? enrollment.course.modules;
const progressByChapter = new Map<string, ChapterProgressRow>(
enrollment.chapterProgress.map((progress) => [progress.chapterId, progress])
);

const makeChapterReport = (chapter: { id: string; title: string; order: number }, module: { id: string; title: string } | null) => {
const progress = progressByChapter.get(chapter.id);
const status = chapterStatus(progress);
return {
id: chapter.id,
title: chapter.title,
order: chapter.order,
module_id: module?.id ?? null,
module_title: module?.title ?? null,
status,
completed: status === "COMPLETED",
progress_percent: chapterProgressPercent(progress),
blocks_completed: progress?.blocksCompleted ?? 0,
blocks_total: progress?.blocksTotal ?? 0,
score: progress?.score ?? 0,
max_score: progress?.maxScore ?? 0,
time_spent_seconds: progress?.timeSpentSeconds ?? 0,
started_at: iso(progress?.startedAt ?? null),
completed_at: iso(progress?.completedAt ?? null),
};
};

const modules = moduleSource.map((module) => {
const moduleChapters = module.chapters.map((chapter) => makeChapterReport(chapter, module));
const completedChapters = moduleChapters.filter((chapter) => chapter.completed).length;
const progressPercent = moduleChapters.length
? Math.round(moduleChapters.reduce((sum, chapter) => sum + chapter.progress_percent, 0) / moduleChapters.length)
: 0;
const status = moduleChapters.length > 0 && completedChapters === moduleChapters.length
? "COMPLETED"
: moduleChapters.some((chapter) => chapter.status !== "NOT_STARTED")
? "IN_PROGRESS"
: "NOT_STARTED";
return {
id: module.id,
title: module.title,
order: module.order,
status,
progress_percent: progressPercent,
completed_chapters: completedChapters,
total_chapters: moduleChapters.length,
time_spent_seconds: moduleChapters.reduce((sum, chapter) => sum + chapter.time_spent_seconds, 0),
chapters: moduleChapters,
};
});
const unassignedChapters = pinnedRelease
? []
: enrollment.course.chapters.map((chapter) => makeChapterReport(chapter, null));
const flatChapters = [...modules.flatMap((module) => module.chapters), ...unassignedChapters];
const inProgressChapter = flatChapters.find((chapter) => chapter.status === "IN_PROGRESS");
const currentChapter = inProgressChapter ?? flatChapters.find((chapter) => !chapter.completed);
const completedChapters = flatChapters.filter((chapter) => chapter.completed).length;
const totalStudySeconds = flatChapters.reduce((sum, chapter) => sum + chapter.time_spent_seconds, 0);
const completed = enrollment.lifecycle === "COMPLETED" || enrollment.status === "COMPLETED";
const overallProgress = enrollment.percentComplete;
const status = completed ? "COMPLETED" : overallProgress > 0 ? "IN_PROGRESS" : "NOT_STARTED";

return {
enrollment_id: enrollment.id,
course_id: courseId,
course_slug: enrollment.course.slug,
course_title: enrollment.course.title,
learner_email: learnerEmail,
lifecycle: enrollment.lifecycle,
enrollment_status: enrollment.status,
status,
status: completed ? "COMPLETED" : overallProgress > 0 ? "IN_PROGRESS" : "NOT_STARTED",
overall_progress: overallProgress,
completed_lessons: completedChapters,
total_lessons: flatChapters.length,
completed_chapters: completedChapters,
total_chapters: flatChapters.length,
total_modules: modules.length,
total_score: enrollment.totalScore,
max_score: enrollment.maxScore,
pending_reviews: enrollment.pendingReviews,
time_spent_seconds: totalStudySeconds,
started_at: iso(enrollment.startedAt),
last_active_at: iso(enrollment.lastActiveAt),
completed_at: iso(enrollment.completedAt),
current_chapter: currentChapter
? {
id: currentChapter.chapter.id,
title: currentChapter.chapter.title,
progress:
currentChapter.blocksTotal > 0
? Math.round((100 * currentChapter.blocksCompleted) / currentChapter.blocksTotal)
: 0,
id: currentChapter.id,
title: currentChapter.title,
module_id: currentChapter.module_id,
module_title: currentChapter.module_title,
status: currentChapter.status,
progress: currentChapter.progress_percent,
}
: null,
completed_lessons: completedLessons,
total_lessons: totalLessons,
modules,
unassigned_chapters: unassignedChapters,
completed,
report_generated_at: new Date().toISOString(),
};
}
Loading
Loading