Skip to main content

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:

JobWhat it doesMain data
DecidePick a resolution path (cure, contest, postpone, dismiss) for each linked HPD orderproject_order_decisions
WorkDerive the field tracks from those decisions, schedule visits, capture datainspections, inspection_events, capture tables
FileGenerate and slot the paperwork, assemble a filing packagedocuments, document_orders, filing_packages
MoneyPrice, propose, invoice, pay vendorsproposals, 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​

ModuleWhat it isWhy it exists
Staff shell<RequireAuth><AppLayout>: buildings, clients, projects, field jobs, documents, violations, deadlines, map, settings, teamEverything 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 functionsThe only place the browser talks to Supabase tables and RPCs
src/auth/AuthProvider, useAuth, usePermission, routeAccess.ts, session helpersThe 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, notificationsOne implementation of every rule, imported by the browser and by the edge functions
Edge functionsDeno functions in supabase/functions/I/O that cannot run in the browser: PDF generation, CSV ingest, email, sync, staff invites
PostgresSchema, RLS policies, triggers, RPCs, pg_cronThe enforcement layer: every read and write is checked here
Ingestionsync-orchestrator plus domain/src/ingestion/Loads NYC agency data into public_events
map-service/DuckDB over a nightly Parquet snapshot, deployed separatelyAnswers the /map query fast at citywide scale
Render serviceHeadless-browser HTML-to-PDF endpointReport 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​

LayerRunsTrust
React appBrowserUntrusted. Holds the anon key and the user's JWT
PostgREST + RLSSupabaseThe boundary. Evaluates every policy per caller
Edge functionsSupabase (Deno)Trusted; service role, so each does its own authorize()
Postgres triggers and RPCsSupabaseTrusted. Derive tenant ids, stamp history, guard column changes
pg_cronSupabaseSchedules the nightly public-data sync and the email outbox drain
map-serviceAWS LambdaReads a snapshot of public rows; asks Postgres for the caller's own tenant overlay using the caller's JWT
Render serviceCloudflare Browser Rendering or a compatible endpointReceives HTML, returns PDF bytes. No database access

What lives where​

PathWhat
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
TopicPage
Tables, by moduledata-model.md
Diagrams of how data movesdata-flows.md
One project traced through every tableproject-walkthrough.md
Tenancy, RLS and permission keyspermissions.md
Documents: levels, types, sharing, versionsdocuments.md
Public-data ingestioningestion.md
The mapmap.md
PDF report generationpdf-reports.md
Local setup, deployment, conventionslocal-development, deployment, conventions