- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Bulk import
- Playwright
- Prettier
- Block Rebound
- Transportation
The admin admission flow runs from application review through enrollment confirmation. Confirming enrollment fires a single transaction that creates the Student, Guardians, FeeAssignments, and UserInvoices in one atomic step.
Applying is always free. The application wizard collects information only — no payment is taken at the application stage. Payment is triggered at two later points: the registration fee when the applicant accepts an offer, and tuition invoices after enrollment is confirmed.
Student intake — four channels, one pipeline
A student can enter the system four ways. All four funnel through one shared core, provisionStudent, so however a student is added they get the same complete result — and every student is born from a real Application, tagged with the channel it came through.
| Channel | How a student is added | Application.channel |
|---|---|---|
| Apply | Family completes the public application, staff review → offer → confirm enrollment | PORTAL |
| Admin single | An admin creates one student in the /students wizard | ADMIN_DIRECT |
| Onboarding bulk | CSV upload while setting up the school | ONBOARDING_IMPORT |
/school/bulk | CSV upload later, from the dashboard | BULK_IMPORT |
| Backfill script | Retroactive shadow application for a pre-unification student | LEGACY_BACKFILL |
A student who signs themselves up is not a fifth channel — they apply. The
internal-onboarding and newcomers self-registration flows are for the adults
who join a school (teachers, staff, admins, parents); their student option was
removed, and a self-registering student is sent to /{lang}/application so they
enter the PORTAL pipeline like any other applicant. When a school has no open
campaign, the closed screen offers the inquiry form instead, and the family lands
in the Leads tab to be invited when admissions reopen.
The non-PORTAL channels create a "direct-admit" Application in the ADMITTED state, hung off a hidden per-school system campaign (named in the school's language). Everything below describes the PORTAL (public application → enrollment) path in detail; the other three channels reuse the same provisionStudent step from enrollment onward.
Every channel is trackable
Whichever way a student arrived, the school can find their application and follow it:
- Applications tab lists every channel by default. A Channel column and facet filter (
?channel=PORTAL,BULK_IMPORT— deep-linkable, multi-select, server-side) narrow back to the review queue. Until 2026-08-15 this tab hard-defaulted toPORTAL, so a direct-admit or imported student had a real application number that no screen could look up. - Student profile → application. The unified profile sidebar shows the application number and links to the detail page (ADMIN/STAFF only — the detail page is RBAC-gated, so the link is not offered to viewers who cannot open it). Legacy students with no application (pre-unification, backfill not yet run) simply show no row.
- Application → student. The detail page shows the channel badge and, once a Student exists, a View student profile link — the loop closes both ways.
- Direct admits are terminal.
ADMITTEDhas no outgoing status transitions andconfirmEnrollmentrefuses an already-admitted application server-side, so surfacing these rows cannot double-provision anyone. Their detail page hides the offer/registration-fee rows (that ledger never runs for them) and says fees are billed through Finance → Fees like any enrolled student. - The Enrollment tab keeps its
PORTALdefault — direct admits never pass through offer / registration-fee confirmation, so they have nothing to do there.
Expect the legacy backfill to be visible. Once scripts/backfill-legacy-applications.ts runs, every pre-unification student gains a LEGACY_BACKFILL row in the default view (~970 on the demo school). The filter is one click and lives in the URL.
Keeping the invariant true
"Every student is born from an Application" only holds while every write goes through provisionStudent, and it is easy to break by accident — a new feature reaches for db.student.create and the student it makes is invisible to every surface that reads Application.channel. Two guards enforce it:
- Lint — a
no-restricted-syntaxrule ineslint.config.mjsrejects*.student.create/upsert/createManyoutside a short, commented allowlist (STUDENT_CREATE_ALLOWLIST). - Test —
src/tests/school-dashboard/listings/student-intake-invariant.test.tsre-scans the tracked tree and imports that same allowlist, so an inlineeslint-disableor a drifting config still fails CI. It also fails if an allowlist entry stops matching any file.
Legitimate exceptions are the core itself, the seeds, snapshot restore, ops scripts, and createDraftStudent — a wizard draft carries a non-null wizardStep and is deliberately Application-less until completeStudentWizard runs the core. As of the self-registration change above, that is the complete list: no intake path is exempt any more.
Telling the family — one dispatcher, every channel
provisionStudent runs inside a caller-owned transaction and deliberately never notifies, so the telling happens after the commit. That work used to live inline in confirmEnrollment, which meant only PORTAL students were ever told anything: a student added through the wizard or a CSV import got a login, fees and invoices in silence, and so did their guardian.
src/lib/student-provisioning-notify.ts is that dispatch, shared by all five channels. It sends the student's account notice (with a password-setup link when the account is new), a fee_due notice covering the instalment schedule, and a guardian notice — falling back to the guardian's own address, since guardians made by createOrLinkGuardian are contact records with no login.
Two rules it must keep:
- Placeholder addresses lose the email channel at row-creation time. A student with no address of their own gets a synthesized
…@student.local. Skipping only the inline send is not enough — the email cron skips recipients with no address, and a placeholder is an address, so it would have tried to deliver later. - Bulk queues, single sends.
delivery: "immediate"sends inline so an admin who just clicked "Create" sees the mail land. A CSV import always passesdelivery: "queue", which writes the notification rows and lets theprocess-email-notificationscron drain them 50 at a time — a 500-row import spreads over hours instead of bursting. Bulk notification is opt-in and off by default; unchecked, an import stays as silent as it has always been.
Nobody falls out of the flow
A student with no academicGradeId was the pipeline's quiet failure mode: ensureStudentFeeAssignments short-circuits on a null grade, so no fees were assigned, no invoices raised, and neither the fee-due nor the fee-overdue cron ever chased them. A CSV section column that did not parse was enough to cause it, and nothing surfaced the result.
The students list now has an unplaced filter (no grade or no class), and the daily fee-due cron alerts admins weekly while any remain. Setting a grade from the list re-runs the fee assignment and backfills what was missed.
Invoices and receipts are also delivered now, not merely generated: the fee notice states the instalment schedule and stamps UserInvoice.sentAt, and a cleared payment links the payer to their receipt.
Students that predate the unification are linked retroactively by scripts/backfill-legacy-applications.ts. It mints a LEGACY_BACKFILL shadow Application per orphan and sets only Student.applicationId — never the other eleven provisioning steps, which those students already have. Dry run is the default; --execute writes, --school <id> scopes to one tenant. It is idempotent: the applicationId IS NULL filter plus the column's @unique mean a re-run only picks up what is still unlinked.
The journey, audited end to end (2026-08-15)
The whole path — apply → review → shortlist / reject → offer → the family's reply → registration fee → enroll → place in a section → tuition invoices → instalment reminders → payment → receipt — was traced across the admission, notification, finance and invoice blocks. The core money movement (fee assignment → invoices → allocation on payment → PAID/PARTIAL/OVERDUE) is sound; every break was at an edge. Fixed in that pass:
| Journey step | What was broken | Now |
|---|---|---|
| Track the application | Applications tab hard-defaulted to PORTAL; direct-admit / imported students unreachable | Every channel listed; Channel column + facet; profile ↔ application links both ways |
| Family pays registration fee at the office | "Confirm registration payment" row action never appeared on first paint (registrationFeeMethod missing from the SSR select) | In the select; the extra per-page lookup is gone |
| Offer link after the deadline | Once the cron flipped the offer to EXPIRED the family's link was a bare 404 | Renders the "offer expired, contact the school" card |
| STAFF reviews an offer | Saw a "Confirm Enrollment" button the server always rejected | Gated on the same permission table the server asserts against (detail page + enrollment row) |
| Family pays online (Stripe / Tap) | Only the student was told; guardians heard nothing; the school got no admin notice for a registration fee; guest applicants were skipped | One shared fan-out (finance/lib/payment-notify.ts) tells student + every guardian; registration-fee webhooks also alert ADMIN/ACCOUNTANT and reach guests by email |
| Family opens the "fees due" notification | Linked to /my-fees, which shows what is owed but cannot pay | Links to /finance/fees/my (pay online, receipts); /my-fees gained a "Pay & view invoices" CTA; dead admin-only links removed from the family page |
| Admin adds a student, no fee structure for the grade | Fees silently not assigned; no signal anywhere | provisionStudent emits NO_FEE_STRUCTURE_MATCH / FEES_SKIPPED_NO_GRADE; the wizard toasts them, the CSV import summarises them |
| Applicant reads the public status tracker in English | Timeline and checklist were hardcoded Arabic; EXPIRED was invisible | Localized via the dictionary in the applicant's locale; EXPIRED shown |
| Enrollment email to the family | School's language, not the family's | Uses Application.lang |
| Offer email on a balqalam.com school | Hand-assembled …databayt.org URL pointed at the wrong root | tenantUrl() (which now also survives being called outside a request — crons, webhooks) |
| Direct-admit student browses the public site | Status banner greeted them with an application they never filed | Banner is PORTAL-only |
| Invoice currency | Re-read the live school currency instead of the assignment's snapshot | Inherits the snapshot |
Still open — flagged, not fixed, each with its reason:
- Instalment reminders and OVERDUE detection read only
FeeStructure.paymentSchedule(fee-due/fee-overduecrons). That JSON is populated only for auto-generated quarterly structures; a single-instalment or unscheduled admin-created fee has perfectly goodUserInvoice.due_datevalues that neither cron reads — so it never reminds, never goes overdue, never accrues a late fee. The fix belongs in both crons (an invoice-due-date path). Both files were mid-edit by another session on 2026-08-15. - Fee due dates and "today" windows are pure UTC (
buildQuarterlySchedule,todayBounds) — the same class of bug fixed in payroll;src/lib/timezone.tshas the helpers. - A withdrawn / archived student's open fees keep accruing fines and reminders — the
archivedAt: nullfilter was in flight in the other session's cron edits. No rejection reason— fixed 2026-09-04: a rejection or waitlist asks the reviewer for an optional note, stored onreviewNotes, carried in the family's notice and shown on the status tracker.- No refund path (
PaymentStatus.REFUNDEDis displayed, never set) and withdrawal does not void invoices — feature work. - One parent account, two children —
provisionStudentreuses a Student byuserId, and@@unique([schoolId, campaignId, userId])blocks a second application in the same campaign regardless ofallowMultipleApplications. Needs a product decision on account model. - Purging a student leaves
UserInvoicerows orphaned (feeAssignmentIdisON DELETE SET NULL) and keeps theUser. - Bulk section placement exists only in the dictionary; placement is one row at a time.
groupLabels/i18nGroupLabelsin every wizard config render no text —FormFooteruses the array only for its length. Localizing them further is wasted until the footer renders labels.
The production pass, 2026-09-04
A second end-to-end trace — wizard, offer, finance, notifications, roles, i18n/RTL, mobile — after the 08-15 audit. Six commits on main. Fixed:
| Journey step | What was broken | Now |
|---|---|---|
| Family accepts; the office is slow to confirm | The daily cron flipped every SELECTED offer past its deadline to EXPIRED — accepted and paid ones included; confirmEnrollment then refused them | Only unaccepted offers lapse. The deadline binds acceptance; the cron, confirmEnrollment, the offer page and the payment actions all apply the same rule |
| Registration fee booked at enrollment | The ledger step matched the student's annual fee assignment and flipped it to PAID on a deposit; the fee crons stopped chasing the year | PARTIAL unless the payment covers the balance; the Payment snapshots a currency |
| Offer quotes the registration fee | Summed every matching structure, pricing variants included; the school-wide arm had no isAutoGenerated filter — one number on the offer, another on the invoices | offer/fee-structures.ts picks the set enrollment will assign (one variant per grade) for both the preview and the amount due |
| Two admins confirm at once | Read-then-write; only the late Student.applicationId unique index caught it | Status-guarded updates for enrollment and payment confirmation |
| Family clicks a notification | Deep-linked to /admission (the staff tree — role gate); leads notices linked routes that do not exist | Applicant dashboard / offer page; Leads tab; dashboard home for placement and guardian notices |
| Family reads a status notice or the offer mail | School's language | Application.lang, school preference as fallback |
| Email link on a balqalam.com school | Hand-assembled …databayt.org host, no locale (Stripe/Tap return URLs too) | Root-aware, locale-prefixed; return URLs via tenantUrl() |
| Family turned email off for a type | The inline send consulted only the caller's channel list | Same per-channel gate the cron drain applies |
| Cash / bank / wallet fee confirmed | Only the applicant heard; STAFF saw the confirm item and got FORBIDDEN; Bankak / Cashi intents had no confirm item at all | Through the shared settler (offer/settle.ts): ADMIN + ACCOUNTANT told; item gated on recordPayment; one shared rail list |
| "Fees Pending" stat | Counted the vestigial application fee — everyone pending, forever | Registration fee |
| ACCOUNTANT / STAFF row menus | Status menu, Assign Section and Generate Merit List offered to roles the server refused; no sidebar entry for ACCOUNTANT / DEVELOPER | Gated on the permission table; sidebar mirrors ADMISSION_VIEW_ROLES |
| Grid view · export | Raw status enum on cards; export labelled in every toolbar but never wired | Translated labels; Applications + Enrollment CSV with localized headers, role-gated |
| Rejection / waitlist | Contentless notice; reviewNotes never written | Optional note dialog → notice + status tracker |
| Status tracker | A bare "Selected"; a vestigial application-fee checklist item | Offer link; offer-accepted + registration-fee items; the school's note |
| Manual payment intent | Lived only in component state — a reload sent the family back to the picker to record (and be quoted) twice; no receipt for the accountant to check | Restored from the server; receipt upload for bank / wallet rails, linked from the enrollment row and the detail page |
Re-offer (EXPIRED / WAITLISTED → SELECTED) | Kept a stale acceptance and unpaid intent; the token could already have lapsed, so the dashboard hid the offer | Reset (never a paid fee); token outlives the new deadline |
| OTP verification | Looked up by application number alone — five wrong codes locked the real applicant out | Bound to the email the code went to |
| RTL / mobile | rtl:flex-row-reverse double reversals in the wizard header and cards; campaign form 3 columns at 360px; ar-SA (Hijri) dates; dialogs without a scroll cap | Fixed |
| Family returns after the campaign window closed | /application answered "Enrollment is currently closed" the moment the campaign's end date passed — hiding a submitted application, a live offer and the fee state | Their applications render first; only a family with nothing on file sees the closed page, and "start a new application" gives way to a closed notice |
| STAFF / ACCOUNTANT click Admission in the sidebar | The link is the campaigns index, admin-only in the route matrix — a dead end at /unauthorized | The index lands non-admin roles on the Applications tab |
Still open after this pass — product decisions and follow-ups, each with its reason:
- One parent account, two children (unchanged):
provisionStudentpromotesApplication.userIdfrom USER to STUDENT, so a parent who applied on their own account logs back in as their child, and@@unique([schoolId, campaignId, userId])blocks a second application in the same campaign (submitApplicationalready maps the collision toAPPLICATION_DUPLICATE). Needs an account-model decision: a separate student User + the GUARDIAN role for the applicant. - Entrance / interview scheduling carries no date, time or venue — the schema has no columns, so both statuses notify with the generic message. Needs
interviewAt/interviewLocation(DDL) and a scheduling dialog. - No
EXPIREDnotice to the family or the admin when the cron flips an unaccepted offer. - Tours: no confirmation template exists for the family (only cancel / reschedule builders), and no day-before reminder.
- Links built outside a request (crons, webhooks) fall back to the primary root domain — a balqalam.com school's cron-sent links still carry
databayt.org. Needs the school's root recorded on theSchoolrow. - Admission notification copy lives in inline bilingual constants, not the dictionary /
NotificationTemplatetable — invisible to the tooling, unoverridable per school. - Registration fee materializes only at enrollment (a
Paymentneeds aStudent): a cash fee confirmed for a family that later withdraws is never on the books. createJournalEntryinsideconfirmEnrollment's transaction uses the global client — the ledger entry commits independently of the enrollment.- A started Stripe checkout is not expired when the family switches to a manual rail.
- An accepted-but-unpaid offer never lapses — the price of the expiry fix: the cron exempts accepted offers, so a family that accepts and then never pays holds the seat until an admin withdraws it. Needs a payment deadline distinct from the acceptance deadline, with its own reminder and lapse.
- WhatsApp / SMS are unused by admission; instalment reminders and OVERDUE detection still read only
paymentSchedule, in UTC (unchanged from 08-15).
The intake pass, 2026-09-05
A third trace, this time of all four intake channels to the assembly point and of everything after it, verified in the browser as ADMIN and as the applicant on the local demo (wizard → credentials dialog → Applications tab → Assign Section; portal SUBMITTED → SHORTLISTED → SELECTED → family accepts → Confirm Enrollment → student created, graded, notified). Fixed:
| Where | What was broken | Now |
|---|---|---|
| Production crons | vercel.json is crons: []; only the conference jobs were bridged — fee-due, fee-overdue, the email drain and document jobs never ran | admission-crons.yml bridges the four; the email drain has a 3-day age gate that retires the stale backlog before its first send |
| Wizard actions | getStudentForWizard had no auth(); draft / complete / delete had no role check | One guard (wizard/authorize.ts) for every wizard action |
| Editing an enrolled student | Save re-ran the core: new student code, new shadow Application (orphaning a portal one), re-sent welcome mail | The finisher recognises a provisioned row; the core reuses code / application / user |
| Editing a legacy student | A row with no application link (most pre-unification students) still re-ran the core, and the "one parent" gate — matching only the English type names — then locked the Save on the last step | Any row past the wizard saves and returns to the list; the gate guards provisioning only; parent types match by role |
| Guardian types | createOrLinkGuardian upserted by exact English name, so Arabic-seeded schools grew a second father/mother row and the edit form showed empty parents | Reuses the school's existing spelling (الأب, Father, father are one role); the guardian form fills once the parents load |
| Wizard credentials | The minted temp password was returned and never shown | One finisher opens the shared credentials dialog on the list page |
| Guardian WhatsApp | Stored in *Email columns, written to Guardian.emailAddress at enrollment; a shared household number merged two parents | Classified by shape; createOrLinkGuardian writes a whatsapp phone row; the detail page labels it |
| Placement for direct admits | Only the Enrollment tab (PORTAL) could place; an empty applyingForClass matched every section | One PlacementDialog, also on the students list; matching by grade id |
| Reminders | fee-due's invoice arm skipped every invoice whose assignment existed; fee-overdue read only paymentSchedule; archived students were chased | Skip only assignments reminded this run; overdue on invoice due dates; archived excluded |
| Arabic grade 12 | extractGradeNumber("الصف الثاني عشر") returned 2 | Longest ordinal wins |
| Public grade picker | Static KG1–12 regardless of the school | Narrowed to the school's grades |
| Tables after an action | Rows kept their mounted state after router.refresh(); a confirmed row still offered Confirm Enrollment | Platform tables adopt the server's rows when their content changes, and refetch instead while a search or filter is active |
Still open after this pass: a student with a grade and no fee assignment (the demo campaign is 2026-2027, every active structure is 2025-2026) is invisible to the reminders and the unplaced chip — only a warning toast at creation says so; the account-model, interview-date, tour-mail and out-of-request-link items from 2026-09-04 are unchanged.
Flow
A family moves through six stages. The first four happen on the public website (driven by the family); the last two happen in the staff dashboard.
Two invariants hold everywhere: applying is always free (the only payment is the registration fee, after an offer is accepted), and schools never see each other's data (every read and write is scoped to one school).
Public portal
Ask a question, book a tour, or check an application's status.
Application wizard
A logged-in family completes a five-step form that auto-saves and is resumable.
Staff review
Only staff-side roles may open the dashboard. Terminal statuses cannot re-offer.
Offer and registration fee
The one payment in the flow, taken only after an offer is accepted.
Enrollment
One all-or-nothing transaction turns an accepted applicant into a placed student.
Behind the scenes
Settings, AI document reading, reminder jobs, and notifications.
Structure
The admission feature follows the mirror pattern — every route under app/[lang]/ has a matching feature directory under components/, so if you know the URL you know where the code lives.
src/— Source code directoryapp/— Next.js App Router (Routing & Layouts)[lang]/— i18n supportabc/— URL route: /abcpage.tsx— Route entry pointlayout.tsx— Route layoutcomponents/— Component Logic (Mirrors app structure)abc/— Mirrors app/[lang]/abc/content.tsx— Page UI: headings, sections, layoutactions.ts— Server actions: validate, mutateconfig.ts— Enums, option lists, defaultsvalidation.ts— Zod schemas & refinementstypes.ts— Domain and UI typesform.tsx— Typed forms (RHF)card.tsx— KPIs, summaries, quick actionsall.tsx— List view with table, filtersdetail.tsx— Detail view with sectionscolumn.tsx— Table column buildersuse-abc.ts— Feature hooksREADME.md— Feature purpose, APIs, decisionsISSUE.md— Known issues and follow-upsatom/— Atomic UI componentstemplate/— Reusable layout templatesui/— Base UI components (shadcn/ui)Routes
| Tab | Route | Purpose |
|---|---|---|
| Campaigns | /{lang}/admission | Create / manage admission campaigns |
| Applications | /{lang}/admission/applications | Review submitted applications |
| Merit list | /{lang}/admission/merit | Rank applications by scores |
| Enrollment | /{lang}/admission/enrollment | Confirm enrollment for selected apps |
| Leads | /{lang}/admission/leads | Inquiries + tour bookings (ADMIN/ACCOUNTANT read-only) |
| Settings | /{lang}/admission/settings | Configure admission portal |
| Detail | /{lang}/admission/applications/{id} | Single application view |
Status flow
| Status | Trigger | What happens |
|---|---|---|
DRAFT | Session created | Form data being collected |
SUBMITTED | Applicant submits | Application number generated, email sent |
UNDER_REVIEW | Admin updates | reviewedAt + reviewedBy set |
SHORTLISTED | Admin updates | Applicant notified |
ENTRANCE_SCHEDULED | Admin schedules | Entrance exam date set |
INTERVIEW_SCHEDULED | Admin schedules | Interview date set |
SELECTED | Admin selects | Offer created (configurable expiry), payment link |
EXPIRED | Daily job (offer lapsed) | Offer past expiry auto-flips; admin can re-offer |
WAITLISTED | Admin waitlists | Applicant notified |
REJECTED | Admin rejects | Applicant notified |
ADMITTED | Admin confirms enrollment | Student + Guardians + Fees + Invoices created |
WITHDRAWN | Applicant or admin withdraws | Application closed |
Enrollment transaction
confirmEnrollment() keeps the application-specific work (status guard, ENR-{yy}-{seq} enrollment number, the registration-fee Payment + ledger entry) and delegates the actual student creation to the shared provisionStudent() core (src/lib/student-provisioning.ts) — the same core the admin wizard and bulk import call. Inside one db.$transaction:
- Set application status
ADMITTEDwith the enrollment number. provisionStudent():- Resolve
AcademicGrade, generate the per-school student code (YYGGNNNN). - Create
User(roleSTUDENT) if the applicant was a guest; promoteUSER→STUDENTotherwise. User is created before fees — invoices no-op without auserId. - Create the
Student(or reuse a wizard draft), linkinguserId+applicationId. - Match
applyingForClass→YearLevel; createStudentYearLevel+ setacademicGradeId. - Auto-assign active
FeeStructures→FeeAssignmentrows (PENDING) and oneUserInvoiceper assignment (viaensureStudentFeeAssignments→ensureInvoicesForAssignment). - Create
Guardian+StudentGuardian+GuardianPhoneNumber; copy application documents toStudentDocument.
- Resolve
- Materialize the registration-fee
Payment+ journal entry when the registration fee was paid.
Post-transaction (best-effort, owned by the caller): notify student + guardians, mint a password-reset link for a new guest user, dispatch fee_due notifications, compute a section suggestion. The student is created with wizardStep: null and status: ACTIVE. The admin wizard and CSV import call provisionStudent() the same way (with origin ADMIN_DIRECT / BULK_IMPORT / ONBOARDING_IMPORT), which is what gives every channel the same login + invoices.
Application → Student data transfer
| Application | Student | Notes |
|---|---|---|
| firstName / middleName / lastName | same | Direct copy |
| dateOfBirth, gender, nationality | same | Direct copy |
| Direct copy | ||
| phone / alternatePhone | mobileNumber / alternatePhone | Direct copy |
| address, city, state, postalCode, country | currentAddress, … | Direct copy |
| photoUrl | profilePhotoUrl | Profile picture |
| previousSchool / previousClass | previousSchoolName / previousGrade | Direct copy |
| previousMarks + previousPercentage + achievements | previousAcademicRecord | Combined string |
| fatherName / motherName | Guardian record | Creates Guardian + StudentGuardian |
| fatherPhone / guardianPhone | emergencyContactPhone | First available used |
| guardianRelation | emergencyContactRelation | Falls back to "Parent" |
| documents (JSON) | StudentDocument records | Each document creates a row |
| applyingForClass | academicGradeId | Via YearLevel → AcademicGrade |
Guardian auto-creation
For each parent (father, mother):
- Split name into
firstName+lastName(last space). - Upsert
GuardianType(father,mother). - Create
Guardian(or find by email). - Create
StudentGuardianjoin row with occupation,isPrimary. - Create
GuardianPhoneNumberif phone provided.
Father is primary; if no father, mother becomes primary.
Section placement
After enrollment, place the student in a section:
- Enrollment response includes
suggestedSectionIdif exactly one section has capacity for the student's grade. - Lists sections with available capacity for that grade.
placeStudentInSection()assigns the seat, then gives the student the LMS courses of the section's grade viasyncStudentSubjectEnrollments().
Wizard editing
Enrolled students can be edited via the student wizard (Students table → Actions → Edit). All application data is pre-populated.
updateStudentWizardStep only updates wizardStep for draft students (non-null wizardStep) — prevents accidental reversion of enrolled students. Final button reads "Save", not "Create".
Merit list
Admins enter entrance and interview scores directly on the merit table (0–100 per score). generateMeritList() computes a weighted meritScore for each application:
- Entrance exam — 60%
- Interview — 40%
Applications are ranked by meritScore descending; unscored applications sort last. Scores are batch-written via updateApplicationScores(). The merit table covers shortlisted, selected, and waitlisted applications.
Offer → accept → pay → enroll flow
Once an applicant is SELECTED:
- An offer is created with a configurable expiry (
offerExpiryDays, default 14). - The applicant logs in via a token'd offer URL (login preserves the full callback path).
- The applicant pays the registration fee through the school's gateway (Tap for UAE/Gulf, Stripe elsewhere). The application wizard shows an informational payment preview — no payment is collected at the application stage.
- On payment success, the admin sees a registration-fee paid banner and can proceed.
- Admin clicks "Confirm enrollment" →
confirmEnrollment()transaction fires (see Enrollment transaction). - Post-enrollment the admin uses the Placement dialog on the enrollment table to pick a section (seat counts shown);
placeStudentInSection()puts the student on the section's roster and timetable and gives them their grade's subjects in Lumos.
Abandoned-checkout retry is unblocked — applicants who navigated away can return and complete payment.
Leads tab
The Leads tab (/{lang}/admission/leads) surfaces:
- Inquiries — contact form submissions with name, email, phone, interest grade, source, and follow-up status.
- Tour bookings — scheduled visits with attendee count, status (
PENDING/CONFIRMED/CANCELLED), and reschedule/cancel actions.
ADMIN and STAFF roles have full action access; ACCOUNTANT has read-only access. New inquiries and bookings trigger in-app notifications to ADMIN + STAFF. Tour bookings guard against overselling (TOCTOU-safe) and honour the enableTourBooking campaign flag; cancel/reschedule decrements the attendee count correctly.
Document-AI processing
Uploaded applicant documents (ID scans, transcripts) are processed by an extraction queue. The cron /api/cron/process-document-jobs runs every 10 minutes and drains pending jobs. classifyDocument() and getDocumentProcessingStatus() are RBAC-gated. AI usage is budget-checked (canUseAI) before each call; the bank-receipt Zod schema is optional so partial extractions do not fail the whole job.
Settings
{
allowMultipleApplications: false,
requireDocuments: true,
// applicationFee removed — applying is always free
offerExpiryDays: 14, // 1-90
autoEmailNotifications: true,
enableOnlinePayment: false,
paymentMethods: ["stripe", "cash"],
bankDetails: { bankName, accountName, accountNumber, iban, swiftCode },
cashPaymentInstructions: "",
entranceWeight: 60, // merit weights (sum=100)
interviewWeight: 40,
}Server actions (admin, auth + permission)
| Action | Permission |
|---|---|
getApplications() | viewApplications |
updateApplicationStatus() | updateStatus |
confirmEnrollment() | confirmEnrollment |
placeStudentInSection() | confirmEnrollment |
getAvailableSectionsForPlacement() | confirmEnrollment |
generateMeritList() | manageMerit |
updateApplicationScores() | manageMerit |
getCampaigns() / createCampaign() / updateCampaign() / deleteCampaign() | viewCampaigns / manageCampaigns |
getAdmissionSettings() / saveAdmissionSettings() | manageSettings |
fetchCampaignOptions() | viewCampaigns |
classifyDocument() / getDocumentProcessingStatus() | viewApplications (RBAC-gated) |
Multi-tenant safety
All queries scoped by schoolId. Submit flow resolves school from subdomain via getSchoolBySubdomain(); session is verified to belong to that school.
Key files
| Purpose | Path |
|---|---|
| Provisioning core | src/lib/student-provisioning.ts (shared by all 4 channels) + src/lib/system-campaign.ts (direct-admit Application + system campaign) |
| Dashboard actions | src/components/school-dashboard/admission/actions.ts |
| Applications surface | src/components/school-dashboard/admission/applications-{table,columns}.tsx + application-detail-content.tsx |
| Enrollment / merit | src/components/school-dashboard/admission/{enrollment,merit}-table.tsx |
| Leads tab | src/components/school-dashboard/admission/leads/ |
| Document AI | src/components/school-dashboard/admission/ai/{classify,bank-receipt-schema}.ts |
| Settings | src/components/school-dashboard/admission/settings/{actions,validation}.ts |
| Invoice utility | src/components/school-dashboard/finance/invoice/actions.ts |
| Schema | prisma/models/admission.prisma |
| Tests | src/components/school-dashboard/admission/__tests__/{actions,enrollment-pipeline}.test.ts + finance/invoice/__tests__/create-from-enrollment.test.ts |
See also
- Application — applicant-side flow
- Multi-tenancy —
schoolIdscoping - Invoice — auto-generation from enrollment
- Fees — fee assignment lifecycle
On This Page
Student intake — four channels, one pipelineEvery channel is trackableKeeping the invariant trueTelling the family — one dispatcher, every channelNobody falls out of the flowThe journey, audited end to end (2026-08-15)The production pass, 2026-09-04The intake pass, 2026-09-05FlowPublic portalApplication wizardStaff reviewOffer and registration feeEnrollmentBehind the scenesStructureRoutesStatus flowEnrollment transactionApplication → Student data transferGuardian auto-creationSection placementWizard editingMerit listOffer → accept → pay → enroll flowLeads tabDocument-AI processingSettingsServer actions (admin, auth + permission)Multi-tenant safetyKey filesSee also