Skip to main content

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​

WordMeaning
OrderAn 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 codeA short code from the catalog: INSP-RPT, XRF-AFF, CERT-H, CREDENTIAL, OTHER
LevelWhat a document hangs off (below)
Access classWhich permission keys read and write a type: general, field_report, financial, credential
GeneratorAn 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.

LevelAnchorExampleOwning client
projectproject_id (plus building_id, copied from the project)An inspection report, a signed HPD formThe building's client
buildingbuilding_idA Certificate of OccupancyThe building's client
unitunit_id (plus building_id)A lease for apartment 4BThe building's client
clientclient_idA contract with the ownerThat client
tenanttenant_idThe firm's insurance, its EPA certificationNone
userprofile_idA certification about one of the firm's peopleNone
platform_publishednonePublished by the platform to every firmNone
platform_to_tenanttenant_idSent by the platform to one firmNone
platform_internalnonePlatform operators onlyNone

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:

FieldMeaning
categoryregulatory_filing, report, affidavit_certification, lab_result, financial, credential, correspondence, reference or other. Used for filtering and for "should the client get this"
access_classWho reads and writes it
allowed_levelsWhere it may be filed
signer_role, requires_notaryWhether 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_policyWhether 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_rolesWhich roles of a collaborating tenant may read it (field reports only)
is_activeRetiring a type sets this false; the check runs on insert only
CodesCategoryAccess classLevelSigner / notary
CERT-H, CERT-T, CONTEST, AF5regulatory filinggeneralprojectowner, notary
DR, DISM-T, POST1, POST2regulatory filinggeneralprojectowner
INSP-RPT, PC-RPT, ABATE-RPT, WORK-DESCreportfield_reportprojectnone
XRF-AFF, PC-AFFaffidavitfield_reportprojectinspector, notary
ABAT-AFFaffidavitfield_reportprojectabatement supervisor, notary
SAMP-AFFaffidavitfield_reportprojectsampler, notary
PC-LAB, DUST-LAB, COClab resultfield_reportprojectnone
INSP-EPA, SAMP-EPA, ABAT-EPA, CREDENTIALcredentialcredentialtenant, usernone
COFOreferencegeneralbuilding, projectnone
LEASEreferencegeneralunit, projectnone
NOVreferencegeneralbuilding, unit, projectnone
PROPOSALfinancialfinancialprojectclient
INVOICEfinancialfinancialprojectnone
OTHERothergeneralany levelnone

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:

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

FunctionWrites
generate-xrf-reportINSP-RPT, XRF-AFF
generate-paint-chip-reportPC-RPT, PC-AFF, PC-LAB
generate-dust-wipe-reportDUST-LAB
generate-abatement-reportABATE-RPT (no screen in the app calls it)
generate-cocCOC, and sets lab_chain_of_custody.document_id
generate-hpd-documentThe 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-packageEnsures those cover forms exist, then writes filing_packages rows, not a document
generate-proposal-pdfPROPOSAL
generate-invoice-pdfINVOICE

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​

ActionWhat happensCode
Share or stop sharingSets client_visible; the trigger stamps or clears shared_client_id. One update for a whole selection: if any document has no owning client, none changesetClientVisible(ids, visible)
Sign or notarizeMoves the status forward and records name and timesignDocument, 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 wassupersedeDocument(previous, file, createdBy)
VoidSets 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 itvoidDocument(id, reason)
DeleteOnly a draft that was never shared (a failed upload's own row). Everything else is voidednone

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 toChange
Add a document typeA 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 levelallowed_levels in both places
Change who reads a classThe 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" meansneedsAction() and CLIENT_DELIVERABLE_CATEGORIES in documentRules.ts; the query follows
Add a facetDocumentListFilters 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 functioninsertDocument() from _shared/documents.ts, an authorize() check, project-level documents only
Show a new column in the listDocumentsTable.tsx and documentDisplay.ts
Change reuse rulesDOCUMENT_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​

SuiteCoversRun
domain/tests/docs/*.test.tsCatalog, reuse policy, level and type rules, needsAction and its 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

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.