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:

TrackGoverning orderDecisionDerived 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.

RunscreateProjectFromViolations in src/data/projects.ts (composer: useProjectComposer); the single-violation entry is createProjectFromViolation from /violations/:id
Permissionmanage_projects (RLS insert policy)
Readstenant_buildings (tracked buildings), public_events (building_id, citation_code, status_norm, due_date)
Writesprojects (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
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. 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.

RunsrecordOrderDecision in src/data/projectOrderDecisions.ts; the tab derives its view with buildDecidedFormBundles from @complied/domain
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 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).

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. 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.

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 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.

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 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.

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 File tab (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 order the bundle covers. Refuses an undecided order (decision_required)
Link the report to its orderattachDocumentToOrder (drawer, "Orders covered")document_types reuse policy via canAttachOrderToDocumentdocument_orders (document_id, order_number). 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 typesEnsures 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.

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. 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 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
/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) 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​

ConcernLocation
Create, phase and linkssrc/data/projects.ts
Decisions and derivationsrc/data/projectOrderDecisions.ts, domain/src/programs/nyc-lead-paint/decidedFormBundles.ts, domain/src/field/serviceRequirements.ts
Tracks and dispatchsrc/pages/project-detail/work/, 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