GAZA40+ — engineering case study by Kaleem Ahmed

A case management platform for a humanitarian organisation helping students in Gaza take up university offers abroad. The hard part was never the screens. It was modelling who is allowed to see what, and where each case is supposed to go.

Last updated 2026-08-20, first published 2026-08-19.

Built with Next.js 16, React 19, Express, Prisma, MongoDB, Cloudflare R2, Socket.IO, Docker, Nginx.

Overview

GAZA40+ is a case management platform for a humanitarian organization that helps students in Gaza take up university offers abroad.

The organization works in the gap between "you have been admitted" and "you are able to go." A university can offer a scholarship or a tuition waiver. It generally cannot help with a visa, a passport that may have been lost, an accommodation deposit, living costs a visa authority wants documented proof of, travel, or the coordination that holds all of it together. That coordination is the organization's actual job, and before the platform it lived in Notion databases, spreadsheets, Slack and WhatsApp.

We built the system that work runs on now. A student registers, verifies their email, completes a profile whose requirements change depending on their situation, uploads documents and submits university offers. Each offer routes to the regional administrator responsible for that offer's country. The server computes the funding gap from rules the organization owns, documents sit in private object storage that no public URL points at, queries route to a named person who can escalate when they are stuck, and roughly thirty sensitive actions are written to an append only audit log.

It was built with my friend Hamza, who led the overall implementation. I worked mostly on the backend, the module structure, the services, the workflows and specifically the authorization model, plus the frontend scaffold, the UX and the UI polish.

The five objects everything else hangs off

The hard part was never the screens. It was modelling who is allowed to see what, and where each case is supposed to go. These records hold passport numbers, national IDs, emergency contacts, a location inside Gaza and a family's financial circumstances, so a wrong access grant is a real exposure for a real person, not an embarrassment. The one decision the whole system rests on is that a case's region comes from the offer's university country and not from where the student is. Every student is in Gaza. Scoping administrators by the student would have given every administrator every student.

Live at portal.gaza40plus.co.uk, in use by the organization and by real students. This page gives no usage numbers, because nothing in the system measures them and I would rather leave a gap than invent one.

Problem

The organization was coordinating a whole network around every single student, and every piece of that network lived in a different tool.

Around one student there is a university, an offer letter, maybe a partial scholarship, a funding gap, a visa application, a passport that may be expired or lost or never issued, an emergency contact, a mentor who knows that specific university's admissions process, and a regional coordinator who knows that country's immigration system. The organization's own problem statement lists eight responsibilities it has taken on: registration and consent based data collection, offer and scholarship tracking, regional coordination and visa support, volunteer and university level support, funding gap identification, case workflow and escalation, student queries, and centralised announcements.

It also names two populations that need different things. Some students already hold an offer and need funding and relocation help urgently. Some are still looking for a place and need mentorship and university matching. One platform, two operational tracks, and the data model has to tell them apart rather than flattening them into one queue.

Notion databases    ->  student records, one row each, status is a label anyone can set
Spreadsheets        ->  funding maths, one copy of the formula per file
WhatsApp            ->  the real conversations, no state, no owner
Slack               ->  internal coordination
Drive folders       ->  documents, readable by anyone with the folder
Human memory        ->  whether a query was resolved, and who was handling it

None of those were bad choices. Notion lets a non technical team build a database in an afternoon. A spreadsheet is the best ad hoc calculation tool ever made, and the funding maths correctly started in one. WhatsApp is where the students already are, and it works on an old phone and a bad connection, which in Gaza is not a small advantage. The tools were right until the workflow got more complicated than the tools could model.

At ten students you answer those by asking someone. Past that you cannot, and the failure mode is not a missed report. A query that falls through the cracks costs a student an academic year.

The interesting part is that this is not a tooling preference. Several of these rules are not expressible in a page permission model at all.

If I compress that to one line: you cannot express who is allowed to see this, because the rule depends on a relationship between three entities that live in three different systems. A coordinator's access to a student is derived from that student's offers, the country of the university on those offers, and whether that country matches the coordinator's assignment. It is a join, and a spreadsheet cannot join at all.

Every case record holds a passport number, a national ID number, an emergency contact, a location inside Gaza, and a description of what a family can and cannot pay. That changes what a bug means. In most systems an over permissive query is a defect you fix in the next release. Here it hands identity documents for people in a conflict zone to someone who had no reason to hold them. It is why the access rules are enforced in the service layer against the database rather than by hiding a button, and why the audit log records that review notes were provided rather than storing the notes themselves.

The first is connectivity. The problem statement says the system must stay accessible, lightweight and usable on mobile as well as desktop, and the requirements put a number on it: cap uploads at around 5MB per file because of poor internet in Gaza. That is not a generic performance nicety, it is a stated property of the user population, and it shows up in the code as a 5MB document cap, a 1MB cap on signatures, a mobile first UI, and an Nginx body limit sized to match.

The second is language. English and Arabic are a requirement, with a right to left layout switch, not a later nicety. That reaches the data layer too, since the configuration options the organization edits carry an Arabic label alongside the English one.

The honest framing of the brief: the organization did not need a dashboard. It needed responsibility, state and access made explicit, in that order.

What We Built

One system where every case has a region, every region has an owner, every state change is a transition rather than an edit, and every document access is a decision made on the server.

            Browser (mobile first, EN / AR with RTL)
                        |  HTTPS
              Nginx, portal.gaza40plus.co.uk
            +-----------+-----------+
       /    |                       |   /api/   /socket.io/
  Next.js 16|                 Express + Prisma
  React 19  |           +---------+---------+--------+
            |        MongoDB   Cloudflare R2      SMTP
                          |
               Socket.IO . node-cron . Sentry

        Docker Compose, two containers, one Contabo VPS

A modular monolith, on purpose. Nineteen feature modules, each owning its routes, controller, service and validation schema. The backend's own engineering guide says do not build microservices, do not build a workflow engine, do not build event sourcing for a system this size, and that was right. Updating an offer and writing its revision record has to be atomic, which is one transaction here and a distributed problem anywhere else. Two people, one organization, one deploy target.

A student is in Gaza. Their offer is not. Every student shares the same location, so scoping administrators by the student's location would give every administrator every student, which is the same as having no regional model at all. So the region belongs to the case. It is derived from the university's country when the offer is created, and the internal flow documentation states the invariant outright: regional admin access is never based on the student's location in Gaza.

The consequence is the thing I would point at if you asked what is interesting here. A student with offers in the UK, Spain and Turkey is a case in three regions at once. Three different regional administrators can each see that student, and each of them sees only their own country's offer on them. A regional admin's student list is not students in a place, it is students who have at least one offer in my region, expressed as a relational filter rather than a column comparison.

That rule has to hold in roughly nineteen separate places: every list, every detail view, document access, exports, chat permissions and announcements. It holds because there is one scope resolver. It reads the user fresh from the database, checks four things (the user is not soft deleted, the account is active, the role is present, and the regional admin profile is itself active and not soft deleted) and returns either a master admin scope with no region or a regional admin scope carrying a region ID. Every query then applies the same filter shape, so master admin is not a privileged branch, it is an empty filter object. One code path, nothing parallel to drift out of sync.

The database has student, mentor, regional_admin, master_admin and reviewer, and the roles field is an array, so one person can hold several. The backend never collapses it and checks membership per capability. The frontend does collapse it for routing by a fixed priority, and its role switcher is cosmetic: it changes what gets rendered and nothing about what the server will return.

RoleHow they get inWhat they can reach
StudentSelf signup, then email verificationOnly their own profile, documents, offers and queries. Ownership is a condition inside the query itself, so changing an ID in a URL returns not found rather than someone else's case.
MentorSelf signup as a volunteer, then admin approvalOffers assigned to them, and a student only through such an offer. Passport and national ID sit outside their document allowlist, and the identity number fields are nulled on the way out of the response.
Regional AdminCreated by a master admin, no self signupOne region: offers in it, students with an offer in it, non escalated queries in it, volunteers who chose it. Never a passport.
Master AdminSeeded, or created by another master adminEverything, including audit logs, configuration, regional admin management and escalated queries, with every sensitive action logged.
ReviewerCreated by a master adminStudent profile review. Deliberately narrow on offers and queries, but broad on documents, because you cannot verify an identity document you are not allowed to open.

There is no volunteer role. Someone signs up as a volunteer with a university affiliation and a preferred region, and they receive the mentor role immediately along with a VolunteerProfile whose status is pending. A middleware called requireActiveMentor then blocks every mentor route until an administrator moves that profile to approved. The token says mentor from day one, and the database decides whether that means anything.

The alternative was a second role granted on approval, which means a write to a permissions array on every approval and a second permission tier to keep in sync forever. This way the permission set never mutates, only the gate opens, and there is exactly one place in the codebase where the question is this mentor real gets answered.

student submits    ->  offer letter required, plus a scholarship letter if a scholarship is claimed
                   ->  status becomes under review, record locked for review
region derived     ->  from the university's country, at creation
routed             ->  to the regional admin queue for that country, or a master admin
mentor assigned    ->  optional, usually someone affiliated with that university
decision           ->  approve | request changes | reject
approved           ->  the student's profile is flagged as holding a verified offer

That flag is maintained rather than set once. Un approving an offer recounts the student's other approved offers before deciding whether the flag should stay true, which is a small piece of correctness a lot of systems get wrong and then quietly carry forever.

Editing an approved offer is the case worth describing in full. The system snapshots the record before and after, computes exactly which fields moved, writes an OfferRevision holding both sides and the changed field list, resets the status to under review, and notifies the administrators by email and in app, all in one transaction. The reviewer then sees the changed fields highlighted instead of re reading the whole offer. An edit that changes nothing writes nothing, so opening the form does not accidentally un approve your own offer.

Nothing is ever a public URL. Uploads go to Cloudflare R2 under randomly generated keys, and the database stores a storage key, not a link. Downloads run through an authenticated endpoint that resolves ownership, then assignment, then region, then a per role allowlist of document types, streams the object back through the server, and writes an audit entry naming who, what type, when and from where. Mentors and regional administrators are refused passports and national IDs by set membership in the service layer, not by a condition in a template. Re uploading supersedes the previous version rather than overwriting it.

The application also refuses to boot in production if object storage is not configured. That check exists because the worst realistic failure here is not a leak, it is a missing environment variable quietly writing student passports to a container filesystem that the next deploy destroys. Better to stop the deploy than to start wrong.

The funding gap is a pure function on the backend, driven by a JSON rule set stored in a single configuration row: currency, per country living cost rules, and how a course duration converts into complete years. The UK rules encode the visa maintenance figures for London and outside London. Scholarships and private funding can each be annual or one time, and a one time amount is amortised across the course duration. Partial years round down. There is handling for residential boarding fees, for manual figures where a country has no rule, and for a scholarship that explicitly covers living costs.

Two properties matter more than the arithmetic. It is computed on read, so changing a government figure corrects every screen at once with nothing to backfill. And it returns where each number came from alongside the number, so an administrator questioning a total gets the configured UK London rule as an answer rather than an unexplained figure. The browser never decides whether a student is funded.

Each query category is a configuration row carrying routing metadata naming the tier it belongs to: mentor, regional admin or master admin. Creating a query derives the region from the student's own offer, resolves the target, assigns them and notifies them. The organization can add a category and decide where it lands without a deploy, which is exactly what happened when the specification arrived unfinished, one section literally ending mid sentence. Three more categories turned up later and the change was three database rows.

Escalation runs in two directions. A mentor who cannot resolve something escalates to their regional administrator, and the escalation writes the reason into the message thread, releases the assignment so the ticket is genuinely back in a queue rather than held by someone who has given up, and notifies the next tier. A regional administrator escalates to a master administrator the same way. If a region has no active administrator, the code counts how many people it actually notified and falls back a tier when that count is zero, so a query is never silently orphaned.

The pattern across all of it: configure what the organization owns, such as visa figures and query categories, and hardcode what the security model owns, such as which roles may open a passport. A database write must never be able to grant access to an identity document.

Features

Everything below is live at portal.gaza40plus.co.uk, not planned.

Onboarding with three stacked gates

Email verification blocks the whole API for self registered accounts. Community guideline approval is a consent record and blocks offers, queries and chat. Profile approval blocks offer management. Each gate exists for a reason and they fire in that order, so review time is only spent on verified cases.

Conditional profile requirements

A student whose passport status is invalid, lost or never had one is not asked to upload a passport. The absence is recorded as a fact and there is a query category for exactly that problem. In an ordinary admissions system a missing passport is an incomplete form. Here it is a case type.

Offer submission with real preconditions

Submitting requires an offer letter document, and a scholarship letter as well if a scholarship is being claimed. Submission flips the status to under review and sets a lock, so an administrator is never reviewing a moving target.

Queries with a named owner

The student picks a category, the system derives the region from their own offer and assigns a specific person who gets notified. They can see the thread and whether it is resolved, which is the one thing a WhatsApp message cannot tell them.

Offer review

Student review

FeatureThe actual mechanic
Private documentsCloudflare R2 with random keys, no public URL anywhere in the system, streamed through an authenticated endpoint after four checks, audited on every download, superseded rather than overwritten on re upload
Upload limits5MB per document and 1MB for a signature, matched by the Nginx body limit, because Nginx rejecting a phone camera upload before Node ever saw it was a real production incident
Funding engineOne server side function reading a rules row from the database, amortising one time grants across the course, and returning the source of every figure next to the figure
CSV exportA background job, not a response. A job record is created, a runner streams the data in cursor paginated batches of a thousand to a temp file, uploads it to R2, and the UI polls for a signed link
Export safetyThe authorization scope is frozen into the job at creation so a permission change mid run cannot widen it, jobs can be cancelled between batches, jobs interrupted by a restart are marked failed at boot, and a daily cron expires old exports and deletes the objects
Audit logAppend only, roughly thirty actions, recording actor, IP, user agent and the shape of what happened. It records that review notes were provided, never the notes, and which fields changed, never the values

Query messages and chat are separate on purpose, and the reason is retention. A query thread is part of the case record: permanent, audited, and locked once the query is resolved. Chat is coordination: direct and group messaging with attachments and a seven day retention window, because holding less conversation about vulnerable people is better than holding more. Different retention is the whole reason they are two systems rather than one with a flag on it.

How chat permissions actually work

The realtime transport is HTTP polling rather than WebSockets. That is worse on paper. It survives restrictive networks and keeps the secure session cookie intact through the proxy, which matters more than the benchmark for the people actually using it.

One thing I will not dress up: there are no automated tests, and lint is a TypeScript typecheck. For a system whose main security property is data isolation enforced at nineteen separate call sites, that is the largest structural risk in the project, and closing it comes before any individual fix.

My Role

GAZA40+ was built with my friend Hamza, who led the overall implementation. I worked primarily on the backend.

I want that to be the first sentence on this tab, because the honest version is also the useful one. This is not a solo project, and describing it as one would misrepresent both what happened and what I took from it. The follow up question, so what did you actually do, has a specific answer.

AreaMy involvement
Backend engineeringPrimary. Module structure, services, workflows, authorization.
Backend architecturePrimary. The modular monolith, the layering rule, the design of the scope resolver.
Authorization and RBAC reasoningPrimary. The layered model, and the decision that a case's region comes from the university's country.
Workflow designShared. Offer review states, revision tracking, query routing, escalation.
Requirements interpretationShared. Turning an ambiguous specification into decisions we could actually build.
Product thinkingShared. Why each feature exists operationally rather than as a feature.
Frontend scaffolding and structureContributing. Route structure, shared components, the adapter layer.
UX and UI polishContributing.
Feature pages, chat UI, most componentsHamza's.
DeploymentShared.

Not the chat UI, the document viewer, most feature pages, or the component library. Not sole authorship of anything, since the change history shows both of us across the same files. Not the original database choice, because I honestly do not know from the repository why it was made. And not the design of features I would now change, as though I had planned to change them.

In a solo project you know why every line exists. Here I had to read code I did not write and work out whether it was correct, which is the part that transfers to a job. Contracts either become explicit or they break: two repositories, two people, no shared types, and the notification deep links that returned 404 are what a convention looks like when it fails. Documentation is a coordination tool and it rots, and ours did, with planning documents still describing local file storage for a system that has used object storage for a long time. Someone else's shortcut becomes your problem, and the most useful thing about the one I inherited is the honest comment sitting on top of it, which made it diagnosable rather than mysterious.

Then there is the part no tutorial produces. Students without passports. Students with offers in three countries. Regions with no active administrator. Phone camera uploads over the size cap. Nginx rejecting a body before Node ever saw it. Real users generate edge cases you would not think to invent.

The things I would rather tell you than have you find: administrator credentials stored in plaintext next to the hash, an assigned mentor able to approve an offer through the admin route which is more authority than our own RBAC documentation describes, and a review lock that is bypassable while no mentor is assigned. All three are written down with fixes and verification steps. I did not want a portfolio page that only lists the parts that went well.

Try It

The platform is live, and you can sign into it as four different roles to see how much the same system changes shape depending on who you are.

GAZA40+ runs at portal.gaza40plus.co.uk. Test logins for Super Admin, Regional Admin, Volunteer and Student are on the credentials page of this site, so nothing sensitive lives in this write up.

What is worth doing in each seat

The comparison is the point. The same records, four genuinely different systems, and the difference is enforced on the server rather than by hiding things in the interface. Requesting a region that is not yours returns a permission error, and leaving the region parameter off does not widen anything, the correct region is substituted.

This is a live production system used by a real organization and real students, so the test accounts are seeded and scoped. Please do not upload anything you would not want stored.

Architecture

One Express application, one database, one deploy. That was written down as a rule before we needed it, not a default we drifted into.

I built GAZA40+ with a friend, Hamza, who led implementation and owned most of the surface area. My own main area was the authorization model, so most of what follows is written from inside that. The product is a case system. Students in Gaza record the university offers they hold, volunteers and regional admins review those offers, and the organisation tracks who is actually close to leaving.

Everything routes through one entity. An Offer belongs to a student, points at a university, carries the money (tuition per year, scholarship name, amount and interval, private funding, living cost key, boarding fees), and carries a workflow status. It also carries a regionId, and that single field is what the entire access model hangs on.

  Region  (Prisma calls it code / name, Mongo stores countryCode / countryName)
    |
    |-- University          isLondon flag drives the London living-cost rule
    |-- RegionalAdminProfile
    |-- Offer               regionId is the routing key for the whole system
    +-- Announcement        nullable region, so null means global

  User  (roles[] is an array, one person can hold several)
    |-- StudentProfile      identity, location in Gaza, passport, emergency contact
    |-- VolunteerProfile    approval state for people holding the mentor role
    |-- Offer               studentUserId, plus a single nullable mentorId
    +-- Query               model name Query, collection name still Alert
          |
          +-- QueryMessage  collection name still AlertMessage

  Offer
    |-- OfferRevision       append-only before / after / changedFields
    +-- Document            storageBucket + storageKey, never a URL

The Offer's regionId is resolved once, at write time, in resolveOfferRegionAndUniversity. It comes from the university's region if a universityId was supplied, otherwise from an explicit regionId, otherwise from a case-insensitive name match on the university's country. It is never taken from the student. That sentence is the whole system, and it gets its own tab.

Entities that carry real weight

One thing in that diagram is worth explaining, because it is the kind of decision you only make after shipping into a database that already has data. The product was specified as an "Alert System". The organisation's actual language turned out to be "query", so the application vocabulary was renamed and the collections were not. Prisma's @@map does the translation, Query means Alert, QueryMessage means AlertMessage, QueryStatus.assigned is stored as in_progress. Renaming live collections is a migration with downtime and risk. The mapping is free. The real cost is that anyone opening Compass or writing a raw Mongo query has to know, which is why it is a hard read-before-touching-the-database table in the repo docs rather than a comment somewhere.

  src/modules/<feature>/
    <feature>.routes.ts       express.Router plus middleware chain
    <feature>.controller.ts   parse -> call service -> shape response
    <feature>.service.ts      business rules, Prisma, AUTHORIZATION DECISIONS
    <feature>.validation.ts   zod schemas for the request body

Nineteen modules, each a full vertical slice with the same four files. app.ts mounts them and is 107 lines, so the API map fits on one screen. Cross-module code lives in shared/ and only gets promoted there when it genuinely repeats, which is seven small files.

The rule that matters more than the folder layout: authorization decisions live in the service, not the route. Middleware answers whether a person of this class could ever do this. The service answers whether this person may do it to this record right now. That is why listAdminOffers opens with a database round trip to resolve the caller's scope instead of reading the roles claim off the verified JWT it already has in hand. The token is a hint. The database is the answer.

The backend's own engineering doc says it outright, under a heading called Avoid: no premature abstractions, no real-time systems unless approved, and no generic systems, workflow engines, or enterprise-style abstractions. That last line is worth sitting with, because GAZA40+ is a workflow product. The pull toward a state machine engine and a rules DSL is strong. The doc pre-committed against it and the code held. Offer states are an enum plus guard clauses in offer.service.ts, and there is no WorkflowEngine class anywhere in the repository.

OptionWhat it would have boughtWhy not here
Modular monolith (chosen)One container, one stack trace, one authorization resolver, prisma.$transactionNothing to argue against at this size
MicroservicesIndependent scaling and deploy per serviceTwo people. Sagas instead of transactions. The scope resolver would be duplicated or centralised, and duplicating it means a leak the day service A grants what service B denies
ServerlessNo servers to patchCold starts on a low-traffic operational app, and the same transaction problem
Event-driven with CQRSReplayable history, read models per surfaceA broker, consumers, a dead letter queue and replay tooling, for a system with one organisation's worth of load

The strongest single argument is the transaction. Editing an approved offer has to do two things atomically: move the offer to under_review, and write the revision recording what changed. If the status lands and the revision does not, a reviewer opens an offer awaiting review with no record of what was altered, and approves it blind. That is not a data glitch, it is a wrong decision made by a real person. In a monolith it costs one prisma.$transaction call. Split offers and revisions apart and it becomes a two-phase commit, for a system that will never see the load that justified the split. The same argument covers query escalation, profile review, and document supersede.

Seams we drew but did not cut

  Browser (mobile first, English and Arabic, RTL)
      |  HTTPS
      v
  Nginx on one Contabo VPS, Certbot TLS, client_max_body_size 10M
      |                  |                    |
   location /         /api/              /socket.io/
      |                  |                    |
      v                  v                    v
  Next.js 16       Express 4 (:8000) <--------+
  React 19         helmet, cors, cookie-parser,
  (:3000)          CSRF header check, rate limiters
  1 CPU / 1 GB     1 CPU / 1 GB
                         |
        +----------------+----------------+
        |                |                |
        v                v                v
     MongoDB      Cloudflare R2       Brevo SMTP
     via Prisma 6   via S3 SDK        best effort, never blocks a workflow

  node-cron in process: chat retention 00:00, csv cleanup 02:00
  Sentry on both repos, 5 percent trace sampling in production

Two containers on one bridge network, each capped at 1 CPU and 1 GB, with NODE_OPTIONS set to a 768 MB heap ceiling so V8 does not get OOM-killed by the container limit in the middle of a garbage collection. The heap ceiling sits below the memory limit on purpose. Socket.IO runs polling-only rather than websockets, which was a deployment decision before it was a design one: polling keeps the httpOnly cookie intact through the proxy and survives networks that drop upgrades. It costs latency and request volume, and now that Nginx routes /socket.io/ straight to the backend it is worth revisiting.

The best line in the deployment story is a guard clause. In production, the storage module refuses to boot if Cloudflare R2 is not configured, with an error saying uploads would otherwise be written to local disk and lost on redeploy. A typo in an R2 key stops a deploy instead of quietly starting to lose student passports.

Five things that broke on the way to production, all integration failures rather than code failures

The point of choosing a monolith here was never that microservices are bad. It was that two people running a workflow app for one organisation need atomic writes and exactly one authorization resolver, and a monolith gives both for free. The honest version of the answer names what I would extract first if that stopped being true, which is the CSV worker, and what I would never extract, which is the scope resolver.

Roles & Access

"We have roles" is the wrong description of what is in here. A role is the second of eight checks, and it is the weakest one.

The thing I kept coming back to while building this is that a role answers the wrong question. It answers whether someone like you could ever do this. It does not answer whether you may do it to this record right now. Those are different questions, and in a system holding passport numbers and the locations of people in a conflict zone, only the second one matters.

  1. Authenticated   is there a valid JWT?              requireAuth
  2. Role            does this role class permit it?      requireRole / requireActiveMentor
  3. Ownership       is this the caller's own record?     where: { studentUserId: userId }
  4. Assignment      is it delegated to them?             where: { mentorId: userId }
  5. Region          is it in their region?               getAdminScope -> regionId
  6. Resource        which entity type is this?           document type allowlists
  7. Action          read / write / approve / export?     separate handlers
  8. Business state  does the workflow permit it now?     lockedForReview, editable statuses

  Layers 1 and 2 are middleware.  Layers 3 to 8 are service code.

That split is the whole design. Take /api/admin/offers. Gating it with requireRole for master admin and regional admin would let any regional admin read every offer in the system. The role is right and the record is wrong. So the service resolves the caller's scope from the database, throws 403 if they explicitly asked for another region, and then injects the region into the where clause, so that even a caller who asks for nothing gets only their own slice. Two independent protections, and the second one is the load-bearing half, because it is the one that still works when somebody forgets to write the first.

None of this lives in the UI. The frontend hides routes and disables buttons, which is worth doing for the person using it, but it is not a boundary. The backend is the only place ownership, assignment and region are ever checked. The frontend's Next middleware decodes the JWT payload with atob to read the expiry and avoid rendering a page that is going to fail, which is a rendering shortcut and explicitly not verification. It cannot be verification, because the client cannot hold the signing secret.

roles is a RoleCode[] on the User, not a single enum column. One person can hold several. That mattered in practice: the same human is sometimes a mentor and sometimes a reviewer, and modelling that as two accounts would have meant two passwords and two audit trails for one person.

RoleWhat it actually gets
studentTheir own profile, their own offers, their own queries and documents. Every access is a query predicate on studentUserId, never a check performed after the fetch.
mentorOffers where mentorId is them, and queries assigned to them. No students table exists for a mentor. A student becomes visible through an offer, and stops being visible the moment the assignment is cleared.
regional_adminOffers, students, queries, volunteers, announcements, exports and documents, all filtered to one regionId resolved from an active RegionalAdminProfile in the database.
master_adminEverything, plus the surfaces nobody else touches: audit logs, config, regional admin accounts, escalated queries.
reviewerStudent profile review. Added narrow and kept narrow, with one exception noted at the bottom of this tab.

The resolver those roles feed into is a discriminated union, and the type is doing real work rather than documenting intent:

  type AdminScope =
    | { role: "master_admin";   regionId?: never }
    | { role: "regional_admin"; regionId: string }
    | { role: "reviewer";       regionId?: never }

TypeScript will not let anyone read scope.regionId without first narrowing to regional_admin, and reading it on a master admin scope is a compile error rather than a runtime undefined that silently becomes an unfiltered query. The lookup behind it re-validates four things from the database and none from the token: the user is not soft-deleted, accountStatus is active, the role is present, and for a regional admin the profile itself is active and not soft-deleted. Anything else throws 403.

Passwords are bcrypt at 12 rounds, compared with bcrypt.compare, and login never reveals which half of the pair was wrong. The JWT lives in an httpOnly cookie, so client JavaScript can never read it and an XSS cannot exfiltrate it. Access and refresh tokens use separate secrets, both validated by zod at boot as at least 32 characters, which means the app will not start with a weak signing secret. That converts a silent security failure into a loud startup failure, which is the trade I want every time.

Putting the token in a cookie makes CSRF your problem instead, and the answer here is a required x-requested-with header on every mutating request. It works because a cross-site form POST cannot set custom headers, and a fetch that does set one becomes a non-simple request, which forces a CORS preflight that the server answers against an origin allowlist. So the real defence is the header plus the allowlist, and both halves are load bearing. That matters more here than usual, because the cookie is currently sameSite: none, a leftover from a previous deployment where the two apps sat on different origins. Today Nginx serves both under one origin, so sameSite: lax would work and would add a genuine second layer.

Password reset and email verification share one primitive. A 256-bit random token is generated, only its SHA-256 hash is stored, creating a new one invalidates every live token of that type in the same transaction, and consuming one marks it used in the same transaction as the password change. A database dump yields no working reset links. Forgot-password returns the same message whether the email exists or not. The known gap is that resetting a password does not invalidate existing sessions, so someone resetting because they think they are compromised still leaves the attacker's cookie working until it expires.

The JWT payload carries roles and regionId, which are a snapshot from login time. If a master admin deactivates a regional admin, that person's existing token still claims the role and the region until it expires. The design compensates rather than pretending otherwise: every sensitive path re-reads the database through getAdminScope, and the refresh endpoint re-reads the user and mints a fresh token from current data, so roles and region re-sync at most one access-token lifetime after they change.

One flaw here is serious enough that it should be the first thing anyone reads about this system rather than something buried in a footnote. Regional admin and reviewer accounts are created by the master admin, and the password is stored in cleartext in a plainPassword column alongside the bcrypt hash, returned by the API, and displayed to the master admin in the UI. It exists because there is a real operational need, resending an invite with the original credential, and it is the wrong solution to that need. It defeats the hash entirely, for exactly the highest-privilege accounts in the system. The right fix is a single-use invite token, and the codebase already has the machinery: AuthToken already does hashed, expiring, single-use tokens for password reset.

Ownership is not a check, it is a query predicate, and that is stronger. A student's offer is fetched with findFirst on id plus studentUserId plus deletedAt: null. You cannot forget to check what you never fetched. Changing the ID in the URL returns null, which becomes a 404 rather than a 403, because a 403 would confirm the record exists.

Assignment is the same idea for mentors. A mentor's world is offers where mentorId is them. Asking about a student runs a query for that student's offers filtered by mentorId, and an empty result is a 403. Even the student's queries are filtered by that derived set, so a mentor sees queries assigned to them or attached to an offer they hold, and not the student's other queries. Revocation is free: clear mentorId and access is gone on the next request. There is no cache to invalidate and no permission row to clean up.

Business state is the eighth layer and it is the one people forget exists. A student owns their offer, and that does not mean they may edit it whenever they like. The status check throws 409 Conflict rather than 403, which is the semantically right code: you are permitted, the resource is not in a state that allows it.

This is the decision in the access model I am most confident about. A volunteer signing up gets the mentor role immediately, at signup, in the roles array. There is no pending_mentor role and no separate volunteer tier. What gates them is a VolunteerProfile whose status starts unapproved, and a requireActiveMentor middleware that blocks every single mentor route until an admin approves that profile. The middleware checks two things: the mentor role is in the token, and the volunteer status in the database says approved.

The reason to model it this way is that approval is not a different set of permissions, it is the same permissions turned on. If approval had been a role, then approving someone would mean mutating their roles array, unapproving them would mean mutating it back, and every place that reads roles would have to know that pending_mentor means mentor-but-not-yet. Two representations of one fact drift apart. Here the role says what they are and the profile says whether they are live, and there is exactly one place that answers the second question.

The route /api/admin/offers mounts only requireAuth and pushes every decision into the service, which is fine for listing, exporting, deleting and assigning, because the scope resolver rejects non-admins. But the PATCH handler calls canReviewOffer with the offerId, and that function returns true for the offer's assigned mentor. The consequence is real: an assigned mentor can approve their own assigned offer and edit its tuition, scholarship and funding fields through the admin endpoint. That is stronger authority than the RBAC documentation describes anywhere, and the gap between what the docs say and what the code does is itself the bug.

The editability check on offers reads as "no mentor assigned, or the status is in the editable set". The first clause short-circuits the second. So an offer sitting in under_review with no mentor attached yet is still editable by the student, and mentor assignment is manual and optional. In practice that means an admin can be part way through reviewing a submitted offer while the student changes the numbers underneath them. The review lock only engages once someone is attached.

Two more worth naming. /api/admin/student-profiles does no region scoping at all in the service, and is safe today only because the route is gated to master admin and reviewer, neither of which is regionally scoped. But a regional admin page on the frontend already points at that endpoint and 403s, so it is a dead feature and a latent leak the moment somebody "fixes" it by adding regional_admin to the role list without adding scoping. And the reviewer role, which is otherwise narrow, has global document access including passports, which is undocumented.

One piece of cleanup worth recording, because it is the same class of problem. An audit found that requireRole, requireActiveDbRole and requireAnyActiveDbRole were byte-identical, despite the Db in two of those names implying a database check that none of them performed. All three were collapsed into requireRole and verified against live requests across four routes and three account types before it landed. A name that lies about what the code does is a security bug in slow motion, because eventually somebody picks the safe-sounding one and gets the weak check.

There is a version of this section that lists the eight layers and stops. The layers are real and they mostly hold, but every property above is currently guaranteed by code review and nothing else, because there are no automated tests. For an authorization model with this many enforcement points, that is the honest headline risk, and it is a bigger problem than any single item on the list.

Regional Isolation

A student is in Gaza. Their offer is not. Everything else in this tab follows from that one sentence.

The naive way to build "regional admin" is to give the admin a country, give the user a country, and match them. That is the model almost every system I have seen uses, and here it produces nothing at all, because every student's country is the same country, and it is not the country whose process any admin owns. A UK regional admin does not own students in Gaza. They own the UK visa process, the UK university relationships, the UK financial rules. What they need to see is UK cases.

So a case's region is derived from the offer, and the offer's region is derived from the university's country. The flows document states it as a hard invariant, in those words: Regional Admin access is never based on locationInGaza. That field stays a profile filter and a display value, and it never touches an authorization decision. It took me a while to be comfortable with how strange that reads at first, because the field that most obviously says "where is this person" is deliberately excluded from the check that most obviously sounds like "where is this person".

  Student (locationInGaza: khan_yunis)
     |
     |-- Offer A  ->  Region: UK      ->  visible to the UK regional admin
     |-- Offer B  ->  Region: Spain   ->  visible to the Spain regional admin
     +-- Offer C  ->  Region: Turkey  ->  visible to the Turkey regional admin

  Three admins.  One student.  Each sees only their own country's slice of them.

One student can be visible to three different regional admins at the same time, and each of them sees a different subset of the same person. The UK admin sees the student, but only through Offer A. They do not learn that Offer B or Offer C exist. That is the right model because it matches how the work is actually divided: review and mentorship expertise is university-specific and country-specific, not student-specific, which is also why mentorId is a single field on the offer rather than on the student. A student with three offers has three mentors, and that is correct rather than a limitation.

regionId is written onto the offer when the offer is created, by a resolver with three paths in priority order. If a universityId was given, the region comes from that university, and the university must be active and not soft-deleted. Otherwise an explicit regionId is used. Otherwise the university's country name is matched case-insensitively against the region list. All three paths validate that the resolved region is active and not soft-deleted, and none of them reads anything off the student.

Resolving at write time rather than joining at read time matters for two reasons. The region is the routing key for every subsequent query, notification, export and permission check, so writing it once makes every downstream check a single indexed field comparison instead of a join through University. And it means that if a university is later moved to a different region, existing offers keep the region they were reviewed under, which is the correct behaviour for a workflow that carries an audit trail.

Listing students for a regional admin uses a where clause built around studentOffers: { some: { regionId, deletedAt: null } }. That decides whether the student appears at all. Then the offers included on each returned row are filtered by regionId again. The second filter is the one that is easy to skip, and skipping it is the actual bug this shape exists to prevent: without it, a UK admin who legitimately has access to a student would receive that student's Spain offer attached to the row. One filter answers "may I see this person". The other answers "which parts of this person may I see". They are different questions and they need different clauses.

The student detail endpoint goes further, and is worth showing because the ordering looks wrong until you think about why. The outer lookup is deliberately not region-scoped, it finds the student by ID alone. The includes are region-scoped. Then, after the fetch, an explicit assertion runs: does this student have an offer in my region, or a query in my region? If neither, 403. Scoping only the outer query would also work and would return a 404, but then the actual business rule is nowhere in the code, it is an emergent property of a where clause. Here the rule is written once, in a form you can read out loud and test. The cost is that the record is loaded before it is rejected, which is a wasted query on the attack path and free on the happy path.

Note that a query in the region counts, not just an offer. A student who has raised a UK visa question but has no UK offer yet is still the UK admin's business. That is a product decision encoded directly in an authorization check, and it widens access slightly. It is almost certainly intended, and it is written down nowhere, which is its own small problem.

This is the part I would want to be honest about in any interview. Regional isolation is not a middleware you mount. It is a property that has to be re-asserted at every surface that returns data, and I counted nineteen of them: offer list, offer detail, offer review, offer assign and delete, offer CSV export, student grid, student detail, student CSV stream, query list, query detail and actions, volunteer grid, volunteer detail, volunteer assignment, announcement listing, announcement targeting, dashboard counts, the CSV query builder, document access, and chat. Nineteen enforcement points is nineteen chances to forget, which is exactly why the absence of tests bothers me more here than anywhere else in the codebase.

The frontend does none of it. That is not a slogan, it is six specific arguments. The API is the product surface, and one curl with a valid cookie skips React entirely. Hiding is not filtering, so if the server returns 200 records and the client renders 40, the other 160 already crossed the network, sat in browser memory, appeared in the devtools network tab and were cached by anything in between. Response size and timing are a side channel even with perfect hiding. The client is code the user owns, not merely code they can modify. Non-browser clients exist, and the Postman collection in this repository is one of them. And UI conditions rot silently: add a page that forgets the filter and nothing fails, it just leaks, whereas a missing server-side where clause fails loudly, because you get no data.

I set this up as a fixed scenario and traced each attempt through the code rather than assuming the answer. admin_uk is an active UK regional admin. student_x has one Spain offer and no UK offer. Twelve attempts.

AttemptWhat stops it
GET a Spain offer by ID403. canReviewOffer looks for a regional admin profile matching that offer's region with status active. No match, and admin_uk is not the assigned mentor either.
GET /api/admin/offers?regionId=<spain>403 from an explicit comparison against the resolved scope, before the query runs at all.
Omit the regionId parameter entirelyScoped anyway. The where builder appends the caller's own regionId last, regardless of what was asked for.
GET a Spanish student by ID403. The post-fetch assertion finds no offer and no query in the UK, so it rejects after loading. Timing could in principle distinguish a real ID from a fake one, which is worth knowing and is not exploitable for content.
Download a Spain offer letter403. Document access resolves the document's offerId and looks it up filtered by the caller's region. Null result, no access. Profile documents with no offerId fall back to the same student-in-my-region test, and passport and national ID fail the type allowlist regardless.
POST an export with empty filters200, and the CSV contains only UK students. The query builder replaces the region rather than merely rejecting a mismatched one, which closes the gap that catches people who only wrote the rejection branch.
POST an export naming another region403 from the explicit mismatch check.
GET a Spain query403. A query with no region at all is also 403, which is deliberate: regionless queries are master admin business.
GET a query escalated to master admin, even in the UK403 in detail, and the list forces isEscalated false, so it never appears there either.
Reassign a Spain volunteer to the UK403. An unassigned volunteer with a null region can be claimed by any regional admin, which is deliberate and has a code comment saying so.
Publish an announcement targeting Spain403 from the target validator.
Direct-message a Spain mentor403 from the chat permission check, and the user search overrides any client-supplied region with the caller's own, so the mentor is not findable in the first place.

Eleven of twelve hold. The twelfth does not. A UK regional admin can open a direct chat with a student whose only offer is in Spain, and read the messages they exchange. The student branch of the chat permission function returns true whenever student chat is enabled, without checking region, and the user search that finds the student is not region-filtered for that target. It needs the config flag on and it needs the admin to find the student, and neither of those is much of a barrier.

The failure is in the newest subsystem, which is where I would have guessed it would be. Chat was added after the isolation model was already established, and the student branch was written as an explicit simplification for an early version, with a comment saying so. The fix is small and already has a template in the same file: canAddMemberToGroup gets this right, requiring an offer in the admin's region, and the direct-chat path should mirror it.

Deriving region from the offer rather than the person makes the correct thing easy and the intuitive thing wrong, and the intuitive thing is what a new contributor will write. locationInGaza is right there on the profile and it reads like a region. Every new surface that returns student data has to be told, explicitly, which region rule applies, and there is no compiler error and no database constraint if it is not told. MongoDB has no row-level security, so nothing beneath the application layer will catch a missing clause.

Two known holes sit outside the model rather than inside it. Documents fetched through a signed CSV download link or a short link validate only the token and the expiry, so anyone holding the URL gets the file regardless of region. And escalated queries disappear from the escalating admin's own list, because isEscalated is forced false for regional admins, so after escalating something you cannot see what you escalated. That one is intentional handoff semantics with poor ergonomics rather than a leak.

If I rebuilt this, the model itself would not change. Region derived from the offer is correct, and the alternative does not work at all. What I would change is the enforcement: nineteen hand-written clauses want a Prisma client extension injecting scope, the way one should already be injecting deletedAt: null, and a test suite whose only job is to replay the twelve attempts above on every commit. The model is sound. The thing currently keeping it sound is attention, and attention does not survive a new contributor.

Onboarding & Review

A student whose passport is lost, expired beyond use, or never issued is not asked to upload a passport.

That is one line of code and it is the piece of this system I would show first, because it is where the domain actually got understood rather than transcribed. A meaningful share of this population does not have a passport. A form that demands one blocks exactly the students who most need the help, and it blocks them with a validation error rather than with a decision anybody made. The system records the absence as a fact about the case instead of treating it as an incomplete form.

The same shape repeats through the whole submission check. Required fields are the flat ones, name and date of birth and location and emergency contact. Then conditional requirements attach to answers: a location of other requires the free-text location, a passport status that implies a document requires the document, claiming an English-medium qualification requires the institution name and type. Conditional documents work the same way.

When submission fails it returns every missing field and every missing document in one response, rather than failing on the first problem. On a poor connection in Gaza, a field-at-a-time API is not a minor annoyance, it is ten round trips that may not complete. The error shape is a bandwidth decision.

register -> draft
  |  email verification blocks the API until the link is clicked
  v
draft  <-------------------+
  |  edit, upload, accept   |
  |  community guidelines   |
  v                         |
under_review                |
  |                         |
  +-- approved              |  offers unlock
  +-- changes_requested ----+  editable again, notes explain
  +-- rejected                 blocked, needs a human

Editability is a set containing draft and changes-requested, and it is applied in both the update and the submit path. Under review is locked, so a reviewer's view cannot shift underneath them while they are looking at it. Approved is locked. Rejected is locked too, which means a student cannot simply resubmit into the same queue: an admin has to move them back to changes-requested first.

That last one is a real product decision rather than an oversight. Rejection here is not try again, it is a human needs to look at this. Given that a rejection can turn on an eligibility question about a person in a crisis, routing it back through a person is right.

There is a sixth status in the enum that nothing ever writes, left over from an earlier design, with no outgoing transitions defined for it. Harmless, and worth deleting before somebody assumes it means something.

One helper fetches the profile and refuses if it is not approved, and it is called at the top of all six student offer functions. One gate, one place to change it.

The operational reason is that an offer entering the pipeline creates a regional admin queue item, an email, a notification, possibly a mentor assignment, and eventually a funding gap figure that feeds a real decision about where money goes. If the identity behind it has not been verified, every one of those is work spent on an unverified record. Approval is the moment a self-declared account becomes a case the organisation is willing to spend on.

The human reason sits next to it, and I think it matters as much. The requirements anticipated the waiting period and asked us to say so out loud: profile under review, it might take two or three days. There is a dedicated route for exactly that state. Telling someone in a crisis that they are in the queue and roughly how long is the difference between a wait and a void.

A middleware blocks student routes until the guidelines are approved, and it fails closed: no profile means nobody has been through onboarding, so nobody has approved anything, so it blocks rather than passes.

It has exactly one deliberate hole. The profile endpoint itself is not gated, because profiles are created lazily there and gating it would deadlock onboarding, since you could never reach the page where you accept the guidelines. What makes that acceptable is that the reasoning is written in the file next to the exception. An undocumented hole in an auth gate is how vulnerabilities happen. A documented one is a design.

Profile review is reviewed by master admins and a narrower reviewer role, and deliberately not by regional admins. That is correct rather than an omission: at review time the student may have no offer at all, so there is no region to scope by. Identity verification is inherently global in a system where everything else is regional.

The review function does four things in a deliberate order.

Idempotency, checked before validation

If the profile is already in the requested status, it returns successfully instead of erroring. The comment says this must run before transition validation to prevent false conflicts. Two reviewers clicking approve on the same profile, or one reviewer double-clicking, should not produce an error. That ordering is the difference between correct and merely defensive.

An explicit transition map

Approved has an empty list of onward transitions, so an admin cannot un-approve a profile. Rejected can be revived, which matters when a rejection was a mistake or new evidence arrives. The permitted moves are data rather than a chain of conditionals.

Compare and set

The update matches on both the id and the original status. If somebody else moved the profile between the read and the write, zero rows match and the second reviewer gets a clear conflict telling them to refresh, instead of silently overwriting the first decision.

Audit, email, event, all after the write

None of them can fail the operation. The audit write is outside the transaction, with a comment explaining that the audit collection does not support it, so the failure mode is a decision that lands without an audit line rather than a decision that fails because logging failed. That is the right way round.

The compare-and-set is the only optimistic concurrency control in the codebase, and I would rather explain the asymmetry than present it as a general pattern. Profile review is the one place where two admins realistically act on the same record at the same moment, because it is a shared pending queue. Everywhere else a record has a single owner or a single regional queue. Applying it uniformly would be ceremony, and leaving it out here would mean losing a decision.

One detail in the audit metadata I like: it records whether review notes were provided, as a boolean, not the notes themselves. The log exists for accountability, and copying a reviewer's free text into a second store would double the exposure surface for commentary about a real person, for no accountability gain.

Because approved is not editable, there is no path for a student to update their profile after approval. A passport that expires, a phone number that changes, a new emergency contact, all frozen. In a system where passport validity materially affects whether the case can proceed, that is a genuine product hole rather than a tidy constraint.

The shape of the fix is already in the codebase. Split the fields into identity-bearing and contact. Let contact fields change with an audit entry and no re-review. Let identity fields change but write a profile revision and move the profile into a re-verification state that keeps existing offers intact while flagging the change to a reviewer. That is the offer revision idea applied to the other reviewed entity, which makes it an extension rather than new machinery.

Queries & Escalation

A contact form takes a message and emails somebody. This decides who owns the problem before anybody reads it.

Every query carries a validated category, a region derived rather than supplied, an optional link to the offer it concerns, an assignee resolved at creation, a threaded history with sender roles, and an audit entry per action. Resolution, reopening and escalation in two directions are all distinct acts.

Each query category is a config row carrying its bilingual label, its sort order, and metadata naming who it should go to. The organisation can add a category, write its label in English and Arabic, decide whether it lands with a mentor, a regional admin or a global admin, and control where it sits in the dropdown, without a deploy.

CategoryGoes toBecause
Visa or offer issueRegional adminVisa rules are country-specific. Knowing the UK process does not transfer to Spain. Routing by country is routing by expertise.
WhatsApp group accessThe mentor on that offerThese groups are university-specific and run by the university-affiliated volunteer. That mentor is the only person who can actually add someone.
General issueMaster adminNo region means no regional owner. Default to the global operator rather than guessing at one.
Missing identity document, missing certificate, university not listedRegional adminAll three are blocked on a country-process prerequisite. The last is really a request to create a university record, which only a master admin can do, so the regional admin acts as a triage layer that escalates upward.

The routing code reads that metadata generically, and will route to a role it has never heard of by name. The cost of that flexibility is that a typo in the config becomes a silent no-match rather than an error, which is why validating the value against the real role list at save time is on the list.

If a query is attached to an offer, the lookup for that offer is scoped to the requesting student, so a student cannot attach somebody else's offer to their own query and inherit that region. Then the region is taken from the offer and overwrites whatever the client sent.

That is the regional isolation model defending itself at the ticket layer. The region determines who can see the query, so allowing the client to state it would make the whole isolation model advisory.

The query and its first message are created in one transaction, because a ticket with no body is an unactionable row. And if no assignee can be resolved at all, the fallback emails the master admins and any regional admins for the region. A query is never silently orphaned.

RoleSees
StudentTheir own queries
MentorQueries assigned to them
Regional adminQueries in their region that have not been escalated past them
Master adminEverything, and can filter by escalation

A regional admin has the not-escalated filter forced rather than defaulted, and the detail endpoint re-checks it, so an escalated query cannot be reached by guessing its address. A query with no region at all is invisible to every regional admin, which is correct, since a regionless query was routed to master admins by design.

A guard runs on every message, every assignment and both escalation paths: resolved queries are read-only. To continue a conversation somebody has to reopen the query, which is an explicit and audited act. That is what makes resolved mean something, and it is what makes a count of resolved queries worth reporting.

Reopening restores the right state rather than a default one. If the query still has a mentor it returns to that mentor, otherwise it goes back to the open pool.

The authority ladder here is real. A mentor is a volunteer with university-specific knowledge, not staff, with no authority over funding and no ability to change a country's process. A regional admin owns a country's process but cannot make an organisation-level decision. Without escalation the only options are solve it or leave it open, and in this domain leaving it open has a cost measured in academic years.

mentor  --escalate-->  regional admin  --escalate-->  master admin
   |                          |                         |
   +-- assignment released     +-- assignment released    +-- owns it
   +-- remark posted into      +-- remark posted into
       the thread                 the thread

Three decisions inside that worth naming

Escalation is cleared by being owned again or by being finished, so the flag means needs higher attention rather than being a permanent mark. And once a query reaches master admins it leaves every regional admin's view entirely, which is a clean handoff and poor ergonomics at the same time: the admin who escalated cannot see what happened next, or even confirm it was received.

This is the most operationally significant gap in the product and I would raise it before being asked. There is no time-based escalation. No ageing report, no stale-query job, no alert on an unassigned ticket, no service-level timer of any kind. Every escalation is a human act, which means an unanswered query escalates only if somebody notices it.

In a support system, time-based escalation is usually the entire point. Here, an unanswered query can mean a missed university deadline, and a missed university deadline can mean a year. The only signals today are the list ordering and a count on the dashboard.

Smaller things in the same area

The Offer Workflow

An offer is not a university application. It is a funding case tied to a specific university place, which is why the money fields outnumber the academic ones roughly two to one.

It is also the clearest example on this project of workflow rather than CRUD, and the difference is not stylistic. In a CRUD model you update a row and the last write wins. Here, whether a field can be written at all depends on the review status and on whether a mentor has been assigned, editing an approved offer writes a revision and resets the decision, and four different people touch the same record for four different reasons.

A CRUD resourceThis
Update any field at any timeEditability depends on review status and mentor assignment
Last write winsEditing an approved offer writes a revision and resets state
One actorStudent creates, regional admin routes, mentor may validate, admin decides
No historyA revision row plus an audit log entry
No notificationEmail and in-app on submit, on assign, and on every decision
Region is a columnRegion is the routing key deciding who is allowed to look
Delete is deleteSoft delete, and blocked entirely once a mentor is attached
Status is a labelStatus gates writes, unlocks edits, and drives a derived flag on the student

Creating an offer resolves the region and university before it validates anything else, because the business rules depend on the country. A UK offer needs a living cost location key, London or outside London. A non-UK offer needs a manual living cost figure instead. You cannot validate the offer until you know which of those worlds it is in.

The validation rules are worth reading as a set, because they are all protecting the calculation that comes later rather than protecting the form. A conditional offer without its conditions is unactionable, since nobody can tell whether the student is on track. And the scholarship rules run in both directions.

hasScholarship && !scholarshipName      -> reject
hasScholarship && amount undefined      -> reject
!hasScholarship && scholarshipName set   -> reject
!hasScholarship && amount > 0            -> reject

The two negative checks are the interesting half. Without them, a student who ticks the scholarship box, enters an amount, then unticks the box, leaves a stale amount on the record. That amount feeds the funding gap, which feeds the organisation's view of how much money this student needs. A silently reduced gap is a wrong operational decision about a real person, arrived at with no error anywhere.

Then three mutually exclusive branches guarantee the living cost has exactly one source: a residential school offer needs boarding fees, a UK offer needs a valid location key, a non-UK offer needs a manual figure. The point of that structure is that validation exists to make the calculation total. There is no path into the financial engine where the living cost input is undefined.

Two pieces of that are keyed on display strings, comparing a course level against the literal text "residential independent school" and a country against "uk". Rename the UK region to United Kingdom in the admin UI and every UK offer silently switches to the manual living cost path. It is in the ledger. It is the kind of fragility that looks harmless until the day somebody tidies a label.

Submitting requires an offer letter document, and a scholarship letter too if a scholarship is claimed. Documents are proof rather than decoration: the whole funding case rests on a document, and there is no point reviewing a case whose evidence is absent. Submission then flips the status to under review, sets a lock, and clears the previous reviewer's notes and identity so a stale decision cannot be mistaken for a current one.

The guard I like most handles resubmission. When an offer comes back with changes requested and the student resubmits, the system checks that something actually moved, either the offer fields or an uploaded document, before it will accept the resubmission. Without that, a student who did not understand the requested change can bounce the same unchanged offer back into the queue, and a reviewer spends a cycle discovering that nothing is different.

It compares timestamps with a one second tolerance against write ordering jitter, which makes it a heuristic rather than an exact test. A dedicated last-submitted-at column would be precise. I would rather have the heuristic that solves the real operational problem today than the exact solution nobody has written, as long as it is labelled honestly as what it is.

One small thing carried through the whole notification path: the submission kind travels with the event, so an admin queue reads "Offer Resubmitted" rather than "New Offer Submitted". That is the difference between a queue you can triage at a glance and a queue you have to open.

student submits
  |  status = under_review, locked
  v
region already on the offer, resolved at creation
  |
  +-- email to the admins for that region
  +-- in-app event to master admins + active regional admins of that region
  v
regional admin queue, region scoped
  |
  +-- review directly ---------------------------+
  |                                              |
  +-- assign a mentor                            |
        |  mentor must be active and approved    |
        v                                        |
     mentor sees it in their own queue ----------+
                                                 v
                         approved | changes_requested | rejected

The permission check behind that is three tiers, short-circuiting in privilege order and all verified against the database rather than against a token. Master admin passes. Otherwise a regional admin passes only if their profile is active and attached to this offer's region, and that condition is expressed inside one query rather than fetched and compared afterwards. Otherwise, if an offer id was supplied, an assigned mentor passes.

That last tier is a divergence I would rather state than have someone find. Because the review path supplies the offer id, an assigned mentor can set an offer to approved. The role documentation says mentors have no administrative access. The flows document says master admins review all offers and regional admins review their own countries, and does not mention mentors at all. The client's requirements document does give a mentor an approval act, but a narrow one, confirming the changed fields in a revision.

This is not a rogue implementation, it is an under-specified requirement resolved in the most permissive direction available. The right fix is not to argue about which document wins. It is to make the two acts different: a mentor validates a revision and that validation is recorded, while the regional admin's decision stays the only thing that moves the status. Two acts, two records, one decider.

Approving an offer sets a verified-offer flag on the student profile. The part that is easy to get wrong is un-approval, and this gets it right: pulling back an approval does not blindly clear the flag, it counts the student's other approved offers and sets the flag to whether any remain. Both branches run inside the same transaction as the status change, so the flag can never disagree with the offers it summarises.

It is a derived field maintained transactionally rather than computed on read, which buys a cheap indexed boolean for the student grid and dashboard filters at the cost of two places that must remember to recompute. That is correct today because there are exactly two and they sit in the same function. It stops being correct the moment a third path can change an approval.

This is the piece I would show first. The question it answers is what happens when a student edits an offer that has already been approved, and there are three tempting wrong answers.

Block the edit

Wrong because real offers change. Universities revise conditions, scholarships get confirmed, start dates move. A system that cannot represent that forces the truth outside the system, which is the exact failure the platform was built to end.

Allow it silently

The dangerous one. The record still says approved, but it now certifies numbers nobody ever approved. Nothing looks broken, which is precisely why it is the worst option.

Allow it and wipe the history

The reviewer sees a changed offer and cannot tell what moved, so they either re-read the entire case or rubber-stamp it. Both are bad, and the second is what actually happens under time pressure.

What it does instead: snapshot the offer before and after, compute the list of changed fields, and if the offer was approved, write a revision row, reset the status to under review, clear the review fields, and re-notify by both email and in-app event. All of it in one transaction, so an offer can never be reset without its revision existing.

Four properties, each earning its place

What it does not cover, and I would say this before being asked: replacing a document is not revisioned and does not reset an approved offer. So the numbers are protected and the evidence is not. Someone can swap the offer letter under an approved decision and nothing records it. There is also no rollback, which I think is right, because reverting should be a reviewed edit rather than a button.

The Funding Engine

The browser never decides whether a student is funded.

The funding gap is computed on the server, by a pure function of about a hundred and fifty lines, from a rule set held in a database row. Given one offer's numbers it returns the tuition gap, the living cost, the living cost gap, whether each is covered, and the number of complete years.

It also returns where the living cost number came from, as an explicit field, one of a configured country rule, a manual figure, boarding fees, a scholarship that covers living costs, or none. That field is my favourite thing in the module. When an admin questions a figure, it is the difference between the system says thirteen thousand seven hundred and sixty one pounds, and the system applied the configured UK London rule. Explainability as an output, not as a log line somebody has to go and find.

The third branch is the one people get wrong. If the student cannot cover tuition, the entire living cost is still outstanding. You do not net a tuition shortfall against a living cost requirement, because they are separate obligations to separate parties, and a visa authority does not care that the tuition is short. Only when tuition is covered does the excess offset the living cost, and even then the result is clamped at zero.

This is worth being able to argue properly, because it is the kind of decision that looks like dogma until you name what it is protecting.

ReasonWhat it prevents
The browser is user-controlledA student reporting a zero funding gap and changing an organisational funding decision about themselves
The number drives moneyThe admin dashboard sums these gaps across approved offers into a total the organisation allocates against. A figure that decides where charitable funds go cannot originate on a client.
Five surfaces consume itStudent view, admin list, admin detail, CSV export and dashboard all show the same summary. One implementation is one answer. A second implementation in the frontend guarantees eventual drift, and the drift would be silent.
Rules change centrallyUK visa maintenance figures are set by the Home Office. Changing one database row updates every calculation at once. A client-side constant needs a deploy and leaves already-rendered pages quietly wrong.
AuditabilityInputs are in the database and the rule set is a row, so you can reconstruct why a number was what it was on the day it was acted on.

The rule set is a single config row holding the currency, the per-country living cost rules and the duration formula. The seeded UK values are the London and outside-London visa maintenance figures. Those two numbers are the textbook definition of a rule that will change without the software changing, and they belong in a row rather than in a constant.

The parser validates the shape and throws loudly if the currency or the rules or the duration block is missing, so a malformed config fails as an error rather than silently computing against undefined values. That is the right instinct, and it is also where I would criticise my own work, because the validation runs at read time. A malformed config is therefore discovered as a server error on every offer request, when it could have been discovered as a rejected form submission at the moment somebody saved it. Validating on write turns a site-wide outage into a form error.

A setting that does nothing

The rule set carries a complete-years formula as a string. Nothing parses it. The code hardcodes the floor operation. It is documentation stored in a database pretending to be configuration, and it is harmless right up until somebody edits it and expects an effect.

Changing a financial rule is not audited

Roughly thirty sensitive actions are audit logged, and editing the living cost figure is not one of them. Changing that row alters every funding gap in the system and leaves no trace of who did it or when. For a financial rule that is a real gap, and it is the one I would fix first on this tab.

The rules row is fetched more than it needs to be

The list path fetches once and passes the rules down. The single-offer paths each fetch independently. It is a completely static row and an in-memory cache with a short expiry would remove a query from most offer requests.

The requirements document asks for the living cost as the annual figure multiplied by the course duration in complete years. The engine does not multiply. Every output field is per-year: tuition per year, available funds per year, the gaps, and the living cost. Internally it is consistent, and comparing an annual fee against annual funding against an annual living cost is a coherent model that arguably matches how funding is actually disbursed.

But it is not what was asked for, and the two produce very different numbers. A three year London course is about thirteen thousand seven hundred pounds in the code and about forty one thousand in the requirement. The tell is that complete years is computed, returned, and never used for anything, which is what a half-finished total-cost model looks like.

I have this flagged as a question to confirm rather than a bug to fix, and that is deliberate. The right resolution is not to pick a side in code, it is to ask the organisation which number they actually act on. If they allocate annually then per-year is correct and the requirement wording is stale. If they need a total commitment per student, the engine should return both, named explicitly. Changing a financial figure without asking is worse than leaving it inconsistent and labelled.

The admin dashboard sums funding gaps to show the organisation's total exposure. Inside that aggregation, for each student it takes the offer with the smallest gap rather than adding them up.

That is correct and it is not obvious. A student with three approved offers will attend one of them. The organisation's real exposure is the cheapest viable path, not the sum of all three. Summing would inflate the funding requirement by roughly the number of offers per student, and that inflated number would be used to plan fundraising. A genuinely good product decision sitting quietly inside a reduce.

It is also every approved offer loaded into memory with the engine run per offer on each dashboard load, which will not scale and is knowingly accepted at current volume. The whole block is wrapped so that a broken financial config makes the funding numbers zero rather than making the dashboard fail, which is the right call, since the other counts on that page are still useful when the money maths is unavailable.

Document Security

Documents are the piece of this system I would defend hardest, because a mistake here is not an embarrassment, it is somebody's passport number.

The platform handles twelve document types and they are not equally sensitive. A CV leaks an employment history. An offer letter leaks a university and a student name. A scholarship letter leaks financial circumstances. A passport leaks a full identity, a document number, a nationality and an expiry date, belonging to a person who may be trying to leave a conflict zone on that exact document. National ID is the same class. Those two types are the reason the rest of this page exists. Every rule below was written with them in mind rather than with the average case in mind, and when a rule felt inconvenient we resolved it in favour of the passport.

It is worth saying plainly what the surrounding data is, because it sets the stakes for everything else. A student profile carries a passport number, a national ID number, an emergency contact, a location inside Gaza and a description of the family's financial position. A wrong access grant on this system is not a leaked email address. It is a real exposure for a real person in a war zone. That is the standard we held the document code to, and it is why the document code is the strictest part of the codebase.

Nothing is ever a public URL. Uploads go to Cloudflare R2 through the S3 SDK, and the object key is a server-generated UUID plus the file extension, never the name the student uploaded. A file called ahmed-passport-2024.pdf never appears in a URL anywhere, so an observed key tells you nothing about whose document it is or what it contains. Only the key and the bucket land on the database row. There is no URL column anywhere in the schema, which means a stolen database dump gives you object locations, not access.

The control I am most attached to is the one that is barely a security feature at all. In production the storage module refuses to start if R2 is not configured. It throws at boot with a message saying that uploads would otherwise be written to local disk and lost on redeploy. The realistic path to a document catastrophe on a project like this was never an attacker. It was a missing environment variable after a deploy, an S3 client quietly failing to initialise, and the app cheerfully falling back to writing student passports onto a container filesystem that the next deploy destroys. Turning that into a startup crash means a typo in an R2 key stops a deploy instead of silently starting to lose documents, and it means a misconfiguration is loud even though an outage is not.

What private by construction means here

Validation runs in layers rather than in one function. The upload middleware checks both the MIME type and the file extension against allowlists, because checking only one of them is the classic bypass. Some document types are image-only and get a narrower filter. Underneath that, the service enforces business rules that make the later authorization rules sound: a passport cannot be attached to an offer, and an offer letter cannot float free of one. That is not validation for its own sake. The offer-scoped access rules only mean something if offer-scoped documents are genuinely tied to an offer.

There are three size caps and they are deliberately separate. Student documents are capped at 5 MB, signatures at 1 MB, and legal documents at 10 MB on their own upload instance, so the higher legal limit can never bleed into the student limit through a shared branch someone edits later. Nginx carries a client_max_body_size of 10M so oversized bodies die at the edge before Node allocates anything. The 5 MB figure is not a generic performance number copied from somewhere. Students in Gaza are on poor and intermittent connectivity, and a large upload that fails at ninety percent is a student who gives up. The cap is a property of the user population that happened to land in the code as a config value.

The honest gap is content inspection. The MIME type is a client-supplied header and the extension is a client-supplied string, and neither of them is the file. A payload with a permitted extension and a matching declared type gets through. There is no magic-byte check and no virus scan, even though the schema has a failed_scan document status sitting unused for exactly that purpose. Magic-byte verification is a few bytes per format and should have been there.

GET /api/documents/:id/download
  |
  +-> document exists, status active, not soft-deleted    else 404
  +-> requester is the owner?                             -> allow
  +-> master admin or reviewer?                           -> allow
  +-> regional admin (active profile)
  |     offer document   -> offer must be in my region
  |     profile document -> type must be in my allowlist
  |                         AND student linked to my region
  +-> mentor
  |     type must be in the mentor allowlist FIRST
  |     then: assigned to that offer, or to some offer
  |           belonging to that student
  +-> no branch matched                                   -> 403
  |
  +-> stream the object through the API
  +-> recordAuditLog(document_downloaded)

Ownership first, then assignment, then region, then a per-role document-type allowlist, then the stream, then the audit entry. The function ends with a 403 rather than an allow, so anything the chain does not explicitly permit is refused. That polarity matters more than any individual branch, because it means a document type added next year is denied to everyone by default until somebody puts it in a set on purpose.

The part I would point at in a review is how mentors and regional admins are blocked from passports and national IDs. It is not an if statement in a template and it is not a hidden button. Those two types are simply absent from the mentor allowlist and absent from the regional-admin profile allowlist, both of which are sets defined as data next to the document types. A mentor asking for a passport does not fail a check, they fall off the end of the chain because no branch can match. We left a comment in the code saying exactly that, because an implicit denial is the kind of thing a refactor deletes without noticing. Writing down why the fall-through exists is the difference between a deliberate default-deny and an accident that happened to be safe.

The authenticated download path streams the object through the API rather than redirecting the browser to a presigned URL. That costs bandwidth and holds a connection on the API container, which is real and which we accepted at this scale. What it buys is that no R2 URL ever reaches the browser, so there is nothing to copy out of an address bar, a bookmark, browser history or a referrer header. Content type and disposition are set by the server from the stored metadata rather than inferred from the object, caching is disabled so documents stay out of intermediary caches, and every byte served has already passed the authorization chain and been written to the audit log.

Uploads supersede rather than overwrite. A new version of a document type marks the previous row superseded and creates a new row in the same transaction, and for a consent form the same transaction sets the consent flag on the profile. If any step fails, none of it happened, so there is no state where the profile claims consent was signed and no consent document exists.

We went through the document surface attempt by attempt rather than feature by feature, because a feature list tells you what exists and an attack list tells you what holds. This is the result, including the entries that do not hold.

The attemptWhat stops it
Guess or construct the R2 object URLBlocked. The bucket is private, keys are UUIDs, there is no public policy, and the authenticated path never emits an R2 URL at all.
Reuse a presigned URL after it has expiredBlocked by the signature itself. Authenticated downloads never expose one, and re-requesting mints a fresh URL only after the whole chain has run again.
Mentor requests a student passport403. The passport type is not in the mentor allowlist, so no branch in the chain can grant it. Set membership, not a UI condition.
Regional admin requests a national ID403 by fall-through. The type sits in the profile document set but not in the regional-admin visible set, so the chain reaches its final refusal.
UK regional admin requests a Spain offer letter403. Offer documents resolve region from the offer's university country, and the region must match the requester's own active profile.
Student A requests student B's document403. Not the owner, and no role branch applies to a student. This is the cleanest boundary in the system.
Iterate object IDs against the download endpointPartially mitigated. Every request runs the full chain so enumeration yields 403s, and the route is rate limited to 60 requests per 15 minutes per IP. The gap is that failed attempts are not logged, so a probing campaign and a stale bookmark look identical.
Upload an executable payload with a permitted extension and MIME typePasses validation. Both checks read client-supplied values. What limits it is that the object is stored under a UUID with no execution context and R2 runs nothing, so the realistic risk is a malicious PDF handed to an admin's viewer.
Upload a 100 MB fileBlocked four times over: Nginx at 10 MB, the upload middleware at 5 MB, the service re-check at 1 MB for signatures, and the separate legal-document instance that cannot raise the student cap.
Path traversal in the storage key or the filenameBlocked. Storage keys are server-generated, and the legacy local path uses a relative-path check rather than a string prefix check, because uploads/private-evil starts with uploads/private and is a different directory.
Fetch a document after it has been deleted404. The chain requires an active status and a null deletion timestamp. The R2 object itself is retained deliberately for auditability, which does mean deleted files persist in storage with no retention policy.
Reuse a document link embedded in an exported CSVThis works, by design, and it is the weakest thing on this page. See below.

Three routes serve documents without a session. One of them is fine and two are a trade we made knowingly, so I would rather describe them precisely than claim the whole surface is locked down.

Legal document preview

CSV token link

Short code link

The split is clear and I would not pretend otherwise in a review. The authenticated path is strong. The convenience paths bolted on for spreadsheet workflows are where the work is. Given one afternoon on this, I would spend it adding audit logging to those two routes before touching anything else, because the exposure window is bounded by a time limit and the detection window is currently infinite.

Chat & Notifications

The system carries two kinds of message, and keeping them apart is the decision everything else in this tab follows from.

A query message belongs to a ticket. It is part of the case record, it is audited, it is kept, and resolving the ticket locks the thread. A chat message is a conversation about the case. It is not audited, it supports attachments, and it is hard-deleted after seven days. We could have built one messaging feature and used it for both, and it would have been less code. It would also have meant either keeping coordination chatter forever or deleting the case record after a week, and neither of those is defensible.

The seven-day chat retention is a privacy decision rather than a storage one. Because the durable record is the query thread plus the audit log, deleting chat costs nothing anybody needs later, and it means the system holds far less conversation about people in a conflict zone than it otherwise would. Holding less is the cheapest privacy control there is.

Direct chat permission is one function that ends in a refusal, so any pair not explicitly permitted is denied. That default matters more than any individual rule, because the failure mode of the opposite polarity is a role added later silently gaining access to everyone. On top of the matrix, any conversation involving a student is gated behind a global feature flag, so student chat can be switched off entirely without a deploy.

PairAllowedWhy
Master admin and anyoneYesThe trust root, and the specification asks for it explicitly.
Regional admin and regional adminYes, across regionsStaff coordination between regions is the point of having both.
Regional admin and mentorOnly in the same regionThe mentor's preferred region must equal the admin's region, and a missing region on either side is a refusal rather than a pass.
Mentor and mentorNoMentors are external volunteers who each hold a slice of student data. The organisation wants coordination flowing through staff, not laterally between volunteers.
Student and studentNoNot asked for, and there is no version of it that helps a student more than it exposes them.
Student and staffYes if the flag is onThis one is a simplification, and it is the hole in the matrix.

That last row deserves the detail. The student and staff branch permits any student to message any mentor or regional admin, with no assignment check and no region check. So a mentor attached to one region can open a conversation with a student they have no relationship with. Everywhere else in the system, staff reach students through an assignment or a region. Here they reach them through user search. The mitigations are real but partial: student chat is behind the flag, and user search overwrites a regional admin's requested region with their own, so a regional admin cannot even find out-of-region users. A mentor's search is not filtered that way. The fix is to require an actual relationship, meaning the student has an offer in that admin's region or the mentor is assigned to one of that student's offers, and it is the first thing I would change in chat.

Only master admins and regional admins can create a group. Mentors and students cannot. Adding a member runs a chain, and the ordering in that chain is the part worth explaining. The student checks run before the group-admin requirement, so being an admin of a group cannot be used to skip the strictest rule. Adding a student requires the same region relationship used everywhere else in the system, which is what stops group membership from becoming a side door to a student you could not otherwise see. Group creation re-runs the student checks rather than trusting the add path, so nobody can be smuggled in at creation time. That is duplicated logic, and it is duplicated in the safe direction.

Socket authentication reuses the same token verification function as the HTTP layer, reading the token from the handshake, the authorization header or the cookie. One verifier across two transports, because a second token verifier is exactly where a divergence bug would eventually live. Room membership is derived from conversation member rows on every connect, never from anything the client sends, so a client cannot ask to join a room it has no row for.

The socket runs on HTTP polling rather than a WebSocket upgrade. Two reasons, both practical. The httpOnly cookie has to survive the proxy hop, and users on constrained networks are more likely to get plain polling through than an upgrade handshake. For this user population the second reason is not hypothetical. The cost is more requests and more held connections, and for coordination chat between a few dozen staff that is acceptable.

A master admin can read and post in any conversation without being a member. The specification asks for it and I would keep it. What I would not keep is that the access is unlogged. For a system that writes an audit entry on every single authenticated document download, a role being able to read any conversation invisibly is inconsistent, and it is inconsistent in the direction that makes internal misuse undetectable.

Chat attachments go to R2 under a key shaped as chat, then the conversation ID, then the file ID. The message row stores the API path that serves them, which begins /api/chat/attachments. A nightly job deletes messages older than seven days, and before deleting it tries to clean up the attachments by checking whether the stored value starts with chat/ and deleting the object if it does.

It never starts with chat/. It starts with /api. So messages are deleted from the database on schedule and every attachment stays in R2 permanently, now with no database row pointing at it. Files that a stated seven-day retention policy promised to delete are retained indefinitely and are simultaneously unreachable through the app. That is worse than a leak in one specific way: it is a privacy policy the system does not actually keep. It is reproducible, it is ours, and the fix is either to reconstruct the key from the conversation and file IDs or, better, to store the storage key on the message alongside the display URL, which is what the document model already does correctly.

Chat attachments are also redirected to a presigned URL rather than streamed through the API, so an R2 URL does reach the browser on that path. Lower stakes than a passport, and inconsistent with the document model, which I would rather flag than quietly rationalise. Attachment access is also enforced by calling the get-messages function and relying on it to throw, which works and is fragile. A named assertion would cost the same and would not break the day somebody changes what get-messages does.

service                      shared events           listeners
  |                              |                       |
  | emit(OFFER_SUBMITTED, {..})  |                       |
  |----------------------------->|  node EventEmitter    |
  |                              |---------------------->| resolve recipients
  | returns immediately          |                       | build role-aware link
  |                              |                       | create Notification row
  |                              |                       | push over the socket

Services emit what happened and listeners decide who cares. Without that split, the offer submission function would have to know who a region's admins are, that master admins also want to know, what the frontend route is for each role, and how to push over a socket. Four concerns leaking into a function whose job is to submit an offer.

Each listener resolves its recipients and builds a different deep link per role, because the same offer lives at a different URL depending on who you are. Every dispatch is wrapped so a notification failure can never fail the business operation, and the emit has already returned by the time the listener runs. Emails follow the same rule and are best effort throughout, so an offer submission succeeds even when the mail provider is down.

The honest cost of fire and forget is that there is no retry, no dead-letter queue and no delivery record. A failed dispatch is a line in a container log. In-app notifications are still durable, because the row lands in the database and the user sees it on their next page load, but a failed email is simply gone. The property that saves this is that every queue in the system is a query over state rather than a feed of notifications, so an admin whose notification never arrived still sees the offer sitting in their review list. Delivery is a convenience layered on top of state, never the thing a workflow depends on.

Bulk announcements iterate recipients one at a time, roughly two queries per user, with a comment in the code admitting a broadcast would be the right shape. At a few hundred users that is a slow request. At ten thousand it is an outage. It has not bitten us at current scale, which makes it a known ceiling rather than a surprise waiting to happen.

Security & Privacy

The data here is genuinely sensitive, so I would rather describe the boundaries precisely, including the ones that leak, than claim the whole thing is secure.

A student record carries a passport number, a national ID number, a date of birth, an emergency contact, a location inside Gaza and the family's financial position. Those are not fields on a form. A wrong access grant is a real exposure for a person in a war zone. That framing decided most of the arguments Hamza and I had about where a check belongs, and it is why the answer was almost always the database query rather than the interface.

AttackWhat holdsWhat does not
Change an ID in a URL to another student's recordOwnership is a query predicate, not a check performed after fetching. The where clause carries the student user ID, a mismatch returns nothing, and the service throws 404 rather than 403 because a 403 confirms the record exists. You cannot forget to check what you never fetched.One admin profile route is gated by role but not scoped by region. Safe today, and one edit to a role list away from not being.
Grant yourself a higher roleThere is no self-service role endpoint. Role writes happen in two places, both admin-only, and the volunteer path explicitly refuses to touch accounts holding a privileged role. Sensitive paths re-read roles from the database, so a stale or forged claim in a token buys nothing.Creating a regional admin or a reviewer writes no audit entry. Role grants should be the most audited action in the system and they are among the least.
Read another region's dataRegion is derived from the offer's university country, never from the student, and scope is both rejected when wrong and substituted when absent. Omitting a region parameter does not widen access, which is the half that catches the developer who only wrote the rejection branch.Student and staff chat carries no relationship requirement, so that one subsystem sits outside the isolation model that holds everywhere else.
Steal a session token and replay itThe cookie is httpOnly and secure, access and refresh use separate secrets validated at boot, and sensitive actions re-read authorization from the database, so a token belonging to a since-deactivated admin fails.There is no revocation. Logout clears the cookie but the token stays valid until it expires, and a password reset does not invalidate existing sessions. A token version integer on the user record would fix both with one field.
Cross-site request using the victim's cookieA required custom header forces a CORS preflight that only the configured origin passes, and httpOnly blocks theft through injected script.The cookie is set to be sent cross-site in production, so the header and CORS pair is the only layer. It is sufficient, and it is one layer where two are available for free.
Read secrets or personal data out of the logsRequest logging records method, path, status and timing only. Audit metadata records shapes rather than contents: that notes were provided rather than the note, which fields changed rather than the values.Error output from the database layer can contain field values, and the error reporting service receives request URLs, which contain IDs.

Access is layered by what a piece of data costs if it escapes. Public policy documents need no session at all, because consent you cannot read before agreeing to it is not consent. Regional data is visible to that region's admins. Assignment data is visible to the assigned mentor. Case data, meaning the profile, the financials, the emergency contact and the Gaza location, is visible to the owner, to master admins, to reviewers, and in reduced form to the in-region admin.

Then there is the identity tier, which is two document types and three profile fields: the passport document, the national ID document, and the passport number, national ID number and passport expiry on the profile. Those are readable by the owner, master admins and reviewers, and by nobody else. The two roles that interact with students most, mentors and regional admins, cannot reach them at all. The documents are outside their allowlist sets, and the numbers are explicitly nulled on the way out even when the row has already been loaded. That second half matters. A mentor still gets a student's name, email, phone and date of birth, because they need contact details to do the job. The view is reduced, not anonymised, and being clear about which one it is stops people assuming a protection that is not there.

One asymmetry is worth mentioning because it was a real decision rather than an oversight. Signature images are visible to regional admins but not to mentors. A signature is reusable, credential-like data, and a regional admin is staff while a mentor is an external volunteer. That difference in trust is encoded as a difference in two sets.

The parts of the privacy model I would keep unchanged

Roughly thirty sensitive actions write an audit entry carrying the actor, the time, the IP, the user agent and the scope of what was touched. It is append-only with no update path and no delete path, which is the only shape an audit log can honestly have. Document downloads, review decisions, offer edits with their changed field list, query assignment and resolution, and every CSV export with its filters and row count all land there. The audit write sits outside the surrounding transaction on purpose, with a comment explaining why, so the failure mode is a decision that lands without a log line rather than a decision that fails because logging failed. That is the right way round.

What is missing from it is fair criticism. Configuration changes are not audited, which means somebody can alter a living-cost figure that feeds every funding calculation in the system and leave no trace. Role grants are not audited. Failed authorization is not audited, so a sustained probing attempt and a stale bookmark produce identical silence. And the two unauthenticated document routes write nothing at all. Detection, rather than prevention, is where this system is thinnest, and the audit log is where that shows most clearly.

We worked through the production failure scenarios one at a time, and one property does most of the work. Every queue in the system is a query over state rather than a feed of delivered notifications. So when a notification fails, the offer is still in the admin's review list and the query is still in the assignee's queue, and nothing is lost. That single decision turns most of the failure list into inconveniences rather than incidents.

Storage goes down

The socket drops

The mail provider fails

A student loses connection mid-form

The most serious problem in this codebase is that regional admin and reviewer passwords are stored in plaintext, in a column alongside the bcrypt hash, returned by the API and displayed in the master admin interface. It exists because a master admin needed to re-send an invite with the original credential, which is a real operational need. It is the wrong solution to it, and it defeats the hashing entirely for the highest-privilege accounts in the system. The fix is to delete both columns and issue a single-use invite token through the token model that already exists for password resets. I found this reading our own code rather than by scanning it, and I am not going to soften it: the same file hashes the password with bcrypt two lines earlier.

The second is that there are no automated tests. The lint script is a TypeScript typecheck, which catches shape errors and catches nothing at all about authorization. Every claim on this page is a claim about behaviour I have read and traced rather than behaviour a test suite pins down. For a system whose central property is that a mentor cannot reach a passport, the absence of a test asserting exactly that is the gap I would close first, ahead of any architectural change. Regional isolation is re-asserted at nineteen call sites, and nineteen call sites without tests is a refactor waiting to quietly break one of them.

Beyond those two: there is no multi-factor authentication for admin roles, no alerting on unusual behaviour, so a regional admin exporting every day produces audit entries nobody reads. There is no retention policy for student records after a case closes and no right-to-erasure path, since deletion is soft and the stored objects survive it. A master admin can export the whole dataset in slices, which is inherent to the role rather than a bug in it. The control for that is visibility rather than prevention, and the visibility is written but unwatched.

If I had to summarise the posture in one line: correctness is good and observability is thin. Most of what I would do next is not another gate. It is finding out sooner that something happened.

Data & Jobs

The most expensive things this system does all happen outside the request that asked for them.

An export of every student in a region, a nightly cleanup of expired files, a retention sweep over chat, a recovery pass on boot. None of those belong in a request handler, and the shape of the data model decides whether they are cheap or painful to run.

The database is MongoDB through Prisma 6. It did not start there. The project ran on PostgreSQL via Supabase earlier, and the leftovers are still visible: a performance investigation written entirely against Postgres, a dead migrations.postgres folder, and a note in the repo instructions saying older docs claiming Postgres are stale. Why the migration happened is not written down, so I am not going to invent a reason. What is recorded is the pain before it: a remote pooler where a plain SELECT 1 took about two seconds cold, intermittent connection failures, and a database region far from the app. Whether that drove the move is inference, not fact.

What the document model actually buys in this schema

There are no foreign keys, so nothing stops an offer pointing at a region that was deleted, and every existence check is application code. There are no partial indexes, so the soft-delete condition is hand-written into every where clause and forgetting it is silent. There are no CHECK constraints, so a rule like "a conditional offer must carry its conditions" lives only in TypeScript and a direct database write bypasses every business rule in the product. And db push means no reviewable, revertible migration history. Schema changes are undocumented events.

The strongest argument against the choice is row-level security. Regional isolation is the most important property in this system and it is currently re-asserted at nineteen separate call sites. In Postgres one policy would enforce it in the database on every query, including the ones nobody has written yet. Starting again, I would pick Postgres for that reason alone. I would still not migrate now: that is a large project for a benefit a real isolation test suite delivers far more cheaply, which is why the top ledger item is tests and not a migration.

Indexes are the weak point. Nearly everything is single-column, and most hot queries are compound. A regional admin's offer list filters on region and deletedAt then sorts by updatedAt, so Mongo narrows by region and sorts the rest in memory. And User.roles, queried with a has predicate in nearly every admin path, has no index at all.

The requirement was one-click export of the filtered database including links to uploaded files. Doing that in the handler fails on timeout, because Nginx and the browser give up first. It fails on memory, because the file is built as a string inside a 768 MB heap. It leaves the user watching a spinner with no way to tell working from hung, no way to cancel a mistaken hundred-thousand-row export, and no record at all if the container restarts mid-run. So it is a job.

POST /api/csv-generator
  resolve scope -> master_admin | regional_admin + regionId | 403
  validate the requested filters against that scope
  same filters/columns/range in the last 5 min -> return the existing job
  2 jobs already pending or generating for this user -> 429
  create CsvJob { status: pending, filters: { ...filters, _scope } }
  runCsvJob(job.id)      <- fired, not awaited
  audit: csv_export_requested
  respond { jobId, status, duplicate }

runCsvJob(jobId)
  status = generating
  cursor batches of 1000 -> temp file, cancel checked every batch
  upload -> R2  csv-exports/YYYY/MM/<dataset>/<jobId>.csv, unlink temp
  status = completed, rowCount, fileSizeBytes, durationMs, expiresAt
  on throw -> status = failed, errorMessage

GET    /:jobId/download   fresh presigned URL, or 410 if expired
DELETE /:jobId            cancel if running, delete if finished
POST   /:jobId/retry      failed jobs only

Batching is cursor-based on id rather than offset, so cost per batch is constant and the pass stays stable while other people insert rows. Rows go to a temp file, never an accumulating string, so peak memory is one batch however large the export. Cancellation is checked at the top of every batch, which is cooperative, and cooperative is the only kind available in a single process. There is deliberately no estimated-rows column, with a comment saying it avoids an expensive COUNT. A progress bar that costs a full collection scan is worse than no progress bar.

What is stored is the object, never a URL, so every download mints a fresh presigned link and a link in someone's history stops working. Both expiries come from one environment variable, default thirty days, capped by the signer at the seven-day SigV4 limit. The job row is checked for expiry before any URL is minted, so an expired export returns a 410 rather than a working link.

The caller's scope is resolved at request time, written into the job row, and stripped back out before the job is returned to a client. The runner reads the frozen scope instead of re-resolving it, so a job cannot gain privilege from a role change mid-run. The detail I care about more is that enforcement overrides as well as rejects: a regional admin who omits the region entirely still gets their own region injected into the query builder. The naive version only writes the reject-a-mismatch branch and forgets the no-region-supplied case, and that is exactly how scope bypasses happen.

ControlMechanism
ConcurrencyAt most 2 pending or generating jobs per user, otherwise 429
DuplicatesSame dataset, filters, sorted columns and range within 5 minutes returns the existing job
Date rangezod-validated, capped at 90 days
ColumnsChecked against an explicit allowlist, never passed through
ScopeForced server-side, and a master admin must name a region or university for student and mentor exports
AuditRequested and completed, both carrying actor, scope, filters and row count

A CSV holding student names, emails, phones, emergency contacts, locations in Gaza, financial gaps and clickable document links is the highest-value artefact this product makes, and once downloaded it is entirely outside the platform's control. Scope enforcement, expiring links and audit logging are the right mitigations and they do not remove the residual risk. The export is the largest data-exfiltration surface in the system and it is also a required feature.

Because jobs run in the same process as the API, a crash leaves a row stuck in generating forever. On boot the server runs one updateMany that flips every generating row to failed with a message telling the user the process restarted and to retry. That is a handful of lines and it is the difference between a job system and one you can trust. It also assumes a single instance: with two containers, restarting one would fail the other's in-flight jobs. Correct today, a bug the day a second container starts.

JobTriggerWhat it does
CSV cleanupnode-cron, 02:00 UTCDelete expired objects from R2, mark the row expired, keep the row as a record
Chat retentionnode-cron, midnightDelete messages older than 7 days
Stuck-job recoveryOn bootgenerating becomes failed
NotificationsEventEmitter, detachedCreate the rows, push over the socket
EmailCalled and ignoredBest-effort SMTP, never retried

The crons recover by construction, because their predicates are everything older than a threshold rather than everything since the last run, so a missed midnight run costs nothing. Notifications are the part that is genuinely not idempotent: re-emitting an event creates a duplicate row, and the clientMessageId field that exists precisely to key that is unused. Job observability is thin. Everything is console.log, the CSV table is the only job with queryable state, and nobody can answer whether last night's cleanup ran except by grepping container logs. A JobRun collection recording name, start, finish, status, items and error is about fifteen lines and is not built.

The audit model has no deletedAt and no update path anywhere in the codebase. The helper that writes it is about twenty lines and only ever creates. Roughly thirty sensitive actions are recorded with the acting user, the time, the IP and the scope, plus a metadata blob. IP comes from the request with trust proxy set, so it is the real client address rather than the Nginx container's.

What is covered

The metadata pattern is the part I would defend hardest: enough to reconstruct the decision, never the sensitive content. A review records the previous status, the new status, and whether notes were given as a boolean, not the notes. An edit records the changed field names, not the values. A download records the type, not the document. An audit log full of copied personal data is a second breach surface with none of the first one's access controls.

The gaps are real. Editing the financial rules configuration changes the funding gap on every offer in the system and leaves no trace. Creating a regional admin or reviewer is not audited. There is no login history, only an overwritten timestamp. Failed authorization writes nothing, so an admin systematically probing another region's IDs produces a wall of 403s and not one audit entry, which is the omission that matters most for detecting an intrusion. Regional admins cannot read the log at all, deferred on purpose because scoping an audit log by region needs a different lookup per entity type and some entries have no region at all.

Frontend & Bandwidth

The users are students in Gaza on unreliable connections, which turns a set of performance niceties into actual requirements.

Scope note, because it matters: I worked on the frontend scaffolding, the structure, the UX and the UI polish. Hamza led the overall implementation and built most of the feature pages. What follows is analysis of the codebase, not a claim that I wrote every file in it.

Next.js 16 App Router, React 19, TypeScript 5, Tailwind v4, TanStack Query v5, Axios, socket.io-client, react-hook-form with zod, framer-motion, a custom i18n layer and Sentry. The decision worth explaining is that route segments map one to one onto role boundaries. Everything under /student is student, everything under /regional-admin is regional admin, down through /reviewer and /chat. A URL tells you which role it belongs to, which is what lets the backend generate role-correct deep links into notifications, and it makes a misplaced page obvious the moment someone puts it in the wrong folder.

The gates on top are duplicated deliberately. The middleware checks whether the auth cookie is present and decodes its expiry without verifying the signature, purely so the app does not render a page that is about to 401. The providers layer then calls the session endpoint, caches the result in sessionStorage only to avoid a flash on navigation, always re-verifies, and blocks protected routes behind a loading gate. Unverified students and mentors get pushed to the verification page, mirroring the backend middleware that blocks the whole API for them. None of that is security. The frontend gates exist for the experience and the backend gates for the enforcement, and keeping that explicit is what stops someone deleting a server check because the UI already hides the button.

Redux Toolkit is not a dependency. State is useReducer plus two contexts across six domains: ui, auth, offers with a pagination cache, queries, announcements, profile. In its favour it adds nothing to the bundle and is trivially readable. Against it, it reproduces Redux's shape without the devtools, the middleware or the memoized selectors, and because the selector hook reads a context value rather than subscribing to a store, every consumer re-renders on any state change anywhere. TanStack Query meanwhile handles chat and notifications, so there are two state systems side by side. Having built it, my honest verdict is that TanStack Query for server state plus a small context for UI state would have covered everything with less code. The custom store is not wrong, it solves a problem a dependency we already had was solving.

About five hundred lines translate backend shapes into view models. It renames title to subject and queryType to category, maps changes_requested onto needs_attention, collapses the roles array into one priority-ordered role, and humanizes enum values so gaza_city renders as Gaza City. Having this file is right, because a rename lands in one place instead of forty components. Having to have it is the price of two repositories with no shared types package, which is the largest structural weakness in the codebase. It also carries a helper that accepts an array, or an object with one of several named array keys, or nothing. That tolerance is a symptom, not a feature: it exists because the response envelope is not perfectly consistent and it hides contract drift. One thing should not be there at all, which is that the client re-derives funding gaps the backend already returns. Two places computing the same money figure is how two different numbers for the same offer end up on two screens.

There is exactly one Axios instance carrying five responsibilities: the header that satisfies the CSRF check on every request, cookie credentials, unwrapping the response envelope, a one-shot 401 refresh that retries the original request once, and error normalization with Sentry reporting that skips 401s and 404s to preserve quota. The rule that follows is in the repo instructions: never use raw fetch for a mutation, because it will be missing the CSRF header and it will 403.

The requirements name it twice: the 5 MB document cap is justified by connectivity in Gaza, and the UI must be mobile-first and fully responsive. That cap is enforced at four layers, in the upload middleware, in the service, at Nginx with client_max_body_size set to 10M, and in the UI, with a separate 1 MB cap on signatures. Responses are gzipped. Sockets run over the polling transport rather than WebSocket upgrades, which costs bandwidth and buys reliability on restrictive networks, and that trade was made deliberately for this population. Export as a background job means no long-held connection on a flaky link, and profile validation returns every missing field at once instead of one per round trip.

The interface ships in English and Arabic. Both locale files live in the frontend behind a custom useTranslation hook with an English fallback and then a key fallback, and switching to Arabic sets the document direction to rtl and the language to ar, so the whole layout flips. The backend supports it at the data layer through an Arabic label on configuration options, so a query category renders in Arabic from the same row that drives its routing. One internal document still claims there is no frontend RTL. That document is stale and the code disagrees with it.

Three gaps here bother me more than anything else on the frontend, and all three are small, frontend-only and directly against the stated constraint. There is no form persistence: the onboarding profile covers identity, location, passport, emergency contact, English proficiency and consent, and losing the connection or the tab loses all of it. On an intermittent link that is the likeliest reason someone abandons onboarding. There is no upload progress, so a 5 MB passport scan on a slow link is indistinguishable from a frozen page and a user who re-submits doubles the transfer. And there is no client-side image compression, so a phone photo of a passport routinely exceeds 5 MB and the user waits through the whole upload to be rejected. There is also no offline support at all, no service worker and no cache API, which I would rather state plainly than dress up.

Testing & Observability

There are no automated tests in this project. Not few, not thin, none.

package.json has no test script and no test framework. The lint script is a TypeScript typecheck with no emit, which is a typecheck wearing a test's name, and the repo instructions say so outright: there is no automated test runner configured, lint means typecheck. I would rather open with that sentence than bury it, because everything else here follows from it.

The testing that happens is manual and at least written down: a 238-line document of curl and PowerShell smoke tests covering the main flows, and a Postman collection walking an end-to-end journey by hand. There are a few scripts with test in the name, but they are connectivity probes rather than assertions, checking that R2 credentials work or timing a few queries. None of them fail a build, and nothing runs in CI because there is no CI running tests to run them in.

TypeScript does real work here and I will give it credit. The Prisma include shapes are the types, so the payload type for an offer is derived from the exact include constant used to fetch it. Where the difference between including a passport number and not including it is a privacy boundary, having the compiler know which fields are in flight is a security property, not just ergonomics. But a typecheck only tells you the shape is right. It cannot tell you a regional admin cannot read another region's offer, and that is the thing this system exists to guarantee.

The authorization model has nineteen regional isolation enforcement points, eight authorization layers, three document-type allowlists, and a financial engine with eleven branch combinations. Every one of those is currently guaranteed by a person reading a diff. Nothing stops someone adding an endpoint that forgets its region filter. Nothing catches a refactor that reorders the branches in the document access chain and lets an assigned mentor through to a passport scan. In a system whose primary security property is data isolation, not having isolation tests is itself the security gap, and it is bigger than any individual finding, because it is the thing that would have caught the individual findings.

That is not hypothetical. Going through the codebase produced a list of real problems and almost every one is a test that was never written. The review lock on offers can be bypassed when no mentor is assigned yet, because the guard checks the mentor rather than only the status. An assigned mentor can approve an offer and edit its financial fields through the admin route, which is more authority than the RBAC docs describe. The chat retention cron matches on the wrong string and never deletes attachments, so the stated seven-day policy is not happening for files. The financial engine computes per-year figures where one reading of the requirements asks for totals. Each of those is an assertion someone could have written the day the feature shipped. The priorities were even written down already: both engineering documents say to test eligibility, consent, review locks, financial calculations, RBAC, regional isolation and uploads first. The order was correct. The tests were not written.

With one day, two things. The financial engine, because it is pure functions with no database and no HTTP, which makes it the cheapest suite in the project covering the highest-consequence logic in the product. A dozen cases: tuition covered but living costs not, a scholarship whose excess covers living costs, two and a half years rounding to two complete years, a one-time award spread across three years, a duration of zero not dividing by zero, and the worked examples from the requirements verbatim. That last one would have surfaced the per-year versus total divergence as a failing test rather than as something found later by reading.

And the regional isolation matrix, because it is the property everything else rests on. Seed two regions, two regional admins, a mentor, a master admin and two students, then assert a 403 or 404 on every cross-region path: reading, reviewing, assigning and deleting another region's offer, student, query, documents, volunteer assignment and announcement. Then assert the positive side, that an unfiltered list returns only that admin's own region. The most important single test in the set is requesting an export with no region filter and asserting the file contains only the caller's region, because that verifies the override rather than the rejection.

Beyond that: the eight-by-five document access matrix, one row per document type per role, each success also asserting an audit row was written. Then the state machines, with a deliberate regression test for the review lock hole. Then query routing and escalation. The tooling is not the hard part: vitest, an in-memory Mongo or a test database reset per suite, supertest against the Express app so the real authorization middleware runs, one seed helper building the full role matrix, and an action running lint and test on push. Two to three weeks for all of it, and the financial engine alone is two days.

Sentry is on both sides and it is the strongest signal available, with trace sampling at 5% in production. Access logs are morgan in combined format, unstructured. The database retry extension logs each retry, which turns out to be genuinely useful. Health endpoints exist for the app and the database and nothing monitors them. Everything else is console.log, so there are no metrics, no request IDs to correlate a Sentry error with an access log line, no alerting and no job dashboard.

One line captures the shape of it. Slow-query logging is enabled when the environment is not production, so it runs in development where nothing is slow and is off in production where it would tell you something. It was presumably disabled for log volume, and the right answer is a higher threshold, not silence. Current production performance is genuinely not measured: no timings, no APM numbers, no load testing. Everything I can say about performance is inference from reading queries, and I flag it as inference rather than presenting it as measurement.

The cheapest observability win here needs no new instrumentation. The audit log already answers questions most systems cannot: time from profile submission to decision, which admin reviews the most offers, how many documents each role downloads and of which types, every export with its scope and row count. It is a metrics store nobody queries. A handful of aggregations on the admin dashboard would surface the organization's real operational numbers, and the discipline that has to survive that work is the existing rule against logging personal data: user IDs in logs, never names, never emails, never document or message contents.

Debt & Next

The system is deployed and working, and there is a short list of things I would not want anyone to discover on their own.

All of it came from going through the codebase issue by issue and checking each claim against current code rather than against what the documents say. Sixteen of the twenty-three ledger items came from reading code. Seven came from reading documents. That ratio is the whole argument for treating code as the source of truth.

When a regional admin or reviewer account is created, the password is stored twice: once as a bcrypt hash, and once in plaintext in a column next to it. The API returns that plaintext field and the master admin UI displays it. The reason it exists is human and obvious, which is that someone needed to hand credentials to a new admin. The consequence is that the highest-privilege accounts in a system holding passport scans, national IDs, emergency contacts and locations in Gaza have their passwords sitting in cleartext, readable by anyone with database or master admin access. The fix is to delete the columns and issue invite tokens through the auth token table that already exists for password resets. It is not a redesign. It is the most serious item on this page and it stays at the top until it is gone.

The chat retention cron deletes messages older than seven days and is meant to delete their attachments from object storage too. It matches attachments on the wrong string, so it deletes nothing, and every chat attachment ever uploaded is still sitting in R2 orphaned. The stated retention policy is not being carried out for files, which is a storage cost and, more to the point, a promise the product is not keeping. The fix is to store the storage key on the message row instead of reconstructing it at deletion time.

Two more in the same family. Configuration changes are neither audited nor validated on write, so editing the financial rules changes the funding gap on every offer with no trace of who did it, and a malformed edit is a site-wide 500. And there is no stale-query detection: a support query nobody picks up simply sits there, and for a student that can cost an academic year. A daily job, an age column and a dashboard tile would cover it, and it is the most operationally significant thing missing rather than the most technically interesting.

Then a set of smaller security items that are each a few lines of work. Short-link codes for document access use Math.random rather than crypto.randomBytes. Those unauthenticated document paths write no audit entry, so a download through a link is invisible. A password reset does not invalidate existing sessions. Cookies use sameSite none in production when the deployment is now same-origin and lax would do. Uploads are validated on MIME type and extension, both supplied by the client, with no magic-byte check and no scanner, and the failed-scan status the schema already defines is unused.

The repository has a gaps document listing thirteen items, and five are stale in the direction that hurts: they describe problems already fixed. It says files are on local disk when storage is R2 and production refuses to boot without it. It says the email provider is Resend when the code uses SMTP through Nodemailer. It says CSRF was deferred when the header check is global. It says there is no frontend RTL when the locale files and the direction switch are both there. Reading it as a to-do list would send you backwards for a week. The fix is not more documentation. It is a superseded banner on the worst offenders, deletion of anything nobody will maintain, and the rule both instruction files already state, which is that code wins. A stale document is worse than a missing one, because it is confidently wrong.

Week 1    plaintext passwords. Then the review lock, the mentor admin-route edit,
          sameSite, crypto short codes, audited token downloads, chat attachments.
          All small, all security.
Week 2-3  tests: RBAC, regional isolation, financial engine. This is the real work.
Week 4    stale-query detection. Magic-byte upload validation. Config audit.
Week 5    compound indexes. Pagination on profiles and queries. JobRun records.
          Slow-query logging on in production with a sane threshold.
Week 6    form persistence. Upload progress. Client-side image compression.
Ongoing   confirm mentor authority, reviewer document scope and per-year vs
          total funding with the organization before changing any behaviour.

Weeks two and three are the important ones and they are the least visible. Everything else on that list is a diff. The tests are what make the diffs safe to apply. The last line matters too: three of these are not bugs, they are decisions. Changing a funding figure or narrowing a role's authority without asking the organization is worse than a documented divergence, because it silently changes what real people are told they can afford.

Current scale is not recorded anywhere, so this is analysis of the queries, not measurement. At a hundred students everything works as written. At a thousand, three things show: the unpaginated profile and query lists, and the dashboard funding-gap calculation that runs the financial engine over every approved offer on every admin page load. At ten thousand, several things break rather than slow: those lists, the offer summary that scans the whole filtered set per page, announcement fan-out doing two queries per recipient, and exports taking minutes while competing with API traffic in the same heap. That is query work plus moving the export runner into its own process, and the job model is already shaped for it.

The thing worth naming is that three pieces of code that are correct today become bugs the moment a second container starts. The boot recovery that resets stuck export jobs would kill the other instance's in-flight work. node-cron running in-process means every instance runs every job, so retention would run twice a night. And rate limiting and the region cache are in-memory, so they diverge per instance. None of that is wrong now. All of it has to be dealt with before anyone scales horizontally, and writing it down is cheaper than finding out.

Learnings

This project taught me less about algorithms than about consequence, and those are the lessons I have carried into everything since.

Building this with Hamza, who led the implementation, meant I spent most of my time on the parts between the features: the authorization model, the data shapes, the review flows, the frontend structure. Almost everything I took away came from those seams.

Software is workflows, not screens

The requirements described screens. The product turned out to be state machines with ownership and handoffs. The admin offers page is a table; the actual thing is an offer with a region deciding who reviews it, a lock stopping it changing mid-review, a revision record if it changes after approval, and a derived flag on the student's profile that has to be recomputed when an approval is reversed. Build the screen and you get a table. Build the workflow and the screen becomes obvious.

Regional access is not a filter on the user

The most transferable thing I learned. Every student here is in Gaza. Every case belongs to the UK, or Spain, or Turkey. The geography of the person and the geography of the case are different things, and scoping by the person hands everyone everything. Once I saw it I saw it everywhere: a customer in one country with orders shipping to three, a patient in one region treated at facilities in others. Getting it wrong is not a bug, it is a data breach.

Reject the mismatch and inject the default

The export scope check taught me the shape of a whole class of security bug. The obvious implementation rejects an admin who asks for another region. The bug is the admin who asks for no region at all and gets everything. Enforcement has to override as well as reject, and the test that matters is the one with an empty filter, not the one with a wrong filter. I write the empty-input case first now.

A document is a security boundary

Not a file. Every document here carries an owner, a type deciding who may see it, a storage key that must never become a public URL, an audit obligation on access, and a supersede-rather-overwrite history requirement. The set of types a mentor may view is four lines of code, and those four lines are the whole difference between a volunteer helping with a university application and a volunteer holding somebody's passport scan.

Long work belongs outside the request

The export requirement looked like one endpoint. It became a job model, a runner, cursor batching, a temp file, cancellation checks, an upload, an expiry, a cleanup cron and a boot recovery pass. All of that came from asking what happens when it takes four minutes and the container restarts at minute three. That question is what turned a handler into a system, and I ask it much earlier now.

Recovery is part of the feature

The lines I am fondest of here are the boot pass that flips every stuck job to failed with a message telling the user to retry. It is a handful of lines and it is the difference between a job system and one you can trust. It also assumes a single instance, and writing that assumption down next to the code was worth as much as the code, because unstated assumptions are how correct code quietly becomes a bug.

Log the shape, never the content

Around thirty actions are audited, and what makes the log safe is what it does not store. Whether notes were given, not the notes. Which fields changed, not the values. Which document type was downloaded, not the document. An audit log full of copied personal data is a second breach surface with none of the first one's access controls, and I would apply that to any logging or metrics work now without being asked.

Security decays at the edges

The parts of this system that are right, private storage, document allowlists, region scoping, audit logging, refusing to boot in production without object storage configured, were designed in from the start. The parts that are wrong, plaintext admin passwords, unaudited link downloads, an unscoped chat branch, were added later under time pressure, as shortcuts, some with comments admitting it. That pattern is exact, and it is the most useful thing I know about where to look first in a review.

Incomplete requirements are workable

The requirements document had a sentence that ended mid-clause and a section saying more would follow later. The response is not to wait and not to guess, it is to build the mechanism and let the policy arrive as data. Query categories proved it: routing is driven by configuration rows, three more categories arrived later, and the change was three rows rather than a deploy.

Operational simplicity is a feature

The engineering guidance said not to build a workflow engine, and it was right. The offer state machine is an enum and a handful of guard clauses. It fits on one screen, it is greppable, and someone new reads it in five minutes. A generic engine would have been more impressive to describe and would have made every later change harder. Boring and readable beats general almost every time, and I stopped treating that as a compromise.

Documentation rots faster than code

Five of thirteen items in this repo's gaps document describe problems already fixed. The only performance analysis is a careful study of the wrong database engine. The fix is not writing more documents, it is fewer documents kept closer to the code, deleted when superseded, plus an explicit rule that code wins. That rule is the only reason the stale ones are diagnosable rather than dangerous.

Tests are the thing you regret not having

Fifty-plus business invariants, nineteen regional isolation points, eight authorization layers, three document allowlists, eleven financial branches, all guaranteed by code review alone. Nearly every real problem I found, the review lock hole, the mentor authority divergence, the retention bug that never deletes attachments, the per-year funding question, would have been caught by a test written the day the feature shipped. I do not think about coverage as a percentage any more. I think about which invariants are guaranteed by nothing but attention.

The technical difficulty here was never algorithmic. What was hard is that a wrong access grant is a real exposure for a real person, an unresolved support query can cost someone an academic year, and a lost document is a passport scan that has to be produced again from a place where producing it is difficult. That changes how you write code. It is why the document authorization chain carries a comment explaining an implicit denial, and why the financial engine returns where every number it produced came from.