diff --git a/docs/README.mdx b/docs/README.mdx index 4572ca9d..19b7a11c 100644 --- a/docs/README.mdx +++ b/docs/README.mdx @@ -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 | diff --git a/docs/organization-api.mdx b/docs/organization-api.mdx new file mode 100644 index 00000000..49696ac1 --- /dev/null +++ b/docs/organization-api.mdx @@ -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=&learner_email= +x-buildment-access-key: +x-buildment-secret-key: +``` + +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. diff --git a/server/README.md b/server/README.md index 0c0db8c5..544877eb 100644 --- a/server/README.md +++ b/server/README.md @@ -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. diff --git a/server/src/controllers/integrations.ts b/server/src/controllers/integrations.ts index a6cc00b7..62342125 100644 --- a/server/src/controllers/integrations.ts +++ b/server/src/controllers/integrations.ts @@ -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; diff --git a/server/src/services/integrations/progress.ts b/server/src/services/integrations/progress.ts index e45fa331..6fb2753a 100644 --- a/server/src/services/integrations/progress.ts +++ b/server/src/services/integrations/progress.ts @@ -1,13 +1,43 @@ 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: { @@ -15,46 +45,164 @@ export async function getLearnerProgress( 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( + 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(), }; } diff --git a/server/tests/api/integrations.test.ts b/server/tests/api/integrations.test.ts index 57a6c342..559e627e 100644 --- a/server/tests/api/integrations.test.ts +++ b/server/tests/api/integrations.test.ts @@ -1,7 +1,9 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import request from "supertest"; import { createApp } from "../../src/app.js"; -import { createCourseFixture, createOrganizationFixture } from "../helpers/fixtures.js"; +import { createCourseFixture, createOrganizationFixture, completeChapter } from "../helpers/fixtures.js"; +import { prisma } from "../../src/db/prisma.js"; +import { recomputeEnrollmentRollup } from "../../src/services/progress/compute.js"; const app = createApp(); let fx: Awaited>; @@ -65,7 +67,41 @@ describe("/integrations/v1 (public organisation API)", () => { expect(lookup.body.enrollment).toMatchObject({ enrollment_id: created.body.enrollment_id, learner_email: payload.student_email }); const progress = await request(app).get("/integrations/v1/progress").set(org.headers).query({ course_id: fx.course.id, learner_email: payload.student_email }); - expect(progress.body).toMatchObject({ status: "NOT_STARTED", total_lessons: 2, completed_lessons: 0 }); + expect(progress.body).toMatchObject({ + status: "NOT_STARTED", + total_lessons: 2, + completed_lessons: 0, + total_modules: 1, + modules: [{ + title: "Module 1", + status: "NOT_STARTED", + completed_chapters: 0, + total_chapters: 2, + chapters: [ + { id: fx.chapterA.id, title: "Chapter A", status: "NOT_STARTED", completed: false, progress_percent: 0 }, + { id: fx.chapterB.id, title: "Chapter B", status: "NOT_STARTED", completed: false, progress_percent: 0 }, + ], + }], + }); + + await completeChapter(created.body.enrollment_id, fx.chapterA.id); + await prisma.$transaction((tx) => recomputeEnrollmentRollup(tx, created.body.enrollment_id)); + const updatedProgress = await request(app).get("/integrations/v1/progress").set(org.headers).query({ course_id: fx.course.id, learner_email: payload.student_email }); + expect(updatedProgress.body).toMatchObject({ + status: "IN_PROGRESS", + overall_progress: 50, + completed_chapters: 1, + total_chapters: 2, + modules: [{ + status: "IN_PROGRESS", + progress_percent: 50, + completed_chapters: 1, + chapters: [ + { id: fx.chapterA.id, status: "COMPLETED", completed: true, progress_percent: 100 }, + { id: fx.chapterB.id, status: "NOT_STARTED", completed: false, progress_percent: 0 }, + ], + }], + }); const cancelled = await request(app) .post("/integrations/v1/enrollments/cancel")