// case study · architecture & design deep dive
Driving Headquarters Platform
How a multi-location driving school's marketing site grew into a single-codebase web + mobile platform — covering authorization, payments, an email-driven document pipeline, a live GPS lesson tracker, and a safe database-operations workflow against a live production Postgres.
01Context & goals
The business started with a WordPress + Elementor brochure site. As it grew into a real driving school across Richmond Hill, Markham and Vaughan, the operational load — enrolments, payments, document collection, lesson scheduling — outgrew plugins and spreadsheets.
The mandate was to replace a patchwork of WordPress, manual email, and spreadsheet bookkeeping with one system that the public, students, parents, instructors and office staff could all use — without paying per-seat for an off-the-shelf LMS that still wouldn't model Ontario's BDE (Beginner Driver Education) and MTO certification workflows.
Constraints that shaped the design
- One developer, one codebase. No room for a separate mobile app team or a microservice fleet — everything had to live in a single deployable that one person can reason about and ship safely.
- Real money, real PII. Credit-card payments and government ID documents meant security and auditability couldn't be bolted on later.
- Live production from day one. The cutover happened against a running business, so schema changes had to be safe and reversible.
- Field use. Instructors record routes from a moving car on Android phones with patchy signal — the mobile experience had to survive offline.
02Inside the product
A tour of the running platform — the admin operations console and the student-facing app. All student, parent and instructor PII (names, emails, driver-licence numbers and phone numbers) has been irreversibly redacted from these screenshots.












// redacted regions are masked, not blurred — the underlying pixels are destroyed.
03System architecture
A single Nuxt 4 application serves the public marketing site, all four authenticated dashboards, and the entire server API. It is wrapped by Capacitor 6 to ship as an Android app, and backed by Supabase for database, auth, storage and realtime.
// architecture.svg — request flow
Frontend
Vue 3 + Nuxt 4, Tailwind v4, Nuxt UI. Server-rendered marketing pages for SEO; client-hydrated dashboards behind auth. Shipped as a PWA and, via Capacitor 6, an Android APK from the same source.
Server
Nitro server routes (server/api/**) provide the REST layer. Server middleware adds security headers and CSRF. Shared server/utils hold auth guards, the ledger, the ingest pipeline, rate limiting and more.
Data
Supabase Postgres is the system of record. Row-Level Security is the last line of defence; an immutable transaction ledger is the financial source of truth. Storage holds avatars and student documents.
Integrations
Moneris Hosted Checkout (payments), Resend (transactional + inbound email), IMAP via imapflow, Leaflet + CARTO tiles (maps), reCAPTCHA v3, and Capacitor background-geolocation.
One Nuxt app means one auth model, one type system end-to-end, one deploy, and one place to enforce a security rule. The mobile app is the same code wrapped by Capacitor — no second API, no contract drift between web and native. For a solo maintainer this is the difference between shippable and not.
04Three-layer authorization
Access is enforced independently at three layers, so that a bug or bypass in one does not grant access. The guiding rule: even a leaked service key shouldn't be able to read another user's data through the front door.
app/middleware/{auth,admin,…}.ts) checks the Supabase session and the user's role before a protected page renders — fast UX, redirects the unauthenticated to /login. Treated as convenience, never as the security boundary.requireAuth / requireRole guards run inside every Nitro route. They resolve the user from the session, load the profile, reject archived/soft-deleted accounts, and hand back both a service client and an RLS-scoped client.The server guard does the real work
Every protected endpoint opens with one call. The guard verifies the session, loads the profile with the service role, and — critically — refuses tokens belonging to accounts that have since been archived or self-deleted, closing the race where a still-valid JWT outlives the account:
// server/utils/require-auth.ts
export async function requireAdmin(event) {
const ctx = await resolveAuth(event) // 401 if no valid session
if (ctx.profile.role !== 'ADMIN')
throw createError({ statusCode: 403, statusMessage: 'Forbidden: Admins only' })
return ctx
}
// resolveAuth rejects archived / soft-deleted profiles even when the
// session token is still technically valid:
if (rawRole === 'archived' || profile.archived || profile.deleted_at)
throw createError({ statusCode: 403, statusMessage: 'Account is no longer active' })The guard returns both a service client (bypasses RLS, for legitimate cross-user admin work) and a userClient (respects RLS). Endpoints default to the RLS-scoped client; the service client is reserved for operations that genuinely need it, keeping the powerful key out of the common path.
05Payments & financial ledger
Payments run through Moneris Hosted Checkout, so raw card data never touches the server. The hard part isn't taking the payment — it's making the money provably correct afterward.
Never trust the client about money
After Moneris returns a checkout ticket, the server performs a server-to-server receipt verification and sources the amount exclusively from the Moneris receipt — the client-reported status and amount are ignored entirely:
// server/api/portal/payment-verify.post.ts
if (data.response?.success === 'true' && receipt?.result === 'a') {
// Amount comes ONLY from the verified receipt, never the request body.
const amount = Number(receipt.cc?.amount)
if (!Number.isFinite(amount) || amount <= 0)
throw createError({ statusCode: 502, statusMessage: 'Receipt missing an amount' })
// ...IDOR ownership check, then write to the ledger.
}Defences layered onto every payment write
- IDOR ownership guard. Before writing, the endpoint confirms the registration belongs to the authenticated user — a payment can't be applied to someone else's account by guessing an ID.
- Idempotency keys. A duplicate confirmation (double-click, retry, network replay) returns the cached result instead of recording the payment twice; a unique index on the Moneris ticket is the backstop.
- Ledger as source of truth. Every payment is an immutable row in
lms_student_transactions. The enrollment's outstanding balance is derived from the ledger, never edited in place — so the books always reconcile. - Rate limiting on the verify endpoint to blunt receipt-probing.
Enrolment creates a charge transaction (package price + HST stored separately as tax_rate/tax_amount); each payment is its own payment row. Balance = sum of the ledger. This models partial payments, installment plans, and e-Transfer reconciliation cleanly, and gives the admin financial dashboard an audit trail it can trust.
Beyond cards
The same ledger backs payment-plan installments (paying one installment marks it paid and completes the plan when the last one clears) and an e-Transfer claim/reconciliation workflow for customers who pay by Interac instead of card.
06Document-ingest pipeline
Driving schools drown in forwarded paperwork: BDE receipts, MTO certificates, driver licences, payment confirmations. This pipeline turns a forwarded email into a document attached to the right student — with a human in the loop.
Two transports, one pipeline
Email arrives via either an IMAP poller (imapflow, on a cron) or a Resend inbound webhook. Each transport's only job is to produce a NormalisedEmail; everything downstream — idempotency, batching, extraction, matching, dedup, logging, notification — lives in one shared module so the two paths can't diverge.
Message-ID is never reprocessed — but it's still logged as a duplicate so replays show up in the inbox instead of vanishing silently.For most receipts the PDF is a formality — the real content is in the email body. Treating the body as a virtual .txt attachment means the specialised Trubicars / Interac / Moneris extractors get a crack at it through the exact same code path as a real file, with no special-casing.
Auto-promotion was built and then deliberately disabled. Misfiling a government ID to the wrong student is a privacy incident; the cost of a one-click admin confirmation is trivial by comparison. The matcher does the heavy lifting and ranks candidates — the human just approves.
07Live GPS trip tracker
Instructors record the actual route of a lesson from their phone; students and admins replay it on a map afterward. The challenge is capturing GPS reliably from a moving car with the screen off and intermittent signal.
Two runtimes from one composable
- PWA (browser):
navigator.geolocation.watchPositionplus a Wake Lock. Works with the screen on, but the OS throttles a backgrounded tab. - Capacitor native (Android APK):
@capacitor-community/background-geolocationruns a true Android foreground service, so tracking continues with the screen off and the app backgrounded — the real field configuration.
Every GPS fix is appended to an IndexedDB pending-queue before any network flush, then deleted only after the server confirms receipt. If the page dies — browser crash, OS kill, force-close — the next session finds the stranded fixes and replays them before starting a new trip. No lesson route is lost to a dropped connection.
Fixes are flushed in batches (every 20s or 100 points), with per-segment distance from a haversine calculation and stall detection. Playback streams over Supabase Realtime onto a Leaflet map with CARTO basemap tiles (OSM as fallback) — so a parent can watch the route fill in live, or review it later.
08Hardened public surface
The marketing site and auth endpoints are the exposed attack surface. Defence is layered here too — headers, CSRF, rate limiting, and graduated bot defence.
Content Security Policy with nonce + strict-dynamic
nuxt-security emits a per-request nonce and strict-dynamic CSP, so any script the app didn't explicitly load won't execute — even if attacker-controlled content lands in the DOM. The allow-lists are scoped tightly: Moneris and reCAPTCHA for scripts/frames, Supabase and the map tile CDNs for connections and images, and nothing else.
// nuxt.config.ts — script-src
'script-src': [
"'self'", "'nonce-'", "'strict-dynamic'",
'https://gateway.moneris.com', 'https://www.google.com/recaptcha/',
]Defence in depth around forms & mutations
- Custom CSRF (
server/middleware/02-csrf.ts): a double-submit cookie pattern with a constant-time token comparison, enforced on every mutating method and bypassed only for the Moneris webhook. - Rate limiting (
server/utils/rate-limit.ts): IP- and session-scoped limits on sensitive endpoints, owned in-app rather than delegated to the module. - Graduated bot defence on public forms: reCAPTCHA v3 (server-verified secret), a honeypot field, and dwell-time analysis — escalating friction only for traffic that looks automated.
- Strict transport & framing: HSTS with preload,
X-Frame-Options: SAMEORIGIN,nosniff, and a locked-down Permissions-Policy.
Cross-origin isolation (COOP/COEP) is deliberately left loose: Moneris checkout posts messages back to our window, and tighter isolation breaks that handshake. Security work is only finished when the payment flow still works — the config documents exactly why each relaxation exists.
09Safe database operations
Schema changes against a live business database are where solo projects usually get hurt. The workflow here is built so a migration can be written, tested against real-shaped data, and rolled forward without guesswork.
- 60+ versioned SQL migrations under the Supabase CLI workflow —
supabase/migrations/is the single source of truth, with the current production schema baselined into it. - Sandboxed local Postgres. An
env-switchflow flips the whole app between CLOUD (real Supabase + real third-party APIs) and SANDBOX (local Supabase in Docker, third-party APIs disabled), with a safety rail that refuses cloud commands while in sandbox mode. - Playwright QA harness. A
qa-testpass runs a full sandboxed regression — build health, public pages, auth flows, role gating, RLS/GRANT checks, and payment/registration API smoke — and refuses to run in cloud mode, so a test pass can never touch production data. - Least-privilege grants. Recent migrations explicitly revoke
anonandauthenticatedaccess on admin-only tables and views, so RLS isn't the only thing standing between a token and a sensitive table.
Because sandbox mode is one command away and QA refuses to run against prod, the low-friction option is also the safe one. That's what keeps a one-person project from shipping a Friday-night migration straight into the live ledger.
10Key design decisions, condensed
Every choice below traded simplicity or speed for a property the business actually needed — auditability, safety, or solo-maintainability.
Single Nuxt codebase
One auth model, one type system, one deploy; the Android app is the same code via Capacitor. Cost: the bundle does everything. Worth it for a solo maintainer.
Authz in three layers
Client UX + server guards + Postgres RLS. Redundant on purpose — no single bypass grants data access.
Immutable ledger
Money is append-only; balances are derived. Slightly more code, but the books always reconcile and there's a real audit trail.
Human-in-the-loop ingest
The matcher ranks; a human confirms. Trades a click for never misfiling a government ID to the wrong person.
Offline-first GPS
Persist fixes to IndexedDB before flushing; replay on crash. More moving parts, zero lost routes.
Sandbox-by-default ops
The safe path (local Postgres, QA that refuses prod) is the easy path, so it's the one that gets used.
11Stack & takeaways
Full stack
What I'd carry forward
The biggest lever wasn't any single feature — it was making the safe path the default path: layered authz so a mistake fails closed, an immutable ledger so the money is always auditable, and a sandbox/QA workflow so schema changes can't quietly hit production. For a one-person team running real payments and PII, those guardrails are what make moving fast survivable.