Admissions
The admission application — a guest-owned draft through a strict state machine, with fee-gated submission, emailed decisions, and worker-rendered PDFs.
An admission application is a family's full application form, owned by a guest (the applicant account type — not a student; the student profile comes later). It moves through a deliberately strict state machine where every transition has exactly one entry point, and money sits in the middle of it: where a campus configures an admission fee, the application can't even be submitted until it's paid.
The entity
admission_application (@TenantScoped, unique ADM- reference): guestId (the owner), nullable campusId, a status defaulting to draft, archivedAt (separate from soft delete), and a large flat form in sections — A learner details, B academic history (uceResults, preferredCombinations, schoolsAttended jsonb), C father/mother, D guardian, E health, F declaration with a stored signatureId. Not year-scoped — an application predates any enrollment.
The lifecycle, transition by transition
- Create (
POST admission-applications) — resolved from the caller's guest profile; borndraft. Update and delete are draft-only. - Submit — three gates in order: still
draft;assertCompletepasses (below); and if the campus has an admission-fee item,getResourcePaymentSummary().paidmust be true — else400 "Admission fee must be paid before submitting". (Anunder_reviewenum value exists but no code transitions into it.) - Admit / Reject — from
submittedonly.admitsetsacceptedand best-effort sends the offer-letter email (school branding resolved viaPromise.allSettled— a branding or email failure logs and never blocks the decision);rejectmirrors it with a rejection email. - Enrolled is set elsewhere.
admitdoes not create a student. The student module'slinkAdmissionApplication(studentId, applicationId)stamps the link on the student profile and moves the application toenrolled(unlinking reverts toaccepted). The admissions side only reads that link back — best-effort, never throwing. - Withdraw — any non-draft, not-already-withdrawn status.
assertComplete — the missingFields pattern's home
Submission (and payment initiation) validate a required-fields list — learner identity, class of admission, father/mother core fields, the health flag, and the full declaration block (the separate guardian section and UCE academic fields are not required). Failure throws a BadRequestException whose body carries missingFields: [...] — the canonical use of the error envelope's missingFields field, which the frontend renders as a completion checklist. One quirk: the check tests null/undefined only, so a false boolean counts as answered.
PDFs render in the worker
GET :id/pdf doesn't render inline — it enqueues a render-application job on the pdf queue, waits up to 20s (waitUntilFinished, else 504), and streams the result. The worker renders with @react-pdf/renderer, applying school branding, and stores the artifact (ADMISSION_APPLICATION_PDF; the offer letter has its own storage type). Keep heavy rendering out of the request path — this is the pattern to copy.
Permissions and audit
Everything runs under Resource.APPLICATION per action, plus guest-ownership checks on the self-service paths (GET mine, payment). One deliberate note for reviewers: admit, reject, withdraw, archive and delete are audited; create, update, submit, and initiate-payment currently are not — a known gap, not a convention to imitate.
Where to go next
Reports
The term report card — remarks and attendance around the gradebook, one per enrollment per term, published to families with a shareable slug.
Admission Payments
How an application resolves its campus's compulsory admission-fee item, pays it idempotently through a self-registering resolver, and gates submission on it.