Skip to main content

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:

OrderMeaningFamily
616Lead-Based Paint (Presumed)hazard
617Lead 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:

TrackOrderDecisionDerived serviceField visits
A616contest, ground content_xrfxrf_negativeone XRF visit
B617cureabatement_dustwipean 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​

TableRows the scenario assumes
tenants, profiles, permission_bundles, permission_bundle_grantsThe firm and its staff, each holding a preset role
clientsExample Realty
buildings, unitsThe 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_eventsTwo 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_ratesThe 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.

RunscreateProjectsFromViolations in src/data/projects.ts, which calls the create_projects_from_violations RPC (composer: useProjectComposer, grouping in projectScope.ts); the single-violation entry from /violations/:id and the violations list is startProjectForViolation, through the same RPC
Permissionmanage_projects (RLS insert policy)
Readstenant_buildings (tracked buildings), public_events (building_id, citation_code, status_norm, due_date, and the computed project_scope), live projects with their project_event_links (to grey out violations already in one)
WritesOne projects row per project_scope among the ticked violations (tenant_id, building_id, unit_id = the scope, origin = 'violation', project_reason, request_method, client_notes; phase defaults to intake), and its project_event_links (project_id, event_id), one row per order, in one transaction. Here both orders are 4B's, so one project. The RPC refuses (project_already_linked, 23505, the holding project's id in DETAIL) if a violation is in a live project of the tenant already
Triggersprojects_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, and the form asks for the apartment or the building instead of violations. Orders can be linked afterwards with Link HPD orders on Proposal & scope, which offers only orders whose project_scope is the project's unit_id (listLinkableViolations).

Step 2: Decide (Proposal & scope)​

The project manager opens the project's Proposal & scope tab (?tab=proposal; the old ?view=decide redirects there) and picks a path for each order. The project page's tabs, their order and the old-name mapping live in src/pages/project-detail/lifecycleTabs.ts.

RunsrecordOrderDecision in src/data/projectOrderDecisions.ts; the page derives its view with planProject from @complied/domain, over loadPlanInputs (project_plan_inputs)
Permissionmanage_projects
Readsproject_event_links joined to public_events (listLinkedEvents); project_order_decisions for the project
Writesproject_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
Triggersproject_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 a violation is the latest decided_at per (project_id, event_id). From the decisions the domain plans each violation (its state, service, HPD form and the document codes the chosen route requires) and groups them into tracks, one per service, each split into filing groups, one per HPD form.

TrackRequired 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. A violation with no decision yet (or one whose saved path no longer counts) falls back to a cheapest-cure estimate for display, but document generation refuses undecided violations. That estimate applies the blocker rules (a 622 while a 617 is open, a 625 while a 624 is open) to the open lead orders in the project's apartment, linked or not (project_plan_inputs.open_orders_in_scope), so an unlinked open 617 still moves a 622 to abatement and its estimate. The edge functions read the same inputs through _shared/projectPlan.ts, so the page and the server plan alike.

Step 3: Field work, add the visit and dispatch it​

The project's Field work tab (?tab=field, was ?view=work) shows one card per track with its field-visit tasks. The Schedule tab (?tab=schedule) lists the same visits by dispatch stage with their logged outreach; it reads only, and the writes below all happen from Field work's visit drawer.

3a. Add the visit.

RunsaddVisitForTask (src/pages/project-detail/work/addVisit.ts) calling createInspection and attachInspectionToEvent in src/data/inspections.ts
Permissionmanage_inspections
Writesinspections (project_id, service_type = 'xrf', is_clearance = false; status defaults to scheduled); inspection_events (inspection_id, event_id) for every event the track covers
Triggersinspection_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 counts toward track A: buildWorkTracks (work/projectTracks.ts) puts a visit under every track whose service and clearance flag match (tracksForVisit), whatever violations it was linked to. A visit no track needs is listed under other visits and never deleted.

3b. Outreach. Before a date exists, staff log attempts to reach the occupant or super.

RunslogContactAttempt, then updateInspection with outreachStatus (VisitDispatchPanel)
Writesinspection_contact_attempts (contacted_party, method, attempted_at, logged_by); inspections.outreach_status
Triggersinspection_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.

RunsupdateInspection (scheduledDate, assignedInspectorId, accessStatus); the picker calls listEligibleInspectorsForService
Permissionmanage_inspections. Only this key may change assigned_inspector_id
Readslicenses and license_types (staff holding an active, unexpired license of a type the service requires), profiles
Writesinspections.scheduled_date, inspections.assigned_inspector_id
Triggersinspections_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 Activity tab (was Timeline) reads project_phase_transitions, inspection_changes and inspection_contact_attempts plus rows the page already holds.

Step 4: the field visit​

The inspector opens the Field work tab of Projects (/projects?tab=field, a list of inspections joined to their project, building and unit) and then /field/:inspectionId. All writes need perform_field_work.

ActionFunctionWrites
Access gateupdateInspection (AccessGate)inspections.access_status, status = 'in_progress'; inspection_changes by trigger
Rooms, checklist, notes, exclusionssrc/data/inspections.tsinspection_rooms (from the room_presets catalog), inspection_checklist_responses (unique per answer), inspection_notes, inspection_apartment_exclusions, inspection_room_exclusions (from xrf_exclusion_phrases)
InstrumentupdateInspectioninspections.instrument_id from xrf_instruments
PhotosuploadXrfPhotoxrf_inspection_photos (category, storage_path); object in field-photos under {project_id}/xrf/{inspection_id}/…
Floor planuploadFloorPlanSketchfloor_plans (status = 'sketch_uploaded', sketch_path); object in field-photos
CSV importingest-xrf-csv, mode preview then commitPreview 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 readingssrc/data/xrfReadings.tsxrf_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.

Functiongenerate-xrf-report (supabase/functions/generate-xrf-report/index.ts); previewXrfReport calls the same function with preview: true, which renders without any write
Permissionperform_field_work (authorize)
Writesdocuments 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 documentsdocuments_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)
FailureThere 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 Documents & filing tab (?tab=documents, was ?view=file; needs view_documents). The checklist lists, per order, the document codes from Step 2 and which are filled.

ActionFunctionReadsWrites
Generate the HPD cover formgenerateHpdDocument, edge function generate-hpd-document (manage_documents)projects, tenants, project_event_links, project_order_decisions, tenant_branding; existing documents of that code and orderdocuments (CONTEST for track A, CERT-H for track B; draft, because the owner signs) and document_orders for every violation of the filing group. Refuses an undecided group (decision_required)
Link the report to its violationattachDocumentToViolations / detachDocumentFromViolation (drawer, "Violations covered by this document": one row per violation of the project plan)document_types reuse policy via canAttachOrderToDocument, with the plan's track per order (trackOfOrder)document_orders (document_id, order_number, event_id). The XRF generator does not link orders itself
Upload the NOVuploadDocument then createDocumentWithFiledocuments (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 credentialscredentialSlots (no write)licenses, documents (CREDENTIAL)Nothing: INSP-EPA is filled by the inspector's current license file
SignsignDocumentdocuments.status = 'signed', signer_name, signed_at
NotarizenotarizeDocumentdocuments.status = 'notarized', notary_name, notarized_at
Share with the clientsetClientVisibletenant_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 packageassembleFilingPackage, edge function assemble-filing-package (manage_documents)projects, decisions, links, documents of affidavit types Asked for by track. Ensures each decided filing group of the track has its form (groups needing a decision 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 Proposal card of the Proposal & scope tab. Invoices and vendor payments are on the Billing tab (?tab=billing, was ?view=money; needs view_financials).

RunssaveProposal, then generateSavedProposalPdf (edge function generate-proposal-pdf, manage_proposals plus owner-tenant check)
Readsrate_cards, abatement_rates, abatement_components, decisions and links (for the derived services)
Writesproposals (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.

RunsgenerateInvoicePdf, edge function generate-invoice-pdf (manage_invoices plus owner-tenant check)
Readsrate_cards, abatement_rates, and what was captured: xrf_readings (non-calibration), dust_wipe_samples, paint_chip_samples, abatement_components; tenants, buildings, tenant_branding
WritesRPC 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 (domain/src/field/invoiceLines.ts). If a service with recorded work has no usable price, or nothing is billable, the function answers 422 naming what is missing. That happens before an invoice number is allocated, so no $0 or short invoice is created. 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 … → Set phase menu (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 pageFunctionTable and the rule that filters it
/portal/buildingslistTrackedBuildingstenant_buildings rows whose client_id is the login's client (active client_users row)
/portal/projects/:idgetProject, listProjectsForBuildingprojects whose building is one of those
/portal/documents and the project pagelistDocumentsForPortaldocument_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; or a document sent to this client to sign, and its signed copy, while the request is not cancelled
/portal/sign/:id, Home and the project page's proposalsgetSignatureRequest, listPortalSignatureRequestssignature_requests whose client_id is the login's client and not cancelled; signing goes through the sign-document edge function
/portal/invoiceslistMyInvoicesinvoices that are not draft and are billed to this client
Opening a filegetPortalSignedDocumentUrlStorage 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.

StepTables
Two visits addedinspections (service_type = 'abatement' and service_type = 'dust_wipe' with is_clearance = true), each with inspection_events for order 617
Abatement captureabatement_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 samplingdust_wipe_samples (lab results entered per sample), lab_chain_of_custody (via createChainOfCustody), laboratory_partners
Documentsgenerate-coc writes COC and sets lab_chain_of_custody.document_id; generate-dust-wipe-report writes DUST-LAB; generate-hpd-document writes CERT-H
IndependenceclearanceIndependenceWarning compares who abated with who sampled and warns; it never blocks

generate-abatement-report writes ABATE-RPT. Once a dust-wipe, paint-chip or abatement visit is marked complete, the field job's sidebar offers its report (FieldReportCard), which calls generate-dust-wipe-report, generate-paint-chip-report or generate-abatement-report for the project. 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​

ConcernLocation
Create, phase and linkssrc/data/projects.ts
Decisions and derivationsrc/data/projectOrderDecisions.ts, src/data/projectPlan.ts, domain/src/programs/nyc-lead-paint/projectPlan.ts, domain/src/field/serviceRequirements.ts
Project page tabssrc/pages/ProjectDetailPage.tsx, src/pages/project-detail/lifecycleTabs.ts
Tracks and dispatchsrc/pages/project-detail/work/, src/pages/project-detail/schedule/, domain/src/field/dispatchStage.ts
Visits and capturesrc/data/inspections.ts, xrfReadings.ts, samples.ts, chainOfCustody.ts, abatementWork.ts
XRF import and reportsupabase/functions/ingest-xrf-csv/, supabase/functions/generate-xrf-report/
Documentssrc/data/documents.ts, supabase/functions/_shared/documents.ts, _shared/hpdDocuments.ts
Moneysrc/data/proposals.ts, invoices.ts, supabase/functions/generate-proposal-pdf/, generate-invoice-pdf/
Portalsrc/pages/portal/, src/data/portalInvoices.ts