schools
The tenant. Everything else hangs off it.
- nametext
- codeuniquetext
- boardtext
- plantext
- timezonetext
- statustext
The engineering blueprint behind this School ERP: what runs in this demo, and the production system it's designed to become.
An honest split between what runs here and what is designed.
This demo (running now)
Production design (not running)
The demo keeps the production layering so each piece has a clear home when it moves to the server. Components never hold business rules; they call a store action, which plays the role of an API handler.
The production design this demo stands in for, and the path one request takes through it
This backend is designed, not running. The demo you're clicking through runs in the browser on generated sample data, but it follows the same layering, so each step below points at the file that plays that part today.
Web app
React + Vite single-page app, built to static files
Nginx
The only public entry point
Axum API
Rust, Tokio, sqlx, serde; stateless, so it scales horizontally
The API depends on
PostgreSQL
Row-level securitySystem of record, reached through an sqlx pool
Redis
Fast, disposable state
RabbitMQ and workers
The API publishes events; workers consume them with the same service code
Object storage
Behind a StorageProvider trait; files served by short-lived signed URLs
PaymentProvider
create, verify, refund, status; no card data touches the API
Webhooks come back through Nginx to the API, which checks the signature before touching an invoice.
AIProvider
Optional, off by defaultInsights and drafting; the admin picks the model
An accountant records a counter payment: POST /api/v1/payments.
Terminate TLS and tag the request in Nginx
Decrypts HTTPS, applies the per-IP rate limit, adds an X-Request-Id that every log line and the response will carry, and proxies /api/v1 to the API.
Verify the access token in Auth middleware
Checks the JWT signature and expiry and reads sub, school_id, role and exp. The role's permission set comes from Redis, keyed by role and version, so an edited role applies on the next request.
In this demo
The account switcher stands in for the token: buildAccess() in access.ts resolves the role, tenant and row scope.
Check the permission in Route extractor
The handler takes RequirePermission<FeesCollect>, so it can't run unless fees.collect is in the caller's set. Hiding the button in the UI is never the only check.
In this demo
Every store action starts with authorize(ctx, "fees.collect"), even when the button was already disabled.
Load the invoice inside the tenant in Service and repository
The repository query is WHERE school_id = $1 AND id = $2, with row-level security behind it. An invoice from another school comes back as not found, never forbidden, so the API doesn't reveal that it exists.
In this demo
createRepo(db, ctx).invoice(id) only sees the caller's school and scope; a miss throws RuleError with NOT_FOUND.
Apply the business rule in Service
validate_payment rejects a zero or negative amount and anything above the balance, and refuses payments on a refunded invoice.
In this demo
The same rule is validatePayment(inv, amount) in rules.ts.
Write everything or nothing in Postgres transaction
One transaction, with app.school_id set for row-level security, inserts the payment, updates the invoice's paid amount, status and version, writes an audit_logs row with before and after, and writes an outbox row for the event.
In this demo
commit(set, patch, [auditEntry(…)]) applies the change and the audit entry in one store update.
Notify after commit in RabbitMQ and workers
The outbox relay publishes PaymentReceived. The notification worker renders the payment template for the parent and sends email and push. A broker outage delays the message; it can't lose the payment.
In this demo
Not simulated: the demo shows a toast and the audit entry instead of sending anything.
Respond with the envelope in Handler
201 with success, the payment and receipt number in data, and the request id in meta. Errors use the same shape with errors filled in.
In this demo
useRun() shows the success toast, or turns a RuleError into an error toast with a readable reason.
Demo files: src/stores/school/finance-actions.ts (the recordPayment action), src/data/school/access.ts, repo.ts and rules.ts. The chain is the same for every write: useRun() calls a store action, which runs authorize, reads through createRepo, validates with rules.ts and commits with an audit entry.
Spec §3. One database, many schools; no school can ever read another's rows.
Every tenant-owned table carries school_id; branch, year, class and section narrow it further. In this demo every record type in types.ts has schoolId for the same reason.
The tenant comes from the signed JWT (school_id, role), never from the URL or request body. Only the platform super admin may switch tenants, and that switch is itself audited.
buildAccess pins every persona to its school; only the super admin role reads the tenant switcher.
Every service call starts with authorize(ctx, permission), then checks row scope (a teacher's sections, a parent's children) before any business rule runs.
Every store action calls authorize(ctx, perm) first, then validates with rules.ts.
Repositories add WHERE school_id = $1 AND deleted_at IS NULL to every query. Composite foreign keys on (school_id, id) make cross-tenant references impossible.
createRepo in src/data/school/repo.ts filters school → branch → scope on every read.
Postgres row-level security as the backstop: policies compare school_id with current_setting('app.school_id'), set per transaction with SET LOCAL. The API connects as a role that can't bypass RLS.
No database in the demo. Designed; see the migration sketch in section 8.
Menus hide what a role can't open and controls explain why they're disabled. A convenience, never the security boundary.
RequirePermission and Gate in the page UI; the store re-checks anyway.
Records outside a caller's reach are indistinguishable from records that don't exist: the API answers 404, not 403, so it never confirms that another school's student exists.
school636 students in reach65 tables in 12 domains, designed for PostgreSQL
The designed schema behind the demo's data model. Keys are UUIDs, every tenant row carries school_id, and the database itself enforces the rules that matter most: unique numbers per school, no double-booked teachers, rooms or beds, and marks that can't exceed the paper's maximum.
Every table carries these columns
They're left off the cards below so each card shows only what's specific to the table.
gen_random_uuid(). Join tables use a composite key instead.
On every tenant table, child rows included, so one row-level security policy fits all of them.
Defaults to now().
Set by a trigger on update.
Null for rows written by background jobs.
Last editor.
Soft delete, only on tables marked soft delete.
Soft delete (a deleted_at timestamp, filtered out by the repository and the list indexes) on users, students, parents, admission_applications, invoices, books, vehicles, employees and documents. Other rows are deleted for real or closed with a status or end date.
Append-only: audit_logs has no updated_at, updated_by or deleted_at, and the API's database role can only insert and read it.
A school has branches and academic years. Users sign in once; roles map to permission sets, and a user holds roles per school or branch.
The tenant. Everything else hangs off it.
Campuses of one school.
2026–27 and so on; exactly one is current per school.
Typed key and value per school, optionally per branch.
Sign-in accounts. school_id is null only for platform admins.
Built-in roles have a null school_id; custom roles belong to a school.
The module.action catalogue, such as fees.collect.
Which permissions a role grants.
Role assignments, optionally limited to one branch.
Rules the database enforces even if application code has a bug. Unique and check violations surface as 409 and 422 from the API.
52 designed REST endpoints, each with the permission it requires
Paths sit under /api/v1, except /health and /ready. The permission column uses the same keys as the demo's role matrix, and in the designed Rust code each handler takes RequirePermission<…> for that key, so the route and its documentation can't disagree. Row scope applies on top: a teacher with students.read still only sees their own classes.
Every response has the same five keys, so the web app has one parser and one error handler. Lists put the next cursor in meta; there are no page numbers to drift when rows are added.
1{2 "success": true,3 "data": [4 {5 "id": "3f9c2b1e-8d4a-4b7e-a0c5-6e2f19d8b734",6 "admission_no": "NWA-2021-0142",7 "first_name": "Rahul",8 "last_name": "Sharma",9 "class": "8",10 "section": "A",11 "status": "active"12 }13 ],14 "message": null,15 "errors": null,16 "meta": {17 "cursor": "eyJjIjoiMjAyNi0wOS0xMlQwOTo0MTowN1oiLCJpIjoiM2Y5YzJiMWUtOGQ0YS00YjdlLWEwYzUtNmUyZjE5ZDhiNzM0In0",18 "limit": 50,19 "request_id": "req_01J8ZQ4M7K2V9T3X"20 }21}1{2 "success": false,3 "data": null,4 "message": "Validation failed",5 "errors": [6 {7 "field": "amount",8 "code": "exceeds_balance",9 "message": "The balance is 12,400. Enter that amount or less."10 },11 {12 "field": "method",13 "code": "invalid_choice",14 "message": "Choose cash, UPI, card or bank transfer."15 }16 ],17 "meta": {18 "request_id": "req_01J8ZQ5B2N6R4H8W"19 }20}404, exactly like one that doesn't exist, so ids can't be probed across tenants.utoipa: each #[utoipa::path] declares the request, responses, permission and examples, and CI would fail if the published spec changed without a version bump.Spec §20. Permissions are module.action; roles are permission sets plus a row-level scope.
Source: builtInRoles in src/data/school/rbac.ts. In the production schema these become rows in roles, permissions, role_permissions and user_roles; custom roles (like the spec's Finance manager) are tenant-owned rows. Admins edit them in Settings, and every change is written to the audit log.
Spec §42–43. Real access contexts, the real repository and the real rules, run against the data you see in this demo.
14 of 15 passing
Run 1 in this browser. Edits you make in other pages are included; role edits in Settings are not.
A Northwind admin can't read Globex studentsSpec §3, §43
Asked for Globex, but the context stayed pinned to NWA. 636 Northwind students visible, 0 of 220 Globex students; a direct lookup by a Globex id returned undefined.
buildAccess(db, { roleId: "school_admin", schoolId: "gis" }) → repo.students().every(s => s.schoolId === "nwa") && repo.student(globexId) === undefined
Super admin sees a tenant only after switching to itSpec §3
On Northwind: 636 students, all Northwind. Switched to Globex: 220 students, all Globex. Never both at once.
as("super_admin", "nwa").students() are all nwa; as("super_admin", "gis").students() are all gis
A parent sees exactly their two childrenSpec §43
Visible: Rahul Sharma and Diya Sharma (2 of 636 Northwind students).
as("parent").repo.students().map(s => s.id) equals [rahul, diya]
A parent can't open another family's childSpec §43
Looking up Samar Kulkarni returned undefined, the same as a record that doesn't exist. 6 invoices visible, all for their own children.
as("parent").repo.student(otherChildId) === undefined && repo.invoices() only for own children
A teacher's students are only in his sectionsSpec §43
47 students across 2 sections (6-B, 8-A); the other 589 Northwind students are out of reach.
as("teacher").repo.students().every(s => taughtSections.has(s.sectionId))
A teacher can't mark attendance for a class he doesn't teachSpec §43
He holds attendance.create, yet Class 1-A isn't among his 2 sections and its roster reads empty, so saveAttendance rejects it with FORBIDDEN.
!as("teacher").repo.sections().some(s => s.id === otherSectionId) && repo.studentsIn(otherSectionId).length === 0
A student can't read audit logsSpec §43
repo.audit() returned 0 of 21 entries, and authorize threw FORBIDDEN: “Student accounts don't have the audit.read permission”.
as("student"): repo.audit().length === 0 && authorize(ctx, "audit.read") throws FORBIDDEN
An accountant can't manage the timetableSpec §43
timetable.manage → FORBIDDEN, “Accountant accounts don't have the timetable.manage permission”. Control check: fees.collect is allowed.
authorize(as("accountant").ctx, "timetable.manage") throws FORBIDDEN; "fees.collect" passes
A librarian can't collect feesSpec §43
fees.collect → FORBIDDEN, “Librarian accounts don't have the fees.collect permission”. Control check: library.issue is allowed.
authorize(as("librarian").ctx, "fees.collect") throws FORBIDDEN; "library.issue" passes
Invoice status is derived from amounts and due dateSpec §13
5 of 5 cases: paid in full → Paid, part paid → Partial, unpaid, past due → Overdue, unpaid, not yet due → Pending, refunded → Refunded.
deriveInvoiceStatus → Paid, Partial, Overdue, Pending; Refunded stays Refunded
Library fines count days past dueSpec §15
Returned 3 days late: ₹15. Returned early: ₹0. Still out, 7 days past due on 29 Sep: ₹35.
libraryFine(3 days late) === 3 × 5; on time → 0; still out → days to today × 5
Grade bands follow the CBSE-style scaleSpec §12
6 of 6: 95% → A1, 81% → A2, 80.9% → B1, 35% → D, 34.5% → E, 0% → E.
gradeFor(95) A1, gradeFor(81) A2, gradeFor(80.9) B1, gradeFor(35) D, gradeFor(34.5) E
Timetable conflicts are detectedSpec §11
A copy of the day 1, period 1 slot collides on class. Saving the original slot unchanged finds 0 conflicts.
findSlotConflicts(copyOf(slot), timetable) flags class, teacher and room; ignoring the slot itself → []
Payslip net equals gross minus deductionsSpec §18
Vikram Nair: gross ₹84,300 − PF ₹7,440 − TDS ₹0 − professional tax ₹200 = ₹76,660.
p.net === p.gross − p.pf − p.tds − p.professionalTax && p.gross === basic + allowances
Payments can't exceed the balanceSpec §13
Invoice INV-2026-N00003, balance ₹26,900: ₹26,901 rejected (“The balance is 26,900. Enter that amount or less”), ₹0 rejected, the exact balance accepted.
validatePayment(inv, balance + 1) throws VALIDATION; validatePayment(inv, balance) === balance; 0 is rejected
The checks live in src/components/school/architecture/auth-tests.ts as pure functions returning a name, a result and what happened. Each one builds an access context with buildAccess and reads through createRepo, the same path every page in this demo uses.
In the production design the same cases become integration tests against a seeded Postgres (designed with sqlx::test), plus API tests that assert status codes: another tenant's record returns 404, a missing permission returns 403.
Rust, SQL and config sketches of the designed backend
These are design sketches, not code that runs in this demo. They show how the rules the demo enforces in the browser would be enforced on the server: tenant filters in every query, a permission check in every handler signature, one error shape, and a gateway behind a trait.
The Rust counterpart of the demo's createRepo(db, ctx): every query starts from the caller's tenant and row scope.
WHERE s.school_id = $1 AND s.deleted_at IS NULL on every read; the tenant comes from verified claims, never the request.app.school_id, so row-level security backs up the WHERE clause.1use chrono::{DateTime, Utc};2use serde::{Deserialize, Serialize};3use sqlx::{FromRow, Postgres, Transaction};4use uuid::Uuid;56use crate::{error::AppError, Db};78/// The caller's tenant and reach, built from verified JWT claims. Never from the request body.9#[derive(Clone, Debug)]10pub struct Tenant {11 pub school_id: Uuid,12 pub scope: RowScope, // School, Sections(Vec<Uuid>) for teachers, Students(Vec<Uuid>) for families13}1415/// Keyset cursor: where the previous page ended. Clients see it as opaque base64.16#[derive(Clone, Copy, Debug, Serialize, Deserialize)]17pub struct Cursor {18 pub created_at: DateTime<Utc>,19 pub id: Uuid,20}2122pub struct StudentFilter {23 pub academic_year_id: Uuid,24 pub section_id: Option<Uuid>,25 pub status: Option<String>,26 pub limit: i64,27}2829#[derive(Debug, FromRow, Serialize)]30pub struct StudentRow {31 pub id: Uuid,32 pub admission_no: String,33 pub first_name: String,34 pub last_name: String,35 pub section_id: Uuid,36 pub status: String,37 pub created_at: DateTime<Utc>,38}3940pub struct Page<T> {41 pub items: Vec<T>,42 pub next: Option<Cursor>,43}4445const LIST_SQL: &str = r#"46 SELECT s.id, s.admission_no, s.first_name, s.last_name,47 e.section_id, s.status, s.created_at48 FROM students s49 JOIN enrollments e50 ON e.student_id = s.id51 AND e.school_id = s.school_id52 AND e.academic_year_id = $253 WHERE s.school_id = $154 AND s.deleted_at IS NULL55 AND ($3::uuid IS NULL OR e.section_id = $3)56 AND ($4::uuid[] IS NULL OR e.section_id = ANY($4))57 AND ($5::uuid[] IS NULL OR s.id = ANY($5))58 AND ($6::text IS NULL OR s.status = $6)59 AND ($7::timestamptz IS NULL OR (s.created_at, s.id) < ($7, $8))60 ORDER BY s.created_at DESC, s.id DESC61 LIMIT $962"#;6364impl Db {65 /// Every repository call runs in a transaction that pins the tenant for row-level security.66 /// set_config(..., true) is transaction-local, like SET LOCAL, but accepts a bind parameter.67 pub async fn begin_tenant(&self, tenant: &Tenant) -> Result<Transaction<'static, Postgres>, AppError> {68 let mut tx = self.pool.begin().await?;69 sqlx::query("SELECT set_config('app.school_id', $1, true)")70 .bind(tenant.school_id.to_string())71 .execute(&mut *tx)72 .await?;73 Ok(tx)74 }75}7677pub struct StudentRepo {78 db: Db,79}8081impl StudentRepo {82 /// One page of the students this caller may see, newest first.83 pub async fn list(84 &self,85 tenant: &Tenant,86 filter: &StudentFilter,87 cursor: Option<Cursor>,88 ) -> Result<Page<StudentRow>, AppError> {89 let limit = filter.limit.clamp(1, 100);90 let mut tx = self.db.begin_tenant(tenant).await?;9192 let mut items: Vec<StudentRow> = sqlx::query_as(LIST_SQL)93 .bind(tenant.school_id) // $1: tenant first, always94 .bind(filter.academic_year_id)95 .bind(filter.section_id)96 .bind(tenant.scope.section_ids()) // $4: None unless the caller is a teacher97 .bind(tenant.scope.student_ids()) // $5: None unless parent or student98 .bind(filter.status.as_deref())99 .bind(cursor.map(|c| c.created_at))100 .bind(cursor.map(|c| c.id))101 .bind(limit + 1) // one extra row says whether a next page exists102 .fetch_all(&mut *tx)103 .await?;104 tx.commit().await?;105106 let next = if items.len() as i64 > limit {107 items.truncate(limit as usize);108 items.last().map(|r| Cursor { created_at: r.created_at, id: r.id })109 } else {110 None111 };112 Ok(Page { items, next })113 }114}Spec §21, §36–37, §40 and §48. All designed; the demo's equivalents are noted where they exist.
One scheduler instance fires at a time (a Postgres advisory lock), and it only enqueues work. Workers consume from RabbitMQ with idempotency keys, retries with exponential backoff and a dead-letter queue. Notification jobs render the templates you can edit under Communication. In this demo the scheduler doesn't run; the Delivery tab shows a simulated log.
Liveness
GET /health answers while the process is up; the orchestrator restarts it otherwise.
Readiness
GET /ready checks Postgres, Redis and RabbitMQ; Nginx stops routing to an instance that isn't ready.
Request ids
Nginx sets x-request-id; the API adds it to every log line, error envelope and queued job, so one id follows a request into the workers.
Structured logs
JSON via tracing: route, status, latency, school_id and user id. No passwords, tokens or card data, ever.
Metrics
Request duration histograms per route, queue depth, job failures and retries, database pool usage, exported for Prometheus.
Error tracking
Unhandled errors go to an error tracker with PII scrubbed; users only see a friendly message and the request id.
Audit logs are a separate, append-only table for business events (who changed what), not application logs.
In the demo there is no sign-in to attack: the account switcher stands in for authentication, and the permission and tenant checks above are what it exercises.
In the demo: the repository builds its lookup maps lazily once per render pass, and term attendance comes from a deterministic generator instead of storing about 64,000 rows (856 students over 75 school days) in the browser.
Spec §53. The backend for every milestone is designed, not built: this demo has no server, database, queue or containers.
Measured against the spec's definition of done (§51), no feature here is finished: each still needs its migration, API endpoint, server-side validation and authorization, API integration and automated tests. What the demo does settle is the UI, the loading, empty and error states, and the business rules those endpoints will call.
Spec §52. The calls that shape the system, and what could go wrong.
Shared database, school_id on every row, RLS as a backstop
Simpler migrations and platform reporting than a schema per tenant. A very large tenant can later move to its own database without code changes.
Rust and Axum for the API
Predictable latency and memory safety. The trade-off is a smaller hiring pool, so business rules stay in plain, well-tested service functions.
RabbitMQ for background work
Durable queues, retries and dead-letter queues for SMS, email and reports. More to operate than Redis streams, but safer for fee reminders that must not be lost or sent twice.
Hosted checkout and webhooks for payments
Card data never touches our servers. Payments are confirmed by verified webhooks, not by the browser.
AI behind an AIProvider interface, off by default
OpenAI or a local model can be swapped without touching features. Outputs are drafts that staff review, never automatic decisions about a child.
Children's personal data
Design for India's DPDP Act: verifiable parental consent, data minimisation, retention limits and export or erasure on request.
SMS delivery rules
Indian carriers require DLT-registered sender IDs and templates; unregistered messages are dropped. Template changes need re-registration.
Role misconfiguration
A wrong permission is a data leak. Every role change is audited, the super admin role is locked, and admins can preview a role with View as.
Data growth
Attendance and audit tables grow fastest. Partition by academic year, archive closed years, and keep dashboards on rollups.
Patchy connectivity in classrooms
Attendance should survive a dropped connection: queue locally and sync, with the server resolving duplicates by (section, date).
This demo's limits
It runs in one browser on generated data and resets on reload. It shows the product and the rules, not operational behaviour under load.