Sometimes access doesn’t come from a checkout. A student buys a workshop off-platform, a partner sends a cohort, someone needs the private bonus course. For those, the Instructor needs to grant access directly. This slice delivers exactly that: a Studio screen to manually enroll people who already have an account into a Published course, by email, one at a time.

It’s the first bite of a larger story. Manual Enrollment started epic-sized — invites for brand-new accounts, CSV bulk paste, personal notes, a GDPR decline path. Rather than swallow it whole, we used the Hamburger Method to slice on account existence: this item (0010) handles people who already have a confirmed account; inviting strangers (with the Profile aggregate, Invited → Active status, and magic-link accept) moves to its own item, 0020.

What was built

The Instructor can now:

  • Open a “Manually enroll students” screen from the tab bar of any Published course, showing the course context (title, language, visibility, current enrolled count)
  • Add a person by email, one at a time, and see them appear in a live “Ready to enroll” preview
  • See each row flagged as valid, already enrolled, or invalid (malformed email, no account, or a duplicate within the batch), with a running valid / duplicate / invalid summary
  • Remove a row or clear the whole list
  • Send — disabled when there are zero valid rows — and land on a summary showing enrolled, skipped (already enrolled), and the new total

The system enrolls each valid person Active immediately and sends them a notification email naming who enrolled them and into which course. No accept link — their account already exists. Draft courses are rejected; any visibility of a Published course is allowed (private bonus content is the main use case).


The add → classify → send flow

sequenceDiagram
    actor Instructor
    participant LV as EnrollLive
    participant Enr as Forgia.Enrollments
    participant DB as Repo
    participant Mail as UserNotifier

    Instructor->>LV: type email + Enter
    LV->>Enr: preview_status(course, email, batch)
    Enr->>DB: confirmed account? already enrolled?
    Enr-->>LV: :valid | :already_enrolled | {:invalid, reason}
    LV-->>Instructor: row appended, counts updated, field cleared

    Instructor->>LV: click "Enroll N students"
    loop each valid row
        LV->>Enr: manual_enroll(course, email, instructor)
        Enr->>DB: enroll/2 (idempotent, on_conflict: nothing)
        Enr->>Mail: deliver_enrollment_notification(user, course, instructor)
    end
    LV-->>Instructor: success summary (enrolled / skipped / before→after)

Classification (preview_status/3) and the actual write (manual_enroll/3) both live in the Forgia.Enrollments context — the LiveView stays thin, holding only the preview rows and the view state. manual_enroll/3 re-validates at send time (course still published, account still exists, not already enrolled), so the preview is a hint, not the source of truth.


Domain model

erDiagram
    User {
        int id
        string email
        datetime confirmed_at
    }
    Course {
        int id
        string title
        string status
        string visibility
    }
    Enrollment {
        int id
        int user_id
        int course_id
        datetime inserted_at
    }

    User ||--o{ Enrollment : "enrolled via"
    Course ||--o{ Enrollment : "has"

Notice what’s not here: an Enrollment status column. The glossary defines Invited and Active statuses, but this slice only ever produces Active access for already-confirmed accounts — there is no Invited state to represent yet. Adding a half-built status field now would be speculative; it arrives with the invite flow in 0020, where it earns its keep. The slice boundary is also a schema boundary.

manual_enroll/3 reuses the existing Enrollments.enroll/2, which inserts with on_conflict: :nothing — so enrolling someone already enrolled is a silent no-op, with no second row and no second email.


Screenshots

Entry point: a new tab on Published courses

Course curriculum with the Enroll students tab

The course Studio gains an "Enroll students" tab next to "Curriculum" — shown only when the course is Published, since manual enrollment requires a Published course.

Empty state

Enroll screen, empty

The course context bar shows what you're enrolling into and the current headcount. The Send button is disabled — "No students to enroll" — until there's at least one valid row.

Live preview with flagged rows

Enroll screen with a mix of flagged rows

Each added email is classified instantly: two valid rows, one "already enrolled" (amber), and two invalid (rust) — one malformed, one with no account. The running summary counts valid / duplicate / invalid, and the field clears after every add for fast keyboard entry.

Success summary

Enrollment success summary

After sending: 2 enrolled, 1 skipped (already enrolled), total 6 → 8. The toast confirms the action — flash rendered by the custom full-page LiveView itself.


Acceptance criteria

  • Open a "Manually enroll students" screen from a Published course, showing course context
  • Add a person by email and see them in a live "Ready to enroll" preview
  • Each row flagged valid / already-enrolled / invalid, with running counts
  • Remove a row or clear the whole list
  • Send disabled at zero valid rows; success summary shows enrolled / skipped / new total
  • Reject manual enrollment into a Draft course; allow any visibility of a Published course
  • Valid email → Active enrollment immediately + a notification email naming the Instructor and course
  • Email with no account → flagged invalid, excluded from send (never creates an account)
  • Already-enrolled email → skipped; enroll/2 stays idempotent (no second row, no second email)
  • Only the Instructor can reach and use the screen

Key decisions

Slice on account existence (Hamburger Method) — The epic was cut so the first deliverable is the smallest useful one: direct enrollment of existing accounts, email only. Everything that exists solely to handle strangers — the Profile aggregate, the Invited → Active lifecycle, account creation, magic-link accept, the GDPR decline surface — moved to 0020. CSV paste and personal notes ride along with it. The full design stays the north star; we just deliver it in bites.

No status column yet — Because this slice has no Invited state, the Enrollment schema gains nothing. The field lands in 0020 when there’s a second state to hold. Schema follows the domain, not the roadmap.

Domain logic in the context, LiveView thinpreview_status/3 and manual_enroll/3 carry all the rules; EnrollLive just renders preview rows and drives events. manual_enroll/3 re-checks its preconditions at send time, so a stale preview can’t enroll into a course that was unpublished mid-session.

type="text", not type="email" — Verification in the real browser caught it: a native type=email input makes the browser block submitting a malformed address, so our own “invalid email” badge was unreachable and pressing Enter on a typo did nothing. Switching to type="text" (with inputmode="email") lets the domain do the validating and gives consistent feedback.

Keying the input id to clear it after submit — LiveView preserves the value of a focused input across patches, so the just-added email lingered in the field. Rendering the input with an id keyed to the row count makes each successful add mount a fresh, auto-focused, empty field — pure LiveView, no JS hook. Both of these were found by actually running the screen, not by the green test suite — a reminder that “tests pass” and “works in the browser” are different claims.


Next up: PBI 0020 — Invite Students (the Profile aggregate, Invited → Active via magic link, CSV bulk paste, and the GDPR decline path).