Status: π‘ Live Hub, config-gated signing. The Document Hub β upload, folders,
versioning, drawing-set split, plan markup, smart views β is π’ live against real
data with no external dependency. Everything that leaves the building to get
signed runs through Documenso and is real code that stays dormant until a
per-instance token is set (documenso_api_token). With no token, every
request_*_signaturecall is a silent no-op β the button "works," nothing is
sent. Webhook-driven completion is a second config gate on top of that.
Audience: engineers + ops. This is the reference for how a Job's files are
stored and organized, and how a document (a proposal, PO, change order, waiver, β¦)
becomes a cryptographically-signed PDF filed back onto the Job. Companion deep-dives:
Reports & Finance (invoices, POs, change orders that get
signed) and Integrations (the Documenso service itself).
Legend: π’ live Β· π‘ WIP / config-gated Β· βͺ dormant / stub Β· β not built.
House terms: a unit of work is a Job (jobs); the person you build for is a
Client (clients). Never "Project", never "Customer".
The system is two loosely-coupled halves that share one Document table
(app/models/document.py:26):
app/routers/documents.py, mounted under /portal. Storesapp/integrations/esign/documenso.py +app/services/esign_service.py + the Signatures registryapp/portal/router.py:1329). Turns an entity's PDF into a Documenso signingThey meet in two places: the Hub's "Send for signature" button
(documents.py:1304), and the completion hook _on_signed
(esign_service.py:457) which auto-files the signed PDF back onto the Job via
job_docs.file_signed_pdf.
Multi-file upload lands at POST /portal/documents/upload (documents.py:336). Each
file gets a fresh doc_id, its bytes are written through storage.save, and a
Document row is inserted with the storage key, content-type, size, and
kind (photo for images, else file).
The Hub renders a set of smart views over every current, non-deleted Job document
(documents.py:83 β all, job, phase, type, trade, photos, drawings,
specs, shared, tags, folders). Documents are enriched at render time with
phase/trade (pulled from their linked schedule task), a coarse type
(_doc_type, documents.py:98), and Job name β none of that is stored, it's derived
per request. Organizing tools:
| Action | Route | Notes |
|---|---|---|
| Move to folder | POST β¦/{id}/move |
documents.py:706 |
| Tag (β€12, lowercased) | POST β¦/{id}/tags |
documents.py:722 |
| Share to Client portal | POST β¦/{id}/share |
toggles shared_with_client (:1290) |
| Soft-delete / restore | POST β¦/{id}/deleteΒ·/restore |
flag only, never purges (:678) |
| Folders (create/rename/delete) | POST β¦/documents/foldersβ¦ |
delete β files fall back to unfiled (:772) |
Uploading a new version (POST β¦/{id}/new-version, documents.py:368) inserts a
new Document, flips the old one's is_current=False + superseded_at, and
chains them via supersedes_id. The version chain is walked in both directions
(_version_chain, :647); the office is notified best-effort (_notify_revision).
There's a client-side PDF.js rev-diff compare view (β¦/{id}/compare, :791).
A multipage plan PDF uploaded at POST /portal/drawings/sets/upload
(documents.py:513) is split into one single-page Document per sheet
(_split_pdf via pypdf, :420) under a DrawingSet. A background task
(_classify_set, :440) asks Gemini for each sheet's number/title/discipline (no-op
if the AI key is unset), and "issue as revision" re-chains matching sheet numbers onto
the prior set (_match_revision, :484). The drawings view renders a discipline-
ordered sheet register with open RFI/punch pin counts per sheet. RFI/punch pins
can be dropped directly on a sheet (POST β¦/{id}/pins, :619), and the plan viewer
carries markup + calibrated takeoff measurement. These are Hub-native and out of
scope for signing.
Storage is backend-agnostic (app/services/storage.py). The key is
{org_id}/{doc_id}{ext} (storage.py:29) and callers only ever use
save/read/exists/open_stream/delete. The backend is chosen by
settings.storage_backend:
local (default) β a persistent volume at STORAGE_DIR. π’spaces β an S3-compatible bucket via boto3 (configured for DigitalOceanspaces_endpoint/spaces_region/spaces_bucket/spaces_key/spaces_secret). π‘π‘ "MinIO" is a misnomer here. The code has no MinIO client β the S3 path is
written for DigitalOcean Spaces (any S3-compatible endpoint, incl. MinIO, would work
since it's plainboto3, but nothing in-repo targets MinIO). TheDocument.spaces_key
column (document.py:64) is named after Spaces but holds the key for either
backend, including local disk. Don't read the column name as "this file is in Spaces."
π‘ Retention. Deletion in the Hub is soft-delete only (
is_deletedflag).
storage.deleteexists (storage.py:96) but the Hub routes never call it, so the
underlying bytes are never purged, and there is no retention/expiry policy on
stored files or superseded versions. Flag for anyone doing a data-retention review.
app/integrations/esign/documenso.py is a thin Documenso v1 REST client. Two URLs
matter: internal calls go over the docker network to http://documenso:3000
(documenso.py:14); signer-facing links use the public
documenso_url (default https://sign.gobuild.ca, config.py:242).
create_and_send (documenso.py:33) does the whole dance: create the document β
upload the PDF to the presigned URL (rewritten to the internal host) β place a
SIGNATURE field per recipient β send. Critically it sends with
sendEmail=False β this activates the signing page without Documenso emailing the
signer, so the Client signs embedded on our branded website (the proposal /
invoice page) instead of via a second Documenso email. The signing URL is rebuilt from
the recipient token as {documenso_url}/sign/{token} (documenso.py:110). Multi-
recipient (counter-sign) is supported: a field is stacked for each and the document
only COMPLETES once every signer has signed.
The single gate:
def is_configured() -> bool:
return bool(get_settings().documenso_api_token) # documenso.py:22
π‘ Empty
documenso_api_token= silent no-op. Every orchestrator
(request_proposal_signature,request_po_signature, β¦) opens with
if not documenso.is_configured(): return None. So with no token the "Send for
signature" button, invoice-send e-sign stitch, PO/CO approval flows, and lien-waiver
requests all returnNoneand do nothing β no request row, no error surfaced to
the user. The feature looks present and does nothing. Verify a token is actually
set before assuming any org signs anything.
Orchestration lives in app/services/esign_service.py. Each entity type maps to a
SignDocType (app/models/signature.py:15) and a SignatureRequest registry row.
Every path is real and wired β but all are downstream of the token gate above:
| Doc type | Orchestrator | Callers (real wiring) | Signers | Status |
|---|---|---|---|---|
| Proposal | request_proposal_signature (:70) |
sales.py:1651, proposal_portal.py:90, ai_bar.py:1342 |
Client (single) | π’ |
| Purchase order | request_po_signature (:288) |
purchase_orders.py:165,332, procurement.py, portal/router.py:1494 |
Vendor + our rep (dual) | π’ |
| Change order | request_change_order_signature (:189) |
change_order.py:120, client_portal.py:242 |
Homeowner + our rep (dual) | π’ |
| AIA Application for Payment | request_payapp_signature (:166) |
cost.py:979 |
Owner (single) β reuses the contract doc-type |
π’ |
| Arbitrary Hub document | request_document_signature (:138) |
documents.py:1320 ("Send for signature"), employee_docs.py:95 |
Client / staff (single) | π’ |
| Invoice | request_invoice_signature (:117) |
money.py:644 (invoice-send flow) |
Client (single) | π‘ β see below |
| Lien waiver | lien_waivers.request |
lien_waivers.py/cost.py/money.py |
Claimant (single) | π’ |
π‘ Correcting the record on
request_invoice_signature. It is not an
orphan β it is called, atmoney.py:644, inside the invoice-send route
(wrapped in a best-efforttry/except). It fires whenever an invoice is emailed,
provided Documenso is configured and the invoice resolves a recipient email
(documenso.is_configured() and recipient_email,esign_service.py:119). Marked π‘
only because both conditions are commonly unmet (no token, or no billing email), in
which case it no-ops β not because it's unreachable.
Dual-signature POs and COs notify our internal counter-signer by email
(po_countersign / co_countersign templates) with their own signing link, and
approve the entity (_on_signed, esign_service.py:457) only once both signers
complete.
Lien waivers are a self-contained entity β LienWaiver, not SignatureRequest
(app/services/lien_waivers.py). The four U.S. statutory archetypes
(conditional/unconditional Γ progress/final, WAIVER_TYPES at :21) render a PDF, open
a Documenso request tagged external_id="lienwaiver:{id}", and β unlike everything else
β send with send_email=bool(claimant_email), so Documenso does email the claimant
directly. On completion the certified PDF is filed onto the Job (_file_signed_pdf,
:146). Payment-gating is advisory (warn + allow override, never blocks). Also
gated by documenso.is_configured() β None no-op when unset.
1. Trigger β request_*_signature(db, entity, name, email)
(button/send/ β is_configured()? ββ no βββΊ return None (π‘ SILENT NO-OP)
proposal view) β yes
2. Render PDF β pdf.<entity>_pdf(db, entity) (proposal/po/co/invoice/waiver)
3. Documenso β create_and_send(sendEmail=False)
create β upload β place SIGNATURE field(s) β send
4. Deliver link β sign.gobuild.ca/sign/{token}
β’ embedded on our branded website (proposals/invoices), OR
β’ emailed by GoBuild (resend/text from registry), OR
β’ emailed by Documenso (lien waivers only)
5. Client signs β Documenso holds the crypto signature + audit certificate
6. Completion lands via EITHER:
(a) public-page POLLING β esign_service.sync() [proposal/invoice pages]
(b) WEBHOOK POST /api/webhooks/documenso β handle_webhook β sync() π‘
7. _on_signed() β file certified PDF to Hub + proposalβJob conversion
/ CO approval / PO approval + notify office
sync (esign_service.py:365) does not trust the webhook body β it re-fetches the
document from the Documenso API (authoritative) and drives status
sent β viewed β signed, running _on_signed exactly once on first completion
(idempotent).
POST /api/webhooks/documenso (app/routers/webhooks.py:124):
secret = get_settings().documenso_webhook_secret
if not secret:
return Response(status_code=404) # webhooks.py:142 β feature OFF, inert
supplied = request.headers.get("X-Documenso-Secret") or ...
if not hmac.compare_digest(supplied, secret):
return Response(status_code=401)
π‘
documenso_webhook_secretunset = inert webhook, completions dropped. With no
secret the receiver returns 404 and processes nothing (webhooks.py:143). The
intended backstop is the public page's status polling (step 6a) β but polling only
exists on the proposal and invoice public pages. POs, change orders, and
arbitrary Hub documents have no public poller, so their completions are only picked
up when someone opens the Signatures registry detail view (which callssync,
portal/router.py:1364) or hits "Sync all" (/signatures/sync-all,:1459).
Net: with the webhook off, PO/CO/document signatures can sit "sent" until a human
re-syncs. This is the single biggest reliability question for the feature.
When the secret is set, the handler is defensive and idempotent: it verifies via
constant-time compare, locates the request by externalId or
documenso_document_id (find_for_webhook, esign_service.py:504), re-syncs, and
also completes any matching lien waiver in the same call
(webhooks.py:154). It always 200s on a verified payload so Documenso doesn't
auto-disable the webhook.
GET /portal/signatures (app/portal/router.py:1329, role-gated PM/office) is the
org-wide ledger of every SignatureRequest, filterable by type and status, with a PO
signature-health panel bolted on (signature_health.assess). From here staff can:
/signatures/{id}) β live-syncs on load and shows the:1384).:1414).:1436).:1459) β the manual catch-up for whenPO signature health (app/services/signature_health.py) is read-only diagnostics
that surface the mess without changing behavior: POs approved with no signature,
approved while still pending, half-signed (one of two signers done), stuck
(>5 days out, STUCK_DAYS), or errored/declined. Each SignatureRequest records
last_error + attempts (signature.py:68) so failures are surfaced, not swallowed,
and the lazy create-on-view path backs off after _MAX_AUTO_ATTEMPTS = 3
(esign_service.py:33).
Separate from signing but adjacent (it also produces PDFs the office chases):
app/services/coi.py. Sub & vendor insurance lives on SubCredential with an
expiry_date and a kind in ("liability_insurance","wcb_wsib","bond") (coi.py:23).
none | ok | expiring | expired (_status, :48; "expiring":9).run_chases, :91) emails and texts each holder whose coveragelast_chased_at). It's driven daily from the scheduler (scheduler.py:48), percoi_reminder email template + SMS.extract_coi, :151) reads a COI/WCB PDF or image with Gemini andpermits.py:783). No-op if the| Setting | Default | Effect when unset/empty |
|---|---|---|
documenso_api_token |
"" |
π‘ All signing is a silent no-op (documenso.py:22) |
documenso_url |
https://sign.gobuild.ca |
signer-facing link base (config.py:242) |
documenso_webhook_secret |
"" |
π‘ Webhook returns 404, inert β completions rely on polling / manual sync (webhooks.py:142) |
storage_backend |
local |
files on STORAGE_DIR disk; spaces switches to S3/DO Spaces (storage.py:33) |