Skip to content

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_slug is 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 enrollments row interface in src/db/types.ts per the project convention; do not add reflection-based codegen.
  • Query helpers live in src/db/queries/enrollments.ts and always take tenant_id as the first argument. super_admin cross-tenant access uses the explicit withTenantOverride() helper described in user-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:

  1. Resolve tenant from host (already done in proxy.ts).
  2. requireSession() — proxy has already redirected unauthenticated users to /learn/sign-in?next=...; this is defense-in-depth.
  3. cms.getCourseBySlug(slug) — return 404 if not found or published === false.
  4. Look up enrollments row for (tenant_id, user_id, course_slug):
    • active or completedredirect() to the first entry (or /learn/dashboard if course gating has no entries yet).
    • pending → render the awaiting payment state with a resume-payment CTA.
    • cancelled or no row → render the confirm enrollment state with the course summary and a single CTA that posts to the enrollInCourse server action (§5).
  5. Course archived in CMS (published === false after 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(); reads userId, tenantId, role from the session — never from input.
  • Validation: Zod parses input. courseSlug is matched against cms.getCourseBySlug to 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 === 0 or free === true) → row inserted with status='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 respond 501 Not Implemented with a clear "paid courses are not yet available" message. The seam for the future implementation is src/server/payments/* (provider interface + adapter); nothing else in this doc should change when payments land.
  • 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 an UPDATE setting status (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 (see entry-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 delete entry_progress or quiz_attempts so 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/completed enrollments 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 for active, fail for cancelled and 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 with DATA_DIR=./data/test. Per AGENTS.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:

  • paymentInitToken is an opaque provider-neutral token stored on the enrollments row or a payment-init table. It must not expose provider session ids directly to the client.
  • initiateCheckout is called only after course publication, tenant scope, price, discount, and prerequisite checks have passed.
  • handleWebhook receives the raw request body and provider signature so adapters can verify authenticity before parsing.
  • Webhook handlers are idempotent. A duplicate payment.succeeded event must not create duplicate enrollments or repeat side effects.
  • payment.succeeded transitions pending → active and emits enrollment.activated; payment.failed leaves the row pending unless the provider marks the checkout terminal, in which case an admin or scheduled job may transition it to cancelled.
  • 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 / requireRole guards, audit_log, course_admin_assignments, JWT claims.
  • implementation.md — Phase 2 (enrollments schema), Phase 6 (/learn/course/\[slug\]/enroll route + server action), Phase 3 (proxy.ts patterns).
  • entry-pagination.md — entry route layout that calls requireCourseAccess, sequential gating rules, completion semantics.
  • tech-stack.md — Kysely + PG/SQLite, Auth.js v5, Next.js 16 with proxy.ts, Zod at boundaries.