Course Enrollment System¶
Status: Design — authoritative for Phase 6 implementation.
This document is the design contract for student enrollment in edx-lms. It cites — and must not contradict — user-management.md (RBAC, auth), implementation.md (Phase 2 schema, Phase 6 flow), entry-pagination.md (entry-layer gating), and tech-stack.md (DB, framework choices).
1. Overview¶
Enrollment is the gate between a course's public marketing surface (/learn/course/\[slug\]) and its protected learning surface (/learn/course/\[slug\]/entry/\[entrySlug\]). It is enforced in two layers — coarse "is the user signed in?" in proxy.ts, and fine-grained "does this user have an active enrollment for this course in this tenant?" in src/auth/guards.ts. Enrollment state lives in the enrollments table (Kysely on PostgreSQL; SQLite in dev) and is always tenant-scoped.
2. Data model¶
The schema is defined in implementation.md Phase 2 and reproduced here for reference only:
enrollments(
id bigserial primary key,
user_id bigint not null references users(id) on delete cascade,
tenant_id bigint not null references tenants(id) on delete restrict,
course_slug text not null,
status text not null check (status in ('pending','active','completed','cancelled')),
enrolled_at timestamptz not null default now(),
completed_at timestamptz null
)
- Unique index:
(tenant_id, user_id, course_slug)— one row per user-course pair within a tenant. Re-enrollment reuses the row (see §3). - Lookup index:
(tenant_id, course_slug, status)for roster queries. - FK behavior: users cascade-delete their enrollments; tenant deletes are restricted (a tenant with enrollments cannot be dropped — must be archived instead).
course_slugis not a foreign key — courses live in the edX CMS, not the DB. Validity is checked at write time against the CMS client.- Kysely typings: handwrite the
enrollmentsrow interface insrc/db/types.tsper the project convention; do not add reflection-based codegen. - Query helpers live in
src/db/queries/enrollments.tsand always taketenant_idas the first argument.super_admincross-tenant access uses the explicitwithTenantOverride()helper described inuser-management.md.
3. Status state machine¶
┌──────────┐ free / payment ok ┌──────────┐ course gating done ┌────────────┐
│ pending │ ───────────────────▶│ active │ ────────────────────▶│ completed │
└────┬─────┘ └────┬─────┘ └─────┬──────┘
│ │ │
│ user unenrolls / admin revokes │ │ admin reset only
▼ ▼ ▼
┌────────────────────────┐
│ cancelled │
└────────────┬───────────┘
│ re-enroll (same row, bumps enrolled_at)
└─────────────▶ pending or active
Allowed transitions:
| From | To | Trigger |
|---|---|---|
| (none) | pending |
Paid course enroll (awaiting payment) |
| (none) | active |
Free course enroll |
pending |
active |
Payment confirmed |
pending |
cancelled |
User abandons / admin revokes |
active |
completed |
Course gating (src/lib/course/gating.ts) reports all required entries complete |
active |
cancelled |
User unenrolls / admin revokes / course archived |
cancelled |
pending | active |
Re-enroll (same row; enrolled_at = now(), completed_at = null) |
completed |
cancelled |
Admin only (e.g., refund). No other transition out of completed. |
All other transitions are forbidden and must throw at the query layer.
4. Enrollment page flow — /learn/course/\[slug\]/enroll¶
Server component:
- Resolve tenant from host (already done in
proxy.ts). requireSession()— proxy has already redirected unauthenticated users to/learn/sign-in?next=...; this is defense-in-depth.cms.getCourseBySlug(slug)— return 404 if not found orpublished === false.- Look up
enrollmentsrow for(tenant_id, user_id, course_slug):activeorcompleted→redirect()to the first entry (or/learn/dashboardif course gating has no entries yet).pending→ render the awaiting payment state with a resume-payment CTA.cancelledor no row → render the confirm enrollment state with the course summary and a single CTA that posts to theenrollInCourseserver action (§5).
- Course archived in CMS (
published === falseafter a previously-published state) → return 410 with a message; do not render the CTA.
UI is built with shadcn/ui primitives; copy lives in the locale files (en/es/fr/de/pt/zh.json — all six must contain every key per AGENTS.md).
5. Server action — enrollInCourse¶
Location: src/app/learn/course/\[slug\]/enroll/actions.ts.
// pseudo-signature
async function enrollInCourse(input: {
courseSlug: string;
}): Promise<
{ status: "active" } | { status: "pending"; paymentInitToken: string }
>;
Rules:
- Auth: wrapped in
requireSession(); readsuserId,tenantId,rolefrom the session — never frominput. - Validation: Zod parses
input.courseSlugis matched againstcms.getCourseBySlugto confirm published. - Idempotency: uses
INSERT ... ON CONFLICT (tenant_id, user_id, course_slug) DO NOTHING, then reads the row back. A duplicate enrollment is a no-op, not an error — the existing status is returned. - Free vs paid branching (price flag from CMS course metadata):
- Free (
price === 0orfree === true) → row inserted withstatus='active'. Returns{ status: 'active' }. - Paid → row inserted with
status='pending'. Returns{ status: 'pending', paymentInitToken }for the client to hand off to the payment surface. Payment integration is out of scope for Phase 6 — until the payment phase ships, paid courses respond501 Not Implementedwith a clear "paid courses are not yet available" message. The seam for the future implementation issrc/server/payments/*(provider interface + adapter); nothing else in this doc should change when payments land.
- Free (
- Audit: writes to
audit_log(see §10) inside the same transaction as the insert. - Re-enrollment: if the existing row is
cancelled, the insert path falls through to anUPDATEsettingstatus(per §3),enrolled_at = now(),completed_at = null. Same audit event as a fresh enrollment, distinguished by metadata.
6. Access verification — requireCourseAccess(courseSlug)¶
Lives in src/auth/guards.ts (extends the pattern established in user-management.md). Called from:
- The
/learn/course/\[slug\]/entry/\[entrySlug\]route layout (seeentry-pagination.md). - Every server action that mutates progress (
recordEntryComplete, quiz submissions, etc.).
Behavior by role:
| Role | Pass condition |
|---|---|
student |
Row exists in enrollments for (tenant_id, user_id, course_slug) with status ∈ {'active','completed'}. |
course_admin |
Row exists in course_admin_assignments for the course OR an enrollment row as above. |
tenant_admin |
Same tenant. No enrollment required. |
super_admin |
Same tenant or explicit withTenantOverride(). |
proxy.ts only checks "is the user logged in"; it does not read enrollments (keeps the proxy fast and DB-free). The guard is the single source of truth for course-level access.
7. Unenrollment¶
- Student-initiated: from a "Leave course" control on the course settings/landing page. Sets
status='cancelled'. Does not deleteentry_progressorquiz_attemptsso re-enrollment resumes from where the student left off. - Admin-initiated: same code path; attribution differs in the audit row (§10).
- Course archived: when the CMS marks a course unpublished, a scheduled job (Phase 6+) does not flip enrollments — they are kept as historical records. New enrollments are blocked at §4 step 5.
8. Course archival impact¶
Archival is CMS-driven; the LMS does not own a course-archived flag. Behavior:
- Existing
active/completedenrollments remain visible to the user as read-only history (links lead to a "course is archived" notice; entry routes return 410). - New enrollments rejected with 410 (see §11).
- Admin roster pages still load so tenant admins can audit who was ever enrolled.
9. Admin enrollment management¶
Surface: /admin/courses/\[slug\]/students. RBAC (matches user-management.md):
| Capability | course_admin | tenant_admin | super_admin |
|---|---|---|---|
| View roster (assigned courses only) | ✅ | ✅ (any course in tenant) | ✅ |
| Manually enrol a user | ❌ | ✅ | ✅ |
| Manually unenrol / cancel | ❌ | ✅ | ✅ |
| Bulk-enrol via CSV | ❌ | ✅ | ✅ |
| Cross-tenant action | ❌ | ❌ | ✅ via withTenantOverride() |
course_admin deliberately cannot enrol/unenrol — this matches the role definition in user-management.md and prevents privilege escalation through self-assigned access. All admin mutations write to audit_log with the admin's user_id as actor_user_id. Bulk CSV enrolment is a Phase 6+ stub: the server action exists and is gated, but the UI is intentionally minimal until usage demands more.
10. Event tracking¶
The audit_log schema is defined in user-management.md. This section documents only the event types and metadata shapes written by the enrollment subsystem.
Every status transition emits one row to the existing audit_log table. Do not add a separate enrollment_events table.
action |
When | metadata |
|---|---|---|
enrollment.created |
First time a row is inserted (free or paid) | { courseSlug, status, free } |
enrollment.activated |
pending → active (payment confirmed) |
{ courseSlug, paymentRef? } |
enrollment.completed |
active → completed (gating done) |
{ courseSlug, completedEntries } |
enrollment.cancelled |
Any transition to cancelled |
{ courseSlug, reason: 'self' \| 'admin' \| 'archived' } |
Common columns: target_kind='enrollment', target_id=enrollment.id, actor_user_id is the acting user (self for student-initiated, admin id for admin actions, system sentinel for archival sweeps).
11. Error taxonomy¶
| Code | Condition |
|---|---|
| 401 | No session (handled by proxy → sign-in redirect; guard re-checks). |
| 403 | Tenant mismatch, or course_admin acting on a non-assigned course. |
| 404 | Course not found or never published. |
| 409 | Conflict the idempotent insert cannot resolve (e.g., race producing two rows — impossible under the unique index but the path is defined). |
| 410 | Course archived after being published. |
| 422 | Validation (Zod) — bad courseSlug shape, etc. |
| 501 | Paid course before payment phase ships. |
12. Testing strategy¶
- Vitest (
src/server/actions/enrollment.test.ts): unit-test the state machine (every allowed transition; every forbidden one throws), the idempotent insert, and the free/paid branch. Use the in-memory SQLite path. - Vitest integration: enrol a fixture user, then call
requireCourseAccess— expect pass foractive, fail forcancelledand absent rows. - Playwright e2e (
e2e/enrollment.spec.ts): sign in → enrol in a free course → land on the first entry → unenrol → confirm entry route now redirects to enroll page. Run withDATA_DIR=./data/test. PerAGENTS.md, every fixture project/course must have a unique name (no "Untitled Project") so failures are diagnosable.
13. Payment provider contract¶
Paid-course support is intentionally behind a provider interface so enrollment, discounts, and future subscriptions do not depend on Stripe-specific types. The canonical interface lives in src/server/payments/provider.ts when Phase 6+ payment work begins.
interface PaymentProvider {
initiateCheckout(params: {
courseSlug: string;
priceCents: number;
userId: bigint;
tenantId: bigint;
successUrl: string;
cancelUrl: string;
}): Promise<{ checkoutUrl: string; paymentInitToken: string }>;
handleWebhook(rawBody: Buffer, signature: string): Promise<PaymentEvent>;
}
type PaymentEvent =
| { type: "payment.succeeded"; paymentInitToken: string }
| { type: "payment.failed"; paymentInitToken: string; reason: string };
Contract rules:
paymentInitTokenis an opaque provider-neutral token stored on theenrollmentsrow or a payment-init table. It must not expose provider session ids directly to the client.initiateCheckoutis called only after course publication, tenant scope, price, discount, and prerequisite checks have passed.handleWebhookreceives the raw request body and provider signature so adapters can verify authenticity before parsing.- Webhook handlers are idempotent. A duplicate
payment.succeededevent must not create duplicate enrollments or repeat side effects. payment.succeededtransitionspending → activeand emitsenrollment.activated;payment.failedleaves the rowpendingunless the provider marks the checkout terminal, in which case an admin or scheduled job may transition it tocancelled.- Payment adapters must log sanitized metadata through
src/lib/log.ts; never log card data, provider secrets, raw webhook signatures, or full provider payloads. - Tests cover checkout-init failure, valid succeeded webhook, valid failed webhook, duplicate webhook delivery, invalid signature, and tenant mismatch.
Provider selection, refunds, subscriptions, and partial refunds remain deferred. This section exists so the enrollment contract does not need to change when a provider is later selected.
14. Out of scope (deferred)¶
- Payment provider selection and full integration (Stripe et al.).
- Refund flows and partial refunds.
- Group / cohort / bulk-purchase enrollment.
- Waitlists and capped seats.
- Prerequisite-course gating ("must complete A before enrolling in B").
- Time-bounded enrollments (expiry dates).
- Discount codes / vouchers.
These are listed so Phase 6 has a clean cut and so this doc tells a future contributor that the absence is intentional, not an oversight.
15. Cross-references¶
user-management.md— personas P1–P5, RBAC matrix,requireSession/requireRoleguards,audit_log,course_admin_assignments, JWT claims.implementation.md— Phase 2 (enrollmentsschema), Phase 6 (/learn/course/\[slug\]/enrollroute + server action), Phase 3 (proxy.tspatterns).entry-pagination.md— entry route layout that callsrequireCourseAccess, sequential gating rules, completion semantics.tech-stack.md— Kysely + PG/SQLite, Auth.js v5, Next.js 16 withproxy.ts, Zod at boundaries.