Project walkthrough
One realistic project followed from the first violation to the client's portal, naming for every step what the user does, which function runs, which tables are read and written, and which triggers fire. It is the page that ties data-model to what an operator sees, and it is written for developers who need to trace a behavior end to end.
The scenario
The scenario is illustrative; every table, column, function and trigger named below is real.
A tenant (call it the firm) tracks a five-story building for a client, Example Realty. HPD has issued two lead-paint orders against apartment 4B:
| Order | Meaning | Family |
|---|---|---|
| 616 | Lead-Based Paint (Presumed) | hazard |
| 617 | Lead Hazard (Positive) | hazard |
The owner decides to contest the 616 on lead content (XRF shows no lead-based paint) and to abate the 617, which HPD confirmed as positive and cannot be disproved. Each decision yields a track:
| Track | Governing order | Decision | Derived service | Field visits |
|---|---|---|---|---|
| A | 616 | contest, ground content_xrf | xrf_negative | one XRF visit |
| B | 617 | cure | abatement_dustwipe | an abatement visit and a separate, independent clearance dust-wipe visit |
The page follows track A end to end because it touches the most modules, then summarizes track B. Four people act: a project manager (Project Manager preset), an inspector (Inspector preset), a back-office user (Back Office preset) and the client's portal user.
Sequence overview
Step 0: what already exists
| Table | Rows the scenario assumes |
|---|---|
tenants, profiles, permission_bundles, permission_bundle_grants | The firm and its staff, each holding a preset role |
clients | Example Realty |
buildings, units | The building and apartment 4B, loaded by ingestion |
tenant_buildings | (tenant_id, building_id, client_id = Example Realty). Written earlier by trackBuilding and assignTrackedBuildingClient in src/data/tenantBuildings.ts |
public_events | Two HPD violations on the building with citation_code 616 and 617, status_norm = 'OPEN', a due_date, and unit_id for 4B |
licenses (+ documents) | The inspector's EPA license with its file, and the firm's EPA certification |
xrf_instruments, rate_cards, abatement_rates | The firm's gun and price lists |
Step 1: create the project from the violations
The project manager opens /projects/new (or the violation's detail page), picks the building and
ticks both orders.
| Runs | createProjectFromViolations in src/data/projects.ts (composer: useProjectComposer); the single-violation entry is createProjectFromViolation from /violations/:id |
| Permission | manage_projects (RLS insert policy) |
| Reads | tenant_buildings (tracked buildings), public_events (building_id, citation_code, status_norm, due_date) |
| Writes | projects (tenant_id, building_id, unit_id, origin = 'violation', project_reason, request_method, client_notes; phase defaults to intake); project_event_links (project_id, event_id), one row per order |
| Triggers | projects_check_scope (a set unit_id must belong to building_id); projects_log_initial_phase inserts the first project_phase_transitions row (from_phase null, to_phase = 'intake') |
Creating a project from an obligation instead also writes an obligations row with
status = 'project_created' and the new project_id. Event links are written only at creation;
no screen adds or removes them afterwards.
Step 2: Decide
The project manager opens the Decide tab and picks a path for each order.
| Runs | recordOrderDecision in src/data/projectOrderDecisions.ts; the tab derives its view with buildDecidedFormBundles from @complied/domain |
| Permission | manage_projects |
| Reads | project_event_links joined to public_events (listLinkedEvents); project_order_decisions for the project |
| Writes | project_order_decisions: order 616 gets path = 'contest', ground = 'content_xrf'; order 617 gets path = 'cure'. Each row carries event_id, decided_by, decided_at, optional rationale |
| Triggers | project_order_decisions_sync_tenant_id derives tenant_id from the project. The table has no UPDATE or DELETE policy: changing a decision inserts a new row with supersedes_decision_id set |
The current decision for an order is the latest decided_at per (project_id, event_id). From
the decisions the domain computes one bundle per track: the governing order, the orders it
covers, the derived service, and the document codes the chosen route requires
(requiredDocCodesByOrder).
| Track | Required document codes |
|---|---|
| A (616 contest on XRF) | CONTEST, NOV, INSP-RPT, INSP-EPA, XRF-AFF |
| B (617 cure by abatement) | CERT-H, ABAT-AFF, ABAT-EPA, WORK-DESC, DUST-LAB, SAMP-AFF, SAMP-EPA |
Nothing is stored for services or tracks: they are recomputed on every read. An order with no decision yet falls back to a cheapest-cure guess for display, but document generation refuses undecided orders.
Step 3: Work, add the visit and dispatch it
The Work tab shows one card per track with its field-visit tasks.
3a. Add the visit.
| Runs | addVisitForTask (src/pages/project-detail/work/addVisit.ts) calling createInspection and attachInspectionToEvent in src/data/inspections.ts |
| Permission | manage_inspections |
| Writes | inspections (project_id, service_type = 'xrf', is_clearance = false; status defaults to scheduled); inspection_events (inspection_id, event_id) for every event the track covers |
| Triggers | inspection_events_sync_owner derives tenant_id and project_id from the inspection; inspections_record_changes fires but writes nothing until a date or inspector is set |
The visit now matches track A: buildProjectTracks links a visit to a track when its linked event
ids intersect the track's covered events and service_type and is_clearance agree. A visit that
matches no track is listed as unmatched and never deleted.
3b. Outreach. Before a date exists, staff log attempts to reach the occupant or super.
| Runs | logContactAttempt, then updateInspection with outreachStatus (VisitDispatchPanel) |
| Writes | inspection_contact_attempts (contacted_party, method, attempted_at, logged_by); inspections.outreach_status |
| Triggers | inspection_contact_attempts_mark_contacting sets outreach_status = 'contacting' on the first attempt; inspections_record_changes appends an inspection_changes row for each dispatch field that changed |
3c. Schedule and assign.
| Runs | updateInspection (scheduledDate, assignedInspectorId, accessStatus); the picker calls listEligibleInspectorsForService |
| Permission | manage_inspections. Only this key may change assigned_inspector_id |
| Reads | licenses and license_types (staff holding an active, unexpired license of a type the service requires), profiles |
| Writes | inspections.scheduled_date, inspections.assigned_inspector_id |
| Triggers | inspections_check_dispatch_permissions (refuses a reassignment without manage_inspections); inspections_record_changes (scheduled_date, assigned_inspector_id) |
The visit's stage in the dispatch pipeline (Unscheduled, Contacting, Ready to schedule, Needs
inspector, Assigned, In progress, Completed) is computed by dispatchStage, not stored. Because
inspections.assigned_inspector_id is set, the inspector now sees the job and, through
my_inspection_ids(), its project, even without view_field_jobs or view_projects. The Timeline
tab reads project_phase_transitions, inspection_changes and inspection_contact_attempts plus
rows the page already holds.
Step 4: the field visit
The inspector opens /field (a list of inspections joined to their project, building and unit)
and then /field/:inspectionId. All writes need perform_field_work.
| Action | Function | Writes |
|---|---|---|
| Access gate | updateInspection (AccessGate) | inspections.access_status, status = 'in_progress'; inspection_changes by trigger |
| Rooms, checklist, notes, exclusions | src/data/inspections.ts | inspection_rooms (from the room_presets catalog), inspection_checklist_responses (unique per answer), inspection_notes, inspection_apartment_exclusions, inspection_room_exclusions (from xrf_exclusion_phrases) |
| Instrument | updateInspection | inspections.instrument_id from xrf_instruments |
| Photos | uploadXrfPhoto | xrf_inspection_photos (category, storage_path); object in field-photos under {project_id}/xrf/{inspection_id}/… |
| Floor plan | uploadFloorPlanSketch | floor_plans (status = 'sketch_uploaded', sketch_path); object in field-photos |
| CSV import | ingest-xrf-csv, mode preview then commit | Preview reads xrf_instruments, xrf_instrument_calibration_rules, inspections and writes nothing. Commit calls commit_xrf_ingest, which writes xrf_readings and xrf_report_data (canonical, parser_version) in one transaction, and moves a scheduled visit to in_progress |
| Edits to readings | src/data/xrfReadings.ts | xrf_readings; each edit appends xrf_edit_log |
A calibration failure (block cadence, sequence gap, time span) blocks the whole import: nothing is
written. A second import for the same inspection is refused with readings_exist unless the
caller passes replace: true, in which case the old readings are deleted and the new ones
inserted in the same transaction. xrf_readings is unique on (inspection_id, sequence_index).
Step 5: complete the visit and generate the report
When the inspector presses Complete, the wizard first runs loadFieldJobReadiness, which counts
what the service's capture contract needs (rooms, readings, an instrument, a floor plan,
chain of custody for lab services) and blocks with a list of what is missing. A no-access outcome
inverts the gate. Then it calls updateInspection with status = 'completed' and
completed_date, which appends an inspection_changes row, and starts generate-xrf-report
without waiting for it.
| Function | generate-xrf-report (supabase/functions/generate-xrf-report/index.ts); previewXrfReport calls the same function with preview: true, which renders without any write |
| Permission | perform_field_work (authorize) |
| Writes | documents twice through insertDocument (level = 'project', types INSP-RPT and XRF-AFF); Storage objects; xrf_report_versions (version_number, is_latest, supersedes, document_id); xrf_audit_findings (version_id); xrf_report_review (status = 'pending') |
Triggers on documents | documents_1_sync_anchors (tenant and building from the project), documents_2_check_type (is project an allowed level for this type), documents_3_share (no-op for an unshared row), documents_supersede (only when supersedes_id is set) |
| Failure | There is no fallback renderer: if the render service fails, the function answers 500 and no document is written (pdf-reports) |
INSP-RPT starts issued because its type has no signer; XRF-AFF starts draft because it
waits for the inspector's signature and a notary. A reviewer holding review_xrf_reports moves
xrf_report_review.status to passed_review, passed_final or flagged.
Step 6: File, generate the forms and slot the paperwork
The back-office user opens the File tab (needs view_documents). The checklist lists, per order,
the document codes from Step 2 and which are filled.
| Action | Function | Reads | Writes |
|---|---|---|---|
| Generate the HPD cover form | generateHpdDocument, edge function generate-hpd-document (manage_documents) | projects, tenants, project_event_links, project_order_decisions, tenant_branding; existing documents of that code and order | documents (CONTEST for track A, CERT-H for track B; draft, because the owner signs) and document_orders for every order the bundle covers. Refuses an undecided order (decision_required) |
| Link the report to its order | attachDocumentToOrder (drawer, "Orders covered") | document_types reuse policy via canAttachOrderToDocument | document_orders (document_id, order_number). The XRF generator does not link orders itself |
| Upload the NOV | uploadDocument then createDocumentWithFile | documents (NOV, draft), then the object in documents/{tenant_id}/{document_id}/…, then the row moves to issued; if the upload fails the draft row is deleted | |
| EPA credentials | credentialSlots (no write) | licenses, documents (CREDENTIAL) | Nothing: INSP-EPA is filled by the inspector's current license file |
| Sign | signDocument | documents.status = 'signed', signer_name, signed_at | |
| Notarize | notarizeDocument | documents.status = 'notarized', notary_name, notarized_at | |
| Share with the client | setClientVisible | tenant_buildings (through document_owner_client_id) | documents.client_visible = true; documents_3_share stamps shared_client_id and shared_at and requires manage_documents |
| Assemble a filing package | assembleFilingPackage, edge function assemble-filing-package (manage_documents) | projects, decisions, links, documents of affidavit types | Ensures each decided, unconflicted bundle has its cover document (undecided bundles are returned as unresolvedBundles), then inserts filing_packages (status defaults to assembled) and filing_package_documents for those cover documents and every non-voided XRF-AFF and PC-AFF of the project |
The checklist ignores voided and superseded documents. The reuse policy of a type
(shared, union, per_track, fresh_per_filing, never_reused) decides whether one document
may answer a second order. See documents for levels, sharing, versions and void.
Assembling a package records which documents were on file at that moment; it does not merge a PDF
and does not file anything with HPD.
Step 7: Money
Proposal. Staff review the derived services and prices on the Work tab's Proposal card.
| Runs | saveProposal, then generateSavedProposalPdf (edge function generate-proposal-pdf, manage_proposals plus owner-tenant check) |
| Reads | rate_cards, abatement_rates, abatement_components, decisions and links (for the derived services) |
| Writes | proposals (status = 'draft', subtotal, total, valid_until) and proposal_line_items; then documents (PROPOSAL, draft until the client signs) and proposals.document_id |
proposals.status is written as draft; nothing in the app moves it.
Invoice. After the work, back office generates the invoice.
| Runs | generateInvoicePdf, edge function generate-invoice-pdf (manage_invoices plus owner-tenant check) |
| Reads | rate_cards, abatement_rates, and what was captured: xrf_readings (non-calibration), dust_wipe_samples, paint_chip_samples, abatement_components; tenants, buildings, tenant_branding |
| Writes | RPC allocate_invoice_number (updates invoice_number_counters, returns {SLUG}-{year}-{nnnn}); invoices (status = 'draft', subtotal, total); invoice_line_items; documents (INVOICE, issued); invoices.document_id |
The invoice is priced from captured work and the price lists, not from the saved proposal. A
service with no rate card contributes no line, so an invoice can total $0 and still return success.
markInvoiceSent sets status = 'sent' and sent_at; the invoices_stamp_billed_client
trigger then stamps billed_client_id from the building's client. markInvoicePaid sets
status = 'paid' and paid_at. Payments out to labs and subcontractors are vendor_payments
rows (createVendorPayment, markVendorPaymentPaid), which are never portal-visible.
The header's phase dropdown (setProjectPhase, advance_projects) can move projects.phase to
docs_qa, billing or closed at any point; the projects_log_phase_transition trigger appends
project_phase_transitions. No step above reads it.
Step 8: the client's portal
A portal login exists as a client_users row for Example Realty; AuthProvider finds no
profiles row and loads the portal context.
| Portal page | Function | Table and the rule that filters it |
|---|---|---|
/portal/buildings | listTrackedBuildings | tenant_buildings rows whose client_id is the login's client (active client_users row) |
/portal/projects/:id | getProject, listProjectsForBuilding | projects whose building is one of those |
/portal/documents and the project page | listDocumentsForPortal | document_list (security_invoker): only rows with client_visible, shared_client_id = current_client_id(), past draft, not voided and not financial; or the PDF of a sent invoice billed to this client |
/portal/invoices | listMyInvoices | invoices that are not draft and are billed to this client |
| Opening a file | getPortalSignedDocumentUrl | Storage createSignedUrl on documents, valid 60 seconds; the bucket policy only allows objects whose documents row the caller can read |
The client sees the shared INSP-RPT and the CONTEST form once each is past draft and shared,
and the invoice once it is sent. It never sees the XRF-AFF draft, the PROPOSAL, vendor
payments, inspections or rate cards.
Track B in brief
The 617 cure runs the same pipeline with different capture tables.
| Step | Tables |
|---|---|
| Two visits added | inspections (service_type = 'abatement' and service_type = 'dust_wipe' with is_clearance = true), each with inspection_events for order 617 |
| Abatement capture | abatement_components (scope, method, footage, completion, clearance_passed), abatement_crew (with license details), abatement_time_entries, abatement_materials, abatement_safety_checks, abatement_component_photos. Needs perform_abatement_work; crew membership also scopes the job to the worker |
| Clearance sampling | dust_wipe_samples (lab results entered per sample), lab_chain_of_custody (via createChainOfCustody), laboratory_partners |
| Documents | generate-coc writes COC and sets lab_chain_of_custody.document_id; generate-dust-wipe-report writes DUST-LAB; generate-hpd-document writes CERT-H |
| Independence | clearanceIndependenceWarning compares who abated with who sampled and warns; it never blocks |
generate-abatement-report (writes ABATE-RPT) exists as an edge function; the app has no screen
that calls it. If the clearance visit is done by another tenant, that tenant is invited through
project_collaborators with role clearance_sampling or lab_coordination, and it never reaches
the money tables (permissions).
Where to look in code
| Concern | Location |
|---|---|
| Create, phase and links | src/data/projects.ts |
| Decisions and derivation | src/data/projectOrderDecisions.ts, domain/src/programs/nyc-lead-paint/decidedFormBundles.ts, domain/src/field/serviceRequirements.ts |
| Tracks and dispatch | src/pages/project-detail/work/, domain/src/field/dispatchStage.ts |
| Visits and capture | src/data/inspections.ts, xrfReadings.ts, samples.ts, chainOfCustody.ts, abatementWork.ts |
| XRF import and report | supabase/functions/ingest-xrf-csv/, supabase/functions/generate-xrf-report/ |
| Documents | src/data/documents.ts, supabase/functions/_shared/documents.ts, _shared/hpdDocuments.ts |
| Money | src/data/proposals.ts, invoices.ts, supabase/functions/generate-proposal-pdf/, generate-invoice-pdf/ |
| Portal | src/pages/portal/, src/data/portalInvoices.ts |