Skip to main content

Documents — developer guide

Audience: an engineer new to this repo who has to work on anything that stores, shows or shares a PDF. Status: all five phases of the documents rework are live in production (2026-09-29). Companion docs: SCHEMA.md §7 (the tables, column by column), PERMISSIONS.md ("Documents: access class, level and sharing"), RULEBOOK.md §0 (which HPD form needs which paperwork).

This is the "how it works and where to change what" walkthrough. You don't need to have read the rest of the repo; where a term comes up for the first time, it is explained.


1. One paragraph​

A document is a piece of paperwork: an inspection report, a lab result, an HPD form the owner signs, an invoice, an EPA license. Every document is one row in the documents table and one PDF in the documents Storage bucket. Each row says what it is (its type, from a catalog), what it is attached to (its level: a project, a building, the company, a person…), how far along it is (its status: draft → issued → signed → notarized → filed), and whether the client can see it (shared). Who may read or change a row is decided by the database (row-level security), not by the UI. The staff app lists documents at /documents; each project's File tab shows that project's documents; the client portal shows only what was shared with that client.

┌───────────────────── documents (one row) ─────────────────────┐
what it is → │ type ─────► document_types (catalog: label, category, │
│ access class, allowed levels, signer, notary) │
attached to → │ level + one anchor (project_id | building_id | unit_id | │
│ client_id | tenant_id | profile_id) │
how far → │ status draft → issued → signed → notarized → filed │
who sees it → │ client_visible + shared_client_id │
history → │ supersedes_id / is_current, voided_at / void_reason │
the file → │ storage_path ──► Storage bucket "documents" │
└────────────────────────────────────────────────────────────────┘

2. Words you will see everywhere​

WordMeaning
TenantA company using Complied (Secure Environmental Group, Abated NYC). Not a resident. Every staff user belongs to one tenant.
ClientA building owner the tenant works for. Clients log in to the portal.
ProjectOne job on one building (and maybe one apartment). Most generated paperwork belongs to a project.
OrderAn HPD violation order number, e.g. 616. A document can "cover" orders; the File tab's checklist asks "which documents does each order still need?"
Document type / codeA short code from the catalog: INSP-RPT (inspection report), XRF-AFF (XRF affidavit), PC-LAB (paint-chip lab result), CERT-H (HPD hazard certification), CREDENTIAL (a license file), OTHER…
LevelWhat a document hangs off. See §4.
Access classWhich permission keys read and write a type: general, field_report, financial or credential. See §6.
GeneratorAn edge function that renders a PDF and writes the document itself: generate-xrf-report, generate-coc, generate-hpd-document, …
RLSPostgres row-level security. Policies on each table decide which rows a signed-in user can see or write. The browser can only ever get what RLS allows.

3. What changed, and why (the five phases)​

Before this work, documents only existed inside a project, type was free text, the portal saw every document on its buildings (drafts and proposals included), several documents could share one PDF, and photos and license files were mixed in with paperwork. The rework ran in five phases; each is one or two migrations plus the code that uses them.

PhaseWhat changedMigrationsPR
1Levels, catalog, sharing, versioning, void. document_types table; documents.level and anchor columns; status gains issued; per-document client_visible; supersedes_id / is_current; soft void; new RLS by access class; Storage access follows the row20260928150000, 20260928150100#45
2Photos and floor plans leave documents. They are field capture, not paperwork: they live on their own rows in the field-photos bucket20260928160000, 20260928160100#45
3License files become CREDENTIAL documents. licenses.document_id points at the current file20260928170000, 20260928170100#45
4One file per document. Each generator writes one PDF per code (the XRF run gives an INSP-RPT and a separate XRF-AFF). The chain-of-custody (CoC) sheet is stored. EPA slots are filled from license files, never uploaded per project20260929100000, 20260929100100#47
5The Documents page. /documents, the document_list view, bulk sharing, the drawer, upload at any level; the project File tab and the portal reuse it20260929110000#49

Rollout records, if you need the history: issues #36 (phases 1–3), #46 (phase 4), #48 (phase 5).


4. Levels: what a document is attached to​

Every document has exactly one level and the one anchor column that level needs. A database check (documents_level_anchor_check) refuses anything else.

LevelAnchorExampleOwning client
projectproject_id (+ building_id, copied from the project)An inspection report, a signed HPD formThe building's client
buildingbuilding_idThe Certificate of OccupancyThe building's client
unitunit_id (+ building_id)A lease for apartment 4BThe building's client
clientclient_idA contract with the ownerThat client
tenanttenant_idThe firm's insurance, the firm's EPA certificationNone
userprofile_idA certification about one of your people (owned by the company, not a private drive)None
platform_publishednoneSomething the platform publishes to every firmNone
platform_to_tenanttenant_idSomething the platform sends one firmNone
platform_internalnonePlatform operators onlyNone

The three platform levels exist in the schema and RLS, but there is no operator UI and nothing seeded yet.

You don't set tenant_id or building_id by hand for most levels: the trigger documents_1_sync_anchors fills them in from the anchor, reading it with your permissions. So you cannot attach a document to a project, client or unit you can't see.

The catalog also limits levels per type (document_types.allowed_levels): a COFO can sit on a building or a project, an INSP-RPT only on a project, a CREDENTIAL only on the company or a person. The trigger documents_2_check_type enforces it.


5. The type catalog​

document_types is a small, global table (one row per code) that every signed-in user can read and only migrations write. It has a TypeScript mirror, domain/src/docs/documentTypes.ts, which the UI and the edge functions read without a round trip. Change both in the same commit: the db-test documents-levels.test.ts fails when they differ.

Each type carries:

  • category: report, regulatory filing, affidavit/certification, lab result, financial, credential, correspondence, reference, other. Used for filtering and for "should the client get this?"
  • access_class: who reads and writes it (§6).
  • allowed_levels: where it may be filed (§4).
  • signer_role and requires_notary: whether it waits for a signature (and a notary) before it is final.
  • reuse_policy: whether one PDF may cover several orders (shared, union, per_track, fresh_per_filing, never_reused). The checklist enforces this through domain/src/docs/reusePolicy.ts.
  • collaborator_roles: which roles of a collaborating firm may read it (field reports only).

Status​

draft ──► issued ──► signed ──► notarized ──► filed
│ ▲
│ └─ types with no signer start here (reports, lab results, invoices)
└──────────── types with a signer start here (HPD forms, affidavits, proposals)

issued means "final, not waiting for anyone's signature". Once a document is past draft it is a record: the trigger documents_4_guard_immutable only lets the status move forward and lets sharing, voiding and is_current change. To correct an issued document you void it or supersede it with a new version (§8). initialDocumentStatus(code) in the domain package picks the starting status from the catalog; every writer uses it.


6. Who can see and change what​

There are two audiences: staff of the tenant (and collaborating firms), and portal logins of a client.

Staff: by access class​

Access classRead withWrite with
general (HPD forms, CofO, lease, NOV, other)view_documentsmanage_documents
field_report (reports, affidavits, lab results, CoC)view_documents, view_field_jobs, or being assigned a job on the project; a collaborating firm whose role is in the type's collaborator_rolesmanage_documents, or a generator after its own permission check
financial (proposal, invoice)view_financialsmanage_proposals or manage_invoices
credential (licenses, EPA certs)view_licenses, manage_inspections, perform_abatement_work, or being the person it is aboutmanage_licenses

Permission keys (like view_documents) come from the user's role; see PERMISSIONS.md. The policy is documents_select in 20260928150100_documents_access_policies.sql; writes go through the helper document_writable(type, level, tenant_id). The UI mirrors the write rule in canWriteAccessClass() so it only offers what the database would accept.

Portal: the hard rule​

A client portal login sees a document only when all of these hold:

  1. client_visible is true, and shared_client_id is the viewer's own client;
  2. the status is past draft;
  3. it is not voided;
  4. it is not financial.

The one exception: the PDF of an invoice that was sent and billed to that client (invoices.billed_client_id).

The building's current client is never consulted. When a document is shared, the trigger documents_3_share stamps shared_client_id with whoever owns the building at that moment. If the building later moves to another client, the old client keeps what was shared with them and the new client gets nothing until staff share it again. Only manage_documents can share.

The file follows the row​

Storage policies on the documents bucket say: you can read an object if you can read a documents row whose storage_path is that object. So hiding a row hides its PDF too, including a portal user guessing a path. Portal links are minted when the user clicks View and expire after 60 seconds; staff links last an hour.


7. How a document gets created​

There are two ways in, and both end in one row plus one object.

A person uploads it​

UploadDialog / checklist slot
└─ uploadDocument({ anchor, type, file }) src/data/documents.ts
└─ createDocumentWithFile()
1. insert the row as a draft (RLS: may you write this type here?)
2. upload the PDF to {tenant}/{document}/{file name}
(Storage lets you only because a draft row you may write points at it)
3. if the upload fails, delete the draft row
4. update the row to its starting status (issued, or draft if it needs a signature)

The row goes in first on purpose: the bucket's insert policy needs the row to exist.

A generator writes it​

The report edge functions (generate-xrf-report, generate-paint-chip-report, generate-dust-wipe-report, generate-abatement-report, generate-coc, generate-hpd-document, generate-proposal-pdf, generate-invoice-pdf) render a PDF and call insertDocument() in supabase/functions/_shared/documents.ts once per code. They run as the service role, which bypasses RLS, so each function's own authorize() check is the only gate: keep it when you touch one. Generated files live at {tenant_id}/{document_id}/{code}.pdf, and no two rows share a file (unique storage_path).

EPA credentials are special​

The HPD filings need proof that the inspector, the sampler or the abatement firm and crew hold EPA certification (INSP-EPA, SAMP-EPA, ABAT-EPA). These are never uploaded per project. Each person's or the firm's license lives under Settings → Licenses, and its file is a CREDENTIAL document. The checklist works out who did the work and shows the slot as filled when each of them has a current license with a file: domain/src/licensing/credentialSlots.ts decides, and src/pages/project-detail/file/credentialSlots.ts feeds it the project's visits. A filled slot's "Credential" button opens the files behind it. To replace a license's file, upload it under Settings → Licenses (that supersedes the old document and relinks the license).


8. Changing a document after the fact​

ActionWhat happensCode
Share / stop sharingSets client_visible; the trigger stamps or clears shared_client_id. One update for a whole selection: if any document in it has no owning client, none of them changesetClientVisible(ids, visible)
Sign / notarizeMoves the status forward and records the name and timesignDocument, notarizeDocument
Supersede (new version)A new row with the same anchor, type and title and supersedes_id = the old one. A trigger sets the old row's is_current to false. The orders the old one covered move to the new one, and it stays shared if the old one wassupersedeDocument(previous, file)
VoidSets voided_at and void_reason. The server stamps who and when (documents_stamp_void), and a void can't be edited or undone. A voided document stays in the table but fills no checklist slot and the portal stops seeing itvoidDocument(id, reason)
DeleteOnly a draft that was never shared (a failed upload's own row). Everything else is voided, never deleted; projects with documents can't be deleted either—

Superseding a CREDENTIAL from the Documents page is blocked on purpose: the license would keep pointing at the old file. Do it from Settings → Licenses.


9. The screens​

/documents (staff)​

src/pages/DocumentsPage.tsx plus the pieces in src/pages/documents/:

  • Tabs: All, Needs action, Company (tenant and user levels), Platform library.
  • Facets: Level, Category, Type, Status (including Voided), Client, Building, Shared, Uploaded by, and "include old versions and voided". By default the list shows current, unvoided versions.
  • Saved views under the scope documents:staff.
  • Bulk share / stop sharing for users with manage_documents.
  • Drawer (DocumentDrawer.tsx): details, versions, orders covered, share toggle, sign/notarize, upload a new version, void, and an activity list.
  • Upload (UploadDialog.tsx): pick the level, then what it hangs off, then a type the level allows and you may write, then the file.

The route opens for any of view_documents, view_financials, view_licenses, view_field_jobs (src/auth/routeAccess.ts).

"Needs action" means: a draft waiting for its signature, a signed document waiting for its notary, or a final report / filing / affidavit / lab result that hasn't been shared with its client yet. The rule is needsAction() in domain/src/docs/documentRules.ts. The list query uses needsActionClauses(), which is derived from the same function, and a test checks the two agree for every type and status.

The project File tab (staff)​

The "Document library" section is the same table, locked to the project, and opens the same drawer. Inside the drawer, "Orders covered" is the order-linking control, which checks the type's reuse policy. The checklist above it ignores voided and superseded documents.

The portal (clients)​

/portal/documents, the portal project page and the portal dashboard all call listDocumentsForPortal(), one query that RLS filters to what was shared with that client. They show only the newest version of each document the client can see.


10. How the list is loaded​

The page doesn't query documents directly. It reads the view document_list (20260929110000_documents_list.sql), which is documents plus what a list needs to show and filter:

Extra columnWhere it comes from
type_label, category, access_classdocument_types
owner_client_id, owner_client_namethe client-level anchor, or the building's client for that tenant (tenant_buildings)
building_address, building_boroughbuildings
effective_unit_id, unit_labelthe document's unit, or its project's unit
created_by_name, profile_nameprofiles: the uploader, and the person a user-level document is about

The view is security_invoker: it runs with the caller's own permissions. The documents policy still decides which rows exist, and any joined table the caller can't read just comes back empty (a portal user doesn't get your internal client list; a collaborating firm doesn't get your clients). A db-test checks that each kind of user sees exactly the same rows through the view as through the table.

DocumentsPage state (filters, page)
└─ listDocuments(filters, { page, pageSize }) src/data/documents.ts
└─ compileDocumentListFilters(filters) src/lib/documentListFilters.ts
→ conditions on document_list (tab, facets, search, needs-action tree)
└─ select * from document_list … order by created_at desc, count exact, range(page)

Every filter is part of the query, so the count and the pages always describe the same set.


11. Where to change what​

I want to…Change
Add a document typeA migration inserting into document_types and the entry in domain/src/docs/documentTypes.ts (and DOC_LABEL if it is an HPD code). Run the domain tests and db-tests/tests/documents-levels.test.ts
Let a type sit at another levelallowed_levels in both places
Change who reads a classThe documents_select policy (new migration) and the table in docs/domain/PERMISSIONS.md; if writes change, document_writable() and ACCESS_CLASS_WRITE_KEYS in documentRules.ts
Change what "Needs action" meansneedsAction() / CLIENT_DELIVERABLE_CATEGORIES in documentRules.ts. The query follows automatically
Add a facetDocumentListFilters + compileDocumentListFilters + chips in src/lib/documentListFilters.ts, and a section in DocumentsFilterMenu.tsx. If it needs a column the view lacks, add it to document_list in a new migration
Write documents from a new edge functionUse insertDocument() from _shared/documents.ts, add an authorize() check, and write project-level documents only
Show a new column in the listDocumentsTable.tsx (and documentDisplay.ts for formatting)

Rules that apply everywhere in this repo: database calls (.from() / .rpc()) live only in src/data/; pure logic lives in domain/ with no database imports; a new migration's timestamp must sort after every existing one.


12. Tests​

SuiteCoversRun
domain/tests/docs/*.test.tsCatalog, reuse policy, level/type rules, needsAction and its query clauses, versionscd domain && npm test
src/lib/documentListFilters.test.ts, src/pages/documents/documentDisplay.test.tsFilter compilation, saved views, display helpersnpm run test (move .env.local aside first, or a missing-config error is hidden)
db-tests/tests/documents-levels.test.tsRead matrix per role, writes by class, anchors, sharing, immutability, client moves, invoices, filing packages, Storagecd db-tests && npm test (needs local Supabase; never supabase db reset)
db-tests/tests/documents-list.test.tsThe view matches the table per user, owning client per level, bulk share, server-stamped voidssame
db-tests/tests/documents-one-file.test.ts, license-credentials.test.tsOne file per row, CoC storage, credential documentssame

13. Things that trip people up​

  • "I shared it but the client can't see it." Is it still a draft? Voided? A financial type? Is the building assigned to a client (tenant_buildings.client_id)? No owning client means it can't be shared at all.
  • "The client still sees the old version." The portal lists only the newest version it can see, but an older version that was shared is still readable by its link. Stop sharing the old one if it must go.
  • "The checklist says a slot is missing, but the document is right there." It may be voided or superseded (neither fills a slot), not linked to that order, or blocked by the type's reuse policy.
  • "I edited an issued document and got an error." Issued documents are records. Void it or upload a new version.
  • "My new edge function wrote a document the user shouldn't be allowed to create." Generators run as the service role and skip RLS. The function's own authorize() is the only check.
  • Activity is not an audit log. The drawer's activity is rebuilt from the row's own stamps (filed, signed, notarized, shared, voided). Unsharing leaves no entry. There is no separate document audit table yet.