Documents
How documents are stored, typed, attached, shared, versioned and listed, and where to change each behavior. It is for an engineer who has to work on anything that stores, shows or shares a PDF. The operator's view of the same feature is documents and filing. The columns of each table are in data-model; the permission keys behind access are in permissions.
In 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 documents and one object in the
documents Storage bucket. The 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), and whether the client can see it (shared). The database, not the UI, decides who
may read or change a row. Staff list documents at /documents; a project's File tab shows that
project's documents; the client portal shows only what was shared with that client.
Vocabulary
| Word | Meaning |
|---|---|
| Order | An HPD violation order number such as 616. A project document can cover orders; the File tab asks which documents each order still needs |
| Type or code | A short code from the catalog: INSP-RPT, XRF-AFF, CERT-H, CREDENTIAL, OTHER |
| Level | What a document hangs off (below) |
| Access class | Which permission keys read and write a type: general, field_report, financial, credential |
| Generator | An edge function that renders a PDF and writes the document itself |
Levels
Every document has exactly one level and the anchor column that level needs;
documents_level_anchor_check refuses anything else.
| Level | Anchor | Example | Owning client |
|---|---|---|---|
project | project_id (plus building_id, copied from the project) | An inspection report, a signed HPD form | The building's client |
building | building_id | A Certificate of Occupancy | The building's client |
unit | unit_id (plus 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, its EPA certification | None |
user | profile_id | A certification about one of the firm's people | None |
platform_published | none | Published by the platform to every firm | None |
platform_to_tenant | tenant_id | Sent by the platform to one firm | None |
platform_internal | none | Platform operators only | None |
The three platform levels exist in the schema and RLS; there is no operator screen for them.
tenant_id and building_id are not set by hand for most levels. The trigger
documents_1_sync_anchors fills them from the anchor, reading it with the caller's own
permissions, so a document cannot be attached to a project, client or unit the caller cannot see. For
building and unit levels it also requires the tenant to track the building
(tenant_tracks_building). The catalog limits levels per type (allowed_levels), enforced by
documents_2_check_type.
The type catalog
document_types is a small global table, readable by every signed-in user and written only by
migrations. Its TypeScript mirror,
domain/src/docs/documentTypes.ts, is what the UI and
the edge functions read without a round trip. Both change in the same commit;
db-tests/tests/documents-levels.test.ts fails when they differ.
Each type carries:
| Field | Meaning |
|---|---|
category | regulatory_filing, report, affidavit_certification, lab_result, financial, credential, correspondence, reference or other. Used for filtering and for "should the client get this" |
access_class | Who reads and writes it |
allowed_levels | Where it may be filed |
signer_role, requires_notary | Whether it waits for a signature (and a notary) before it is final. A type can require a notary only if it has a signer |
reuse_policy | Whether one document may answer several orders: shared, union, per_track, fresh_per_filing, never_reused. Enforced by reusePolicy.ts. A code with no entry defaults to per_track |
collaborator_roles | Which roles of a collaborating tenant may read it (field reports only) |
is_active | Retiring a type sets this false; the check runs on insert only |
| Codes | Category | Access class | Level | Signer / notary |
|---|---|---|---|---|
CERT-H, CERT-T, CONTEST, AF5 | regulatory filing | general | project | owner, notary |
DR, DISM-T, POST1, POST2 | regulatory filing | general | project | owner |
INSP-RPT, PC-RPT, ABATE-RPT, WORK-DESC | report | field_report | project | none |
XRF-AFF, PC-AFF | affidavit | field_report | project | inspector, notary |
ABAT-AFF | affidavit | field_report | project | abatement supervisor, notary |
SAMP-AFF | affidavit | field_report | project | sampler, notary |
PC-LAB, DUST-LAB, COC | lab result | field_report | project | none |
INSP-EPA, SAMP-EPA, ABAT-EPA, CREDENTIAL | credential | credential | tenant, user | none |
COFO | reference | general | building, project | none |
LEASE | reference | general | unit, project | none |
NOV | reference | general | building, unit, project | none |
PROPOSAL | financial | financial | project | client |
INVOICE | financial | financial | project | none |
OTHER | other | general | any level | none |
Photos and floor plans are field capture, not documents: they live on their capture rows in the
field-photos bucket.
Status
issued means final and not waiting for anyone's signature. initialDocumentStatus(code) picks
the starting status from the catalog and every writer uses it. Once a document is past draft it
is a record: documents_4_guard_immutable lets status move only forward and lets only the
signer and notary stamps, sharing columns, void columns, is_current and updated_at change. To
correct an issued document you void it or supersede it with a new version. filed is part of the
status set; no code in the app writes it.
Access and sharing
Staff access follows the type's access class, narrowed by level. The read and write table is in
permissions; the policy is documents_select in
20260928150100_documents_access_policies.sql, and writes go through document_writable(type, level, tenant_id). The UI mirrors the write rule in canWriteAccessClass() so it offers only what
the database would accept.
The portal rule
A 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 is the PDF of an invoice that was sent and billed to that client
(invoices.billed_client_id, stamped when the invoice leaves draft).
The building's current client is never consulted. documents_3_share stamps shared_client_id
with the owner at the moment of sharing, using document_owner_client_id(): the document's own
client_id, else the client of the tenant's tenant_buildings row for its building. If the
building later moves to another client, the old client keeps what was shared and the new client
sees nothing until staff share again. Only manage_documents may share, whatever the document's
access class. canShareDocument in domain/src/docs/documentRules.ts mirrors the rule for the UI:
not voided, not financial, level client, building, unit or project, and an owning client
exists.
The file follows the row
Storage policies on the documents bucket say an object is readable when a documents row whose
storage_path is that object is readable. Hiding a row hides its PDF, including from a portal user
guessing a path. Portal links are minted when the user opens a document and last 60 seconds; staff
links last an hour.
How documents are created
Two paths end in one row plus one object.
A person uploads
The row goes in first because the bucket's insert policy needs it to exist. The client accepts PDF only, up to 50 MB (the bucket itself also permits images). Project-level uploads may attach orders on the way in, each checked against the type's reuse policy.
A generator writes
Each generator renders a PDF and calls insertDocument() in
supabase/functions/_shared/documents.ts once per
code. Objects are stored at {tenant_id}/{document_id}/{code}.pdf, and no two rows share one
(documents_storage_path_key). Generators write project-level documents only.
| Function | Writes |
|---|---|
generate-xrf-report | INSP-RPT, XRF-AFF |
generate-paint-chip-report | PC-RPT, PC-AFF, PC-LAB |
generate-dust-wipe-report | DUST-LAB |
generate-abatement-report | ABATE-RPT (no screen in the app calls it) |
generate-coc | COC, and sets lab_chain_of_custody.document_id |
generate-hpd-document | The cover form for a decided bundle (CERT-H, CERT-T, CONTEST, AF5, DR, DISM-T), linked to every order the bundle covers; refuses an undecided order |
assemble-filing-package | Ensures those cover forms exist, then writes filing_packages rows, not a document |
generate-proposal-pdf | PROPOSAL |
generate-invoice-pdf | INVOICE |
Generators 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. A generator does not link orders, except
generate-hpd-document; an operator links a report to its orders from the document drawer.
EPA credentials
HPD filings need proof that the inspector, sampler or abatement firm and crew hold EPA
certification (INSP-EPA, SAMP-EPA, ABAT-EPA). These are never uploaded per project. A
person's or the firm's license lives under Settings, Licenses, and its file is a CREDENTIAL
document (licenses.document_id). The checklist works out who did the work and shows the slot
filled when each of them has a current license with a file:
credentialSlots.ts decides, and
src/pages/project-detail/file/credentialSlots.ts
feeds it the project's visits. Replacing a license's file supersedes the old document and relinks
(licenses_check_document requires the link to be a live credential document of the license's own
holder).
Changing a document after the fact
| Action | What happens | Code |
|---|---|---|
| Share or stop sharing | Sets client_visible; the trigger stamps or clears shared_client_id. One update for a whole selection: if any document has no owning client, none change | setClientVisible(ids, visible) |
| Sign or notarize | Moves the status forward and records name and time | signDocument, notarizeDocument |
| Supersede (new version) | Uploads a new row with the same anchor, type and title and supersedes_id. documents_supersede sets the old row's is_current false. Its orders move to the new row, and it stays shared if the old one was | supersedeDocument(previous, file, createdBy) |
| Void | Sets voided_at and void_reason; documents_stamp_void stamps who and when, and the stamp cannot be edited or undone. A voided document stays in the table, 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 | none |
Superseding a CREDENTIAL from the Documents page is blocked (canSupersedeDocument): the license
would keep pointing at the old file. The new version must keep the tenant and type of the old one,
and the old one must be current.
Screens
/documents (staff)
DocumentsPage.tsx with 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 an "include old versions and voided" switch. By default the list shows current, unvoided versions.
- Saved views under the scope
documents:staff. - Bulk share and stop sharing for holders of
manage_documents. - Drawer (
DocumentDrawer.tsx): details, versions, orders covered, share toggle, sign and notarize, upload a new version, void, and an activity list. - Upload (
UploadDialog.tsx): pick a level, then what it hangs off, then a type the level allows and the user may write, then the file.
"Needs action" means a draft waiting for its signature, a signed document waiting for its notary,
or a final report, filing, affidavit or lab result not yet shared with its client. The rule is
needsAction() in documentRules.ts; the list query uses
needsActionClauses(), derived from the same function, and a test checks the two agree for every
type and status.
The drawer's activity list is rebuilt from the row's own stamps (created, signed, notarized, shared, replaced, voided). There is no separate document audit table, so an unshare leaves no entry of its own.
The project File tab
The "Document library" section is the same table, locked to the project, opening the same drawer. "Orders covered" in the drawer is the order-linking control, which checks the reuse policy. The required-documents checklist above it ignores voided and superseded documents.
The portal
/portal/documents, the portal project page and the portal dashboard call
listDocumentsForPortal(), one query RLS filters to what was shared with that client. They show
only the newest version of each document the client can see.
How the list is loaded
The page reads the view document_list (security_invoker), which is documents plus what a list
shows and filters on. Because the view runs with the caller's rights, the documents policy still
decides which rows exist and any join the caller cannot read comes back null: a portal user does not
get the internal client list, and a collaborating tenant does not get the owner's clients.
Every filter is part of the query, so the count and the pages describe the same set.
Where to change what
| I want to | Change |
|---|---|
| Add a document type | A migration inserting into document_types, 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 permissions; for writes, document_writable() and ACCESS_CLASS_WRITE_KEYS in documentRules.ts |
| Change what "Needs action" means | needsAction() and CLIENT_DELIVERABLE_CATEGORIES in documentRules.ts; the query follows |
| Add a facet | DocumentListFilters and compileDocumentListFilters 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 | insertDocument() from _shared/documents.ts, an authorize() check, project-level documents only |
| Show a new column in the list | DocumentsTable.tsx and documentDisplay.ts |
| Change reuse rules | DOCUMENT_REUSE_POLICY and canAttachOrderToDocument in reusePolicy.ts |
Repository rules apply: database calls live only in src/data/, pure logic lives in domain/, and
a new migration's timestamp must sort after every existing one.
Tests
| Suite | Covers | Run |
|---|---|---|
domain/tests/docs/*.test.ts | Catalog, reuse policy, level and type rules, needsAction and its 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 |
Things that trip people up
- "I shared it but the client cannot see it." Is it still a draft, voided, or a financial type?
Is the building assigned to a client (
tenant_buildings.client_id)? With no owning client a document cannot 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. A generated XRF report is not linked to any order until an operator links it.
- "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 should not be able to create." Generators run
as the service role and skip RLS; the function's own
authorize()is the only check.