Architecture overview
How Complied is put together: the modules, the seams between them, and where each piece of the system lives in the repository. Read this first if you are new to the codebase; the other architecture pages go deeper on one area each.
What the system is
Complied is a multi-tenant lead-paint compliance system. A tenant is a white-label company
(Secure Environmental Group, Abated NYC) with its own staff, clients, buildings, projects,
documents, invoices and branding. Tenants share one Postgres database; isolation is row-level
security on tenant_id, not a database per customer. A client is a property owner or
managing agent of a tenant and signs in to a separate portal.
The product turns a trigger (an HPD violation, a recurring obligation, an occupant request) into a project and carries it through four jobs:
| Job | What it does | Main data |
|---|---|---|
| Decide | Pick a resolution path (cure, contest, postpone, dismiss) for each linked HPD order | project_order_decisions |
| Work | Derive the field tracks from those decisions, schedule visits, capture data | inspections, inspection_events, capture tables |
| File | Generate and slot the paperwork, assemble a filing package | documents, document_orders, filing_packages |
| Money | Price, propose, invoice, pay vendors | proposals, invoices, vendor_payments, rate_cards |
All four are always reachable from a project. projects.phase (intake, scheduling, field,
docs_qa, billing, closed) is an operator-set label: nothing in the app reads it to decide
what a user may do, and nothing advances it automatically. side_state (blocked, on_hold,
cancelled) is independent of phase. Changing either is a plain update guarded by the
advance_projects permission; the projects_log_phase_transition trigger records history in
project_phase_transitions.
Version 1 ships one compliance program, nyc-lead-paint (HPD primary, DOHMH participating).
Data from other agencies is ingested for intelligence about a building and does not become
billable workflow.
Module map
| Module | What it is | Why it exists |
|---|---|---|
| Staff shell | <RequireAuth><AppLayout>: buildings, clients, projects, field jobs, documents, violations, deadlines, map, settings, team | Everything a tenant employee does |
| Portal shell | <RequirePortalAuth><PortalLayout> under /portal/* | A client's read-only view of its own buildings, projects, documents and invoices |
src/data/ | Roughly 45 flat per-entity files of small typed async functions | The only place the browser talks to Supabase tables and RPCs |
src/auth/ | AuthProvider, useAuth, usePermission, routeAccess.ts, session helpers | The only wrapper over supabase.auth.*; decides staff vs portal context |
domain/ (@complied/domain) | Pure TypeScript: HPD rulebook, decisions, derived services, XRF determination, document catalog, ingestion feeds, branding, notifications | One implementation of every rule, imported by the browser and by the edge functions |
| Edge functions | Deno functions in supabase/functions/ | I/O that cannot run in the browser: PDF generation, CSV ingest, email, sync, staff invites |
| Postgres | Schema, RLS policies, triggers, RPCs, pg_cron | The enforcement layer: every read and write is checked here |
| Ingestion | sync-orchestrator plus domain/src/ingestion/ | Loads NYC agency data into public_events |
map-service/ | DuckDB over a nightly Parquet snapshot, deployed separately | Answers the /map query fast at citywide scale |
| Render service | Headless-browser HTML-to-PDF endpoint | Report design needs a real browser, which an edge function cannot host |
There is no separate application server. The browser talks straight to Supabase; the edge functions and the database carry the server-side logic.
The seams
Four boundaries are enforced by tooling rather than habit.
1. Data access goes through src/data/. src/data/supabaseClient.ts is the only
createClient() call site (anon key only; the service-role key never reaches the browser).
eslint.config.js fails the build for any file outside src/data/** and
src/auth/** that imports supabaseClient.ts (import/no-restricted-paths) or calls
.from(...) / .rpc(...) (a no-restricted-syntax backstop that catches a client reference
smuggled in another way). functions.invoke(...) calls also live in src/data/ by convention.
The reason: pages stay free of query details, and the data layer can be replaced (for
example by a dedicated API) without touching a component.
2. Auth goes through src/auth/. Only that directory calls supabase.auth.*, and it makes the
few identity lookups (profiles, client_users, my_permission_keys()) that decide who the user
is. AuthProvider picks the staff or portal context: a profiles row means staff, a client_users
row means portal.
3. @complied/domain is pure. domain/src/** does no I/O and imports nothing from Supabase. It
is not an npm package: @complied/domain is a path alias to domain/src/index.ts
(vite.config.ts, tsconfig.json, tsconfig.app.json). Edge functions import the same files by
relative path, which is why anything a function needs must live in domain/ and not under
src/pages/. The reason: the rule "does this bundle honour the owner's decision" has exactly one
implementation.
4. Row-level security is the boundary. UI permission gates (usePermission,
routeAccess.ts) only hide what the database would refuse. Every policy on a tenant table checks
tenant_id = current_tenant_id() and a permission key. See permissions.
Edge functions that run as the service role bypass RLS, so each one calls authorize(key) from
supabase/functions/_shared/auth.ts before doing any work, and money-touching functions also
check callerIsOwnerTenant.
Runtime layers
| Layer | Runs | Trust |
|---|---|---|
| React app | Browser | Untrusted. Holds the anon key and the user's JWT |
| PostgREST + RLS | Supabase | The boundary. Evaluates every policy per caller |
| Edge functions | Supabase (Deno) | Trusted; service role, so each does its own authorize() |
| Postgres triggers and RPCs | Supabase | Trusted. Derive tenant ids, stamp history, guard column changes |
pg_cron | Supabase | Schedules the nightly public-data sync and the email outbox drain |
map-service | AWS Lambda | Reads a snapshot of public rows; asks Postgres for the caller's own tenant overlay using the caller's JWT |
| Render service | Cloudflare Browser Rendering or a compatible endpoint | Receives HTML, returns PDF bytes. No database access |
What lives where
| Path | What |
|---|---|
src/ | The React app: two shells, pages/, components/, data/, auth/, lib/ (display and format helpers) |
domain/ | @complied/domain: programs/nyc-lead-paint/ (rulebook), field/ (services, XRF, abatement), docs/ (document catalog and rules), ingestion/, licensing/, collaboration/, notifications/, branding/, jurisdictions/, phases/ (labels only) |
supabase/migrations/ | Schema, RLS, triggers, RPCs, cron. The latest migration touching an object wins |
supabase/functions/ | 19 edge functions plus _shared/ (auth, cors, documents, branding, report HTML builders, PDF writer) |
db-tests/ | Regression suite against a local Postgres: cross-tenant leakage, permissions matrix, documents, ingestion |
map-service/ | The /map query service. See map |
scripts/ | Dev stack, CI guardrails, seeding, one-off maintenance. Not application code |
website/ | The Docusaurus documentation site, built from docs/ |
docs/ | This documentation. REQUIREMENTS.md at the repo root holds the locked decisions |
archive/ | The previous application, kept for reference. Not built, linted or shipped |
Where to read next
| Topic | Page |
|---|---|
| Tables, by module | data-model.md |
| Diagrams of how data moves | data-flows.md |
| One project traced through every table | project-walkthrough.md |
| Tenancy, RLS and permission keys | permissions.md |
| Documents: levels, types, sharing, versions | documents.md |
| Public-data ingestion | ingestion.md |
| The map | map.md |
| PDF report generation | pdf-reports.md |
| Local setup, deployment, conventions | local-development, deployment, conventions |