Skip to content
Abdullah.
All work

Case study · March 2024 – present

The platform a five-branch weekend school runs on

Founding engineer on an internal staff platform for a five-branch school network: registration, class allocation, attendance and behaviour, replacing paper registers and spreadsheets. No public URL — so this is written up as architecture and decisions.

Role
Founding Engineer — sole engineer, product decisions and build
Timeframe
March 2024 – present
Access
Internal — no public deployment

No public demo

Internal system holding children’s records — no public demo and no public repository. Written up as architecture and decisions instead, which is the more useful read anyway.

At a glance

Problem

Five weekend branches, each running one day a week, tracked registration, classes, attendance and behaviour on paper registers and spreadsheets. Nothing was shared between branches, nothing was queryable, and a student’s history existed only wherever the last person to write it down had left it.

Approach

Model the domain properly first — branches, classes, teacher assignments, students, guardians, attendance sessions — and push the rules that must never break into database constraints. Then build the smallest operational loop staff can actually run a Saturday on, with branch isolation enforced server-side on every read and write.

Outcome

The core loop is built and running: staff authentication, branch scoping and switching, class management with teacher assignment, student and guardian records, student profiles, and attendance capture with history. Behaviour records with opt-in parent email, and staff user management, are the remaining MVP verticals.

Stack

  • Next.js 16 (App Router)
  • React 19
  • TypeScript
  • PostgreSQL (Neon)
  • Drizzle ORM
  • Auth.js v5
  • Zod
  • Tailwind CSS v4
  • Resend
  • Vercel

System diagram

Request path and where authorisation happens

Every dashboard request passes a route proxy, renders on the server, and reaches the database only through permission helpers that re-derive the user’s branch access from the database on each request. Mutations additionally pass Zod validators. The cookie holding the selected branch is never trusted on its own. Parent email is a side effect of one action, and only when a staff member explicitly opts in.

6 layers · 11 components · 13 connections

  1. Client

  2. Edge

  3. Server

  4. Authorisation & validation

  5. Data

  6. External

Decisions

7 choices where the alternative was reasonable. The rejected option is the interesting half.

  1. Rejected
    OAuth sign-in and self sign-up
    Chosen
    Email and password only, with accounts pre-provisioned by an admin
    Because
    The system holds children’s records. A public sign-up route is a door I would then have to defend; there is no scenario where a stranger should be able to create an account. The organisation already knows exactly who its staff are, so provisioning is a one-line admin job rather than a security surface.
  2. Rejected
    Auth.js database adapter tables
    Chosen
    JWT sessions against the domain users table
    Because
    With no OAuth there is nothing for accounts, sessions or verificationTokens to hold. Keeping them would have meant three tables and a set of joins that existed only to satisfy a library. Revisit only if database sessions or OAuth ever become a requirement.
  3. Rejected
    Filtering by branch in the client, or trusting the branch cookie
    Chosen
    Server-side permission helpers that re-derive branch access from the database on every request
    Because
    Cross-branch leakage is the one bug here that would genuinely matter. A cookie is a value the user controls, so the selected branch is treated as a request rather than a fact: it is accepted only if it still appears in that user’s assignments, and otherwise falls back. Unauthenticated and unauthorised are also deliberately different failure modes — one redirects to sign-in, the other throws.
  4. Rejected
    Enforcing integrity in application code
    Chosen
    Partial unique indexes and foreign keys in Postgres
    Because
    Application code forgets. The database does not. One active class assignment per teacher per branch, one active teacher per class, one primary guardian per student, one attendance session per class per date, one attendance record per student per session — these are all indexes, so a double-submitted register or a race between two admins fails at the write instead of quietly creating a duplicate.
  5. Rejected
    Supabase
    Chosen
    Neon Postgres with Drizzle ORM
    Because
    I had used Supabase on a side project and liked it, but this app already needed its own staff auth with admin provisioning, so Supabase Auth would have been a second identity system to reconcile rather than one to adopt. Drizzle keeps the schema as type-checked TypeScript, and generated migrations are committed and applied deliberately rather than pushed from a dev machine.
  6. Rejected
    Cross-branch dashboards and org-wide reporting
    Chosen
    Every screen scoped to exactly one selected branch
    Because
    A branch admin’s question is always "what is happening here, today". Allowing any screen to span branches would have put a permissions decision behind every query and made the UI ambiguous about which data a user is looking at. Org-wide reporting is a later feature with its own access rules, not a default.
  7. Rejected
    Automatic parent notifications
    Chosen
    Opt-in email at the moment a merit or demerit is recorded
    Because
    Automated messages to a parent about their child’s behaviour is precisely the thing you do not want firing on a rule you wrote six months ago. A staff member chooses to send, and the record stores both that it was requested and whether it actually sent, so "did the parent get told" is answerable.

The problem

Five weekend branches across a single city, each running on exactly one fixed day — three on Saturday, two on Sunday. Between them they handle registration, class allocation, attendance and behaviour, and until 2024 they handled all of it on paper registers and spreadsheets.

The failure mode wasn't dramatic. It was that nothing was answerable. Which classes exist at this branch? Has attendance actually been taken this week? What does this student's attendance look like over a term? Every one of those questions meant finding the person who happened to write it down, and hoping they still had the sheet. A student's history existed in whatever form the last volunteer to touch it had left it in.

The constraints

The constraints shaped the design more than the requirements did.

The users are volunteers, not staff at a desk. Teachers turn up on a Saturday morning, take a register, and go home. Anything that needs training, or that punishes a mis-tap, doesn't get used — it gets replaced by the paper sheet that always works.

The data is children's records. Names, dates of birth, guardian contact details, medical conditions, behaviour notes. That single fact removed a set of options from the table before I wrote any code: no public sign-up, no ambiguity about who can see what, no leaving access control to the client.

Branch isolation is the rule that must not break. Staff can be assigned to several branches, but they work inside one at a time, and a teacher at one branch must never see another branch's students — not by accident, not by changing a URL, not by editing a cookie. Everything else in the system can have a bug. Not this.

One engineer, part-time. I'm the only person who works on it. That's an argument for boring technology, for pushing invariants somewhere they'll be enforced without me remembering to check them, and for writing the decisions down — the project keeps a decisions log and a domain-rules document precisely because there is no second engineer to ask.

The architecture

Next.js App Router on Vercel, Postgres on Neon through Drizzle, Auth.js for staff sign-in, Resend for the one email the system sends. Nothing exotic. The interesting part is where the checks live.

Everything renders on the server. Dashboard pages are server components and every mutation is a server action. There is no client-side data layer holding a set of records to filter down, because a filtered-in-the-client list is a list the client had in the first place.

The selected branch is a request, not a fact. It's persisted in an httpOnly cookie, but the cookie is never trusted on its own. On every request, resolveBranchContext loads the branches that user is actually assigned to and accepts the cookie value only if it still appears in that set — otherwise it falls back to their first assignment, or to a clear empty state if they have none. Someone editing the cookie to another branch's id gets their own branch back.

Authorisation is a backstop, deliberately duplicated. A route proxy bounces unauthenticated requests to sign-in, and then every branch-scoped read and write goes through a permission helper anyway (requireUser, requireAdminForBranch, requireStudentAccess). The two failure modes are kept distinct on purpose: not signed in is a redirect, signed in but reaching for someone else's branch is a thrown AccessError. Collapsing those into one "something went wrong" would hide a real authorisation bug behind what looks like a login problem.

The database enforces the domain rules. The things that must always be true are partial unique indexes, not if statements:

  • one active class assignment per teacher per branch
  • one active teacher per class
  • one primary guardian per student
  • one attendance session per class per date
  • one attendance record per student per session

A double-tapped submit button, or two admins editing at the same moment, fails at the write. It doesn't quietly produce a second register for the same Saturday.

"This week" is a domain concept, so it's defined once. Because each branch operates on a fixed day, "the current session" means the most recent occurrence of that branch's operating day — evaluated in Europe/London, and returned as a calendar date rather than a timestamp, since the session_date column is a date and comparing a timestamp against it is how you get an off-by-one register in late October. That definition drives the dashboard's "classes missing attendance this week" metric and the duplicate-session check, and it lives in exactly one function.

Parent email is a deliberate act. The only outbound communication in the system is triggered when a staff member records a merit or demerit and explicitly chooses to notify the guardian. The record stores both that an email was requested and whether it was sent, so "was the parent actually told" has an answer. Nothing sends on a schedule or a rule.

What I chose not to build

Scope decisions, as opposed to the architectural ones in the table above.

No cross-branch dashboard. Every screen answers "what is happening in this branch". Org-wide reporting is a genuinely different feature with its own access rules, and building it early would have put a permissions question behind every query.

No parent-facing portal. It is the obvious next thing and the most requested. It's also a second user class, a second auth story, and a public attack surface on a database of children's records — so it waits until the staff-facing system is boring and stable.

No mobile app. A responsive web page that a teacher can open on a phone on a Saturday morning does the whole job. An app adds two build targets and a release process for zero capability.

No multi-tenant abstraction. The system is branch-scoped, not tenant-scoped, and there is one organisation. Generalising ahead of a second customer would have bought flexibility nobody has asked for at the cost of complexity in every query.

What it replaced, and where it is

It replaced paper registers and per-branch spreadsheets with one system that five branches share.

Being accurate about status, because the alternative is worse: the core operational loop is built and running — staff authentication, branch context and switching, class management with teacher assignment, students, guardians, student profiles, and attendance capture with history off the student profile. Behaviour records with the opt-in parent email, and staff user management, are the two remaining MVP verticals.

The most useful thing I've taken from it isn't a technique. It's that on a system with one engineer and no QA, the decisions you write down and the constraints you put in the database are the only two things that keep working when you forget the details.