Naalya Handbook
Admissions & Recruitment

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; born draft. Update and delete are draft-only.
  • Submit — three gates in order: still draft; assertComplete passes (below); and if the campus has an admission-fee item, getResourcePaymentSummary().paid must be true — else 400 "Admission fee must be paid before submitting". (An under_review enum value exists but no code transitions into it.)
  • Admit / Reject — from submitted only. admit sets accepted and best-effort sends the offer-letter email (school branding resolved via Promise.allSettled — a branding or email failure logs and never blocks the decision); reject mirrors it with a rejection email.
  • Enrolled is set elsewhere. admit does not create a student. The student module's linkAdmissionApplication(studentId, applicationId) stamps the link on the student profile and moves the application to enrolled (unlinking reverts to accepted). 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

On this page