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
| Word | Meaning |
|---|---|
| Tenant | A company using Complied (Secure Environmental Group, Abated NYC). Not a resident. Every staff user belongs to one tenant. |
| Client | A building owner the tenant works for. Clients log in to the portal. |
| Project | One job on one building (and maybe one apartment). Most generated paperwork belongs to a project. |
| Order | An 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 / code | A 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… |
| Level | What a document hangs off. See §4. |
| Access class | Which permission keys read and write a type: general, field_report, financial or credential. See §6. |
| Generator | An edge function that renders a PDF and writes the document itself: generate-xrf-report, generate-coc, generate-hpd-document, … |
| RLS | Postgres 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.
| Phase | What changed | Migrations | PR |
|---|---|---|---|
| 1 | Levels, 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 row | 20260928150000, 20260928150100 | #45 |
| 2 | Photos and floor plans leave documents. They are field capture, not paperwork: they live on their own rows in the field-photos bucket | 20260928160000, 20260928160100 | #45 |
| 3 | License files become CREDENTIAL documents. licenses.document_id points at the current file | 20260928170000, 20260928170100 | #45 |
| 4 | One 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 project | 20260929100000, 20260929100100 | #47 |
| 5 | The Documents page. /documents, the document_list view, bulk sharing, the drawer, upload at any level; the project File tab and the portal reuse it | 20260929110000 | #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.
| Level | Anchor | Example | Owning client |
|---|---|---|---|
project | project_id (+ building_id, copied from the project) | An inspection report, a signed HPD form | The building's client |
building | building_id | The Certificate of Occupancy | The building's client |
unit | unit_id (+ building_id) | A lease for apartment 4B | The building's client |
client | client_id | A contract with the owner | That client |
tenant | tenant_id | The firm's insurance, the firm's EPA certification | None |
user | profile_id | A certification about one of your people (owned by the company, not a private drive) | None |
platform_published | none | Something the platform publishes to every firm | None |
platform_to_tenant | tenant_id | Something the platform sends one firm | None |
platform_internal | none | Platform operators only | None |
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_roleandrequires_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 throughdomain/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 class | Read with | Write with |
|---|---|---|
general (HPD forms, CofO, lease, NOV, other) | view_documents | manage_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_roles | manage_documents, or a generator after its own permission check |
financial (proposal, invoice) | view_financials | manage_proposals or manage_invoices |
credential (licenses, EPA certs) | view_licenses, manage_inspections, perform_abatement_work, or being the person it is about | manage_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:
client_visibleis true, andshared_client_idis the viewer's own client;- the status is past
draft; - it is not voided;
- 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
| Action | What happens | Code |
|---|---|---|
| Share / stop sharing | Sets 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 change | setClientVisible(ids, visible) |
| Sign / notarize | Moves the status forward and records the name and time | signDocument, 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 was | supersedeDocument(previous, file) |
| Void | Sets 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 it | voidDocument(id, reason) |
| Delete | Only 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 column | Where it comes from |
|---|---|
type_label, category, access_class | document_types |
owner_client_id, owner_client_name | the client-level anchor, or the building's client for that tenant (tenant_buildings) |
building_address, building_borough | buildings |
effective_unit_id, unit_label | the document's unit, or its project's unit |
created_by_name, profile_name | profiles: 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 type | A 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 level | allowed_levels in both places |
| Change who reads a class | The 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" means | needsAction() / CLIENT_DELIVERABLE_CATEGORIES in documentRules.ts. The query follows automatically |
| Add a facet | DocumentListFilters + 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 function | Use insertDocument() from _shared/documents.ts, add an authorize() check, and write project-level documents only |
| Show a new column in the list | DocumentsTable.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
| Suite | Covers | Run |
|---|---|---|
domain/tests/docs/*.test.ts | Catalog, reuse policy, level/type rules, needsAction and its query clauses, versions | cd domain && npm test |
src/lib/documentListFilters.test.ts, src/pages/documents/documentDisplay.test.ts | Filter compilation, saved views, display helpers | npm run test (move .env.local aside first, or a missing-config error is hidden) |
db-tests/tests/documents-levels.test.ts | Read matrix per role, writes by class, anchors, sharing, immutability, client moves, invoices, filing packages, Storage | cd db-tests && npm test (needs local Supabase; never supabase db reset) |
db-tests/tests/documents-list.test.ts | The view matches the table per user, owning client per level, bulk share, server-stamped voids | same |
db-tests/tests/documents-one-file.test.ts, license-credentials.test.ts | One file per row, CoC storage, credential documents | same |
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.