Files
pn-new-crm/docs/documenso-build-plan.md
Matt a0e68eb060 docs: comprehensive audits + Documenso build plan + admin UX backlog
Six audit documents capture the 2026-05-06 review pass (comprehensive,
frontend, missing-features, permissions, reliability) along with the
Documenso integration audit + locked build plan that drove the bulk
of subsequent feature work.

Adds `docs/admin-ux-backlog.md` as a living tracker for the autonomous
push — every item marked DONE or REMAINING with file pointers and
scope estimates so future sessions can pick up where this one stopped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:57:53 +02:00

48 KiB
Raw Blame History

Documenso signing-flow build plan

Captures every Documenso-related piece that isn't shipped yet, in attack order. A fresh session should be able to pick this up without re-reading the whole conversation.

Companion docs:


Locked design decisions (from user, do NOT re-ask)

Q Decision
Embedded signing host portnimara.com/sign/<role>/<token> (marketing website hosts the embed page; CRM emits URLs in this format)
Initial "please sign" email Per-port admin setting eoi_send_mode: auto = send branded email immediately on generate; manual = generate + show URL + Send button
Contract / Reservation generation Upload-and-place-fields per deal only. EOI is the only template-driven flow. (Resolved Q6 — template-fallback dropped.)
Reminder cadence Manual by default. Rep clicks "Send reminder" button. Per-doc opt-in for auto-reminders at upload time. (Resolved Q1)
Document expiration Never expire. No expiresAt UI in v1. (Resolved Q2)
Approver vs CC Two concepts: APPROVER = real Documenso recipient that gates signing; Completion CC = passive recipient that only receives the signed PDF. (Resolved Q4)
Witness First-class signer role. Configurable per-document; full reminder/tracking flow. (Resolved Q7)
Per-port developer label Configurable via documenso_developer_label / documenso_approver_label. (Resolved Q8 bonus)
Multi-port template config All Documenso settings are per-port via /[portSlug]/admin/documenso (already wired)
Documenso API version Both v1 + v2 supported. Per-port config picks. v1 is prod (1.32) — primary. v2 unlocks embed + envelope
nginx CORS User applies manually. Block is in docs/documenso-integration-audit.md. Supports multi-origin via set $cors_origin regex
Signer override Hybrid — template docs (EOI) keep template-fixed signers (per-port settings fill the slots). Custom-uploaded docs (contract, reservation) get full per-deal signer customization.
Multi-berth EOI keeps existing bundle support. Contract/reservation are custom-uploaded PDFs — no PDF form-fill, just Documenso signature/initials/date fields
Test mode Reuse EMAIL_REDIRECT_TO env var (already redirects every outbound email + Documenso recipient)
Regenerate handling Match old system: 3 retries to delete prior Documenso doc with 2-second wait. Plus a confirm modal: "Retain old EOI? (default no)"
Field placement strategy Auto-detect (anchor text scanner) + manual drag-drop UI as safety net. Auto-detect populates the initial state; rep can drag/delete/reassign before sending.

What's already shipped (foundation)

Files in place; do NOT rebuild:

  • src/lib/services/port-config.ts — extended with: documenso_developer_name/email, documenso_approver_name/email, eoi_send_mode, embedded_signing_host, documenso_contract_template_id, documenso_reservation_template_id
  • src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx — admin UI exposes every Documenso knob across 5 cards
  • src/lib/email/templates/document-signing.tssigningInvitationEmail, signingCompletedEmail, signingReminderEmail with per-port branding
  • src/lib/services/document-signing-emails.service.tssendSigningInvitation, sendSigningReminder, sendSigningCompleted. Includes transformSigningUrl(rawUrl, host, role) for embed URL wrapping
  • src/lib/services/documenso-client.ts — extended DocumensoFieldType to all 11 types: SIGNATURE, FREE_SIGNATURE, INITIALS, DATE, EMAIL, NAME, TEXT, NUMBER, CHECKBOX, DROPDOWN, RADIO. Plus typed DocumensoTextFieldMeta/NumberFieldMeta/ChoiceFieldMeta interfaces and fieldTypeNeedsMeta(type) helper
  • src/components/interests/interest-eoi-tab.tsx — EOI workspace with active-doc hero, signing progress, paper-signed upload, history strip
  • src/components/interests/interest-contract-tab.tsx — Contract workspace shell with paper-signed upload + "send for signing" placeholder dialog
  • src/components/interests/interest-reservation-tab.tsx — Reservation workspace shell (clone of Contract)
  • src/components/interests/interest-tabs.tsx — stage-conditional visibility wired

What works today end-to-end: generate EOI → Documenso template path → manual link sharing (rep copies URL out of UI). What does NOT yet work: auto-send branded invitation, cascading "your turn" emails, custom-doc upload-to-Documenso, embedded signing URL emission to the website, on-completion PDF distribution.


Phase 1 — EOI generate flow polish (~3 hours)

Updated for Q1, Q4, Q6, Q8 resolutions. Adds manual-reminder endpoint, two new per-port label settings, drop of contract/reservation template settings, schema columns for completion CCs + auto-reminder. Also folds in webhook-secret hardening (Risk #7 Option A) and transformSigningUrl role mapping (Risk #5 fix).

Why first: Smallest surface area, validates the per-port eoi_send_mode setting works end-to-end, gets the cascading-email mental model in place before tackling the bigger pieces.

Tasks

  1. Auto-send wiring: in src/components/documents/eoi-generate-dialog.tsx, after handleGenerate() succeeds:

    • Fetch port's eoi_send_mode (already on getPortDocumensoConfig(portId))
    • If auto: server-side already sent the doc to Documenso with sendEmail: false. Now call new endpoint POST /api/v1/documents/[id]/send-invitation (build it) which:
      • Looks up the document's signers
      • Calls sendSigningInvitation() for the first signer (the client; signing order 1)
      • Stores sent_at timestamp on the signer row
    • If manual: do nothing. Surface the signing URL in the EOI tab + a "Send invitation" button that hits the same endpoint.
  2. Regenerate confirm modal: when EOI tab's "Generate EOI" button is clicked AND a Documenso doc already exists for this interest (activeDoc !== null):

    • Show a <Dialog> asking: "There's already an EOI in flight. Regenerating will create a new document and the existing one will be cancelled."
    • Two buttons: "Cancel" (default), "Regenerate" (destructive)
    • Below the buttons, a checkbox: "Keep the previous EOI in Documenso (don't delete)" — defaults UNCHECKED
    • On confirm: if checkbox unchecked, call voidDocument(oldId, portId) with 3 retries + 2-second wait between (mirror old system's generate-quick-eoi.ts lines 110-162). Then run the normal generate flow.
  3. Send-invitation endpoint: new file src/app/api/v1/documents/[id]/send-invitation/route.ts:

    POST /api/v1/documents/[id]/send-invitation
    Body: { recipientId?: string }  // optional — defaults to first unsigned recipient
    
    • Loads the document + signers
    • Resolves the target recipient (passed-in or first unsigned in signing order)
    • Resolves port's documenso config + the recipient's signing URL from the document_signers row
    • Calls sendSigningInvitation from the email service
    • Updates document_signers.invited_at (need to add column — see schema migration below)
  4. Schema migration: add invited_at and last_reminder_sent_at columns to document_signers:

    ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
    ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
    

    The webhook handler updates these (Phase 2). Apply via psql then restart dev server (per CLAUDE.md migration note).

Acceptance criteria

  • Setting eoi_send_mode=auto in admin → generating an EOI fires off our branded HTML email to the client immediately
  • Setting eoi_send_mode=manual → no email fires; "Send invitation" button in EOI tab hits the endpoint
  • Clicking Generate when an active EOI exists → confirm dialog with checkbox; default deletes prior doc with retries

Phase 2 — Webhook handler enhancement (~3-4 hours)

Why second: Once invitations are flowing (Phase 1), the webhook needs to track the lifecycle and fire the cascading "your turn" emails as each signer completes. Without this, the system goes silent after the initial invite.

Tasks

  1. Extend src/app/api/webhooks/documenso/route.ts to handle DOCUMENT_OPENED, DOCUMENT_SIGNED, DOCUMENT_COMPLETED (DOCUMENT_OPENED currently ignored).

  2. For DOCUMENT_SIGNED (fires when one recipient signs, can fire multiple times per doc):

    • Resolve the (port, document, signer) — existing per-port secret lookup already does this
    • Update document_signers.signed_at for the matching signer
    • Find the next unsigned signer in signing order
    • If next signer exists AND we haven't already invited them: call sendSigningInvitation() with the next signer + their signing URL + role='developer' (or 'approver' depending on signing order). Mark document_signers.invited_at for them.
    • This is the cascading "your turn" flow that mirrors client-portal/server/services/documenso-notifications.ts
  3. For DOCUMENT_OPENED:

    • Update document_signers.opened_at for the matching recipient (matched by token in payload)
    • Used for analytics later ("12% of clients open within an hour")
  4. For DOCUMENT_COMPLETED (fires once when all signers have signed):

    • Update document status='completed', completed_at=...
    • Download signed PDF: await downloadSignedPdf(documensoId, portId) (existing)
    • Store in storage backend via the file ingestion flow — this creates a files row
    • Update the document row to point at the signed file (signed_file_id)
    • Call sendSigningCompleted() with all signers + the signed file's id
    • Update the linked interest's pipeline stage:
      • If document type = eoieoi_signed
      • If document type = contractcontract_signed
      • If document type = reservation_agreement → leave stage; reservation is post-deal-close anyway
  5. Recipient-token matching: webhooks include payload.recipients[] with each recipient's token. Use the token to match against document_signers.signing_token (need to add the column if not already). Old system's webhook does this via email match — fragile when the same email serves multiple roles. Token match is robust.

  6. Idempotency: webhook can fire duplicates. Old system's acquireWebhookLock + signature comparison pattern is good. Port that logic.

Schema migration

-- Add fine-grained tracking columns to document_signers
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
ALTER TABLE document_signers ADD COLUMN opened_at timestamptz;
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
ALTER TABLE document_signers ADD COLUMN signing_token text;  -- index this

CREATE INDEX idx_ds_signing_token ON document_signers (signing_token);

Acceptance criteria

  • Client signs → developer receives our branded "your turn" email within seconds
  • Developer signs → approver receives the same
  • All signed → all three recipients receive the signed PDF as attachment
  • Interest's pipeline stage advances to eoi_signed automatically
  • Re-firing of duplicate webhooks is no-op

Phase 3 — Custom document upload-to-Documenso (~6-8 hours)

Why third: Backend foundation for contract + reservation flows. Without this, the "Upload draft for signing" CTA on those tabs is a placeholder.

Tasks

  1. New service src/lib/services/custom-document-upload.service.ts:

    export async function uploadDocumentForSigning(args: {
      interestId: string;
      portId: string;
      documentType: 'contract' | 'reservation_agreement';
      pdfBuffer: Buffer;
      filename: string;
      title: string;
      recipients: Array<{
        name: string;
        email: string;
        role: 'SIGNER' | 'APPROVER' | 'CC';
        signingOrder: number;
      }>;
      fields: DocumensoFieldPlacement[]; // from auto-detect or manual placement
    }): Promise<{ documentId: string; signingUrls: Record<string, string> }>;
    

    Steps:

    • Convert pdfBuffer → base64
    • Call createDocument(title, base64, recipients, portId) — existing client function
    • Call placeFields(docId, fields, portId) — existing client function (handles v1 + v2)
    • Call sendDocument(docId, portId) — existing
    • Return doc ID + per-recipient signing URLs
    • Mirror the timing-safe URL extraction from old system's generate-quick-eoi (recipients[].signingUrl)
    • Insert a row into our documents table with the new doc_id + signers + interest link
    • If port's eoi_send_mode === 'auto': kick off sendSigningInvitation() to first signer
  2. API endpoint: POST /api/v1/interests/[id]/upload-for-signing

    • Accepts multipart: file (the PDF), documentType, title, recipients (JSON), fields (JSON)
    • Validates: file is PDF (magic-byte check, see berth-pdf flow), recipients ≥ 1, fields ≥ 1
    • Calls service
    • Returns 201 with the new document row
  3. Update Contract + Reservation tab placeholders to open a real upload dialog (see Phase 4).

Acceptance criteria

  • Endpoint accepts a PDF + recipients + fields and returns a Documenso doc ID
  • Document appears in the Documents tab with status sent
  • v1 and v2 paths both work (same code path; client chooses based on per-port config)

Phase 4 — Recipient configurator + Field placement UI (~10-14 hours)

Why fourth: This is the BIG visual piece. Don't start until Phase 3 backend is proven via curl.

Sub-phase 4a: Recipient configurator (~2-3 hours)

UI inside a new <UploadForSigningDialog> component:

  • File picker (drag-drop + click)
  • Title input (defaults to filename minus extension)
  • Recipients list:
    • Add row → name + email + role (SIGNER/APPROVER/CC) + signing order (number, auto-increments)
    • Drag to reorder (uses dnd-kit, already in deps)
    • Delete row
    • Defaults: client (signing order 1) prefilled from interest's linked client; developer + approver prefilled from port settings
  • "Configure fields →" button advances to sub-phase 4b

Sub-phase 4b: PDF rendering (~3-4 hours)

  • Install: pnpm add react-pdf (uses pdfjs-dist under the hood; pdfme already pulls pdfjs-dist so no new dep weight)
  • Render the uploaded PDF page-by-page using <Document> + <Page> from react-pdf
  • Page navigation (prev/next, page picker)
  • Zoom controls (50%, 75%, 100%, 125%, 150%)

Sub-phase 4c: Auto-detect scanner (~4-6 hours)

New file src/lib/services/document-field-detector.ts:

export interface DetectedField {
  type: DocumensoFieldType;
  pageNumber: number;
  pageX: number; // 0-100 percent
  pageY: number;
  pageWidth: number;
  pageHeight: number;
  /** Confidence 0-1 — how sure the scanner is. */
  confidence: number;
  /** Original anchor text (for debugging / display). */
  anchorText?: string;
  /** Inferred recipient (from nearby labels). null = unassigned. */
  inferredRecipientLabel?: string | null;
}

export async function detectFields(pdfBuffer: Buffer): Promise<DetectedField[]>;

Implementation:

  • Use pdfjs-dist to extract text per page with getTextContent() — gives {str, transform: [a,b,c,d,e,f]} per text item where e,f is position in PDF user space, plus width/height
  • Anchor patterns:
    • SIGNATURE: /signature[:\s_-]+/i, /sign\s*here[:\s_-]*/i, /X\s*_{4,}/i, /signed\s*by[:\s]+/i
    • INITIALS: /initials?[:\s_-]+/i
    • DATE: /dated?[:\s_-]+/i, /date\s+of\s+signature/i
    • NAME: /(printed?\s*)?name[:\s_-]+/i, /full\s+name[:\s_-]+/i
    • EMAIL: /email[:\s_-]+/i
    • Catch-all: /_{8,}/ → if not preceded by name/email/date keyword, default to TEXT
  • For each match: place field bounding box immediately AFTER the matched text (offset 5pt right), with type-appropriate width:
    • SIGNATURE: 150pt × 30pt
    • INITIALS: 50pt × 30pt
    • DATE: 80pt × 20pt
    • NAME: 150pt × 20pt
    • EMAIL: 200pt × 20pt
    • TEXT: 200pt × 20pt
  • Convert to PERCENT (divide by page width/height)
  • Recipient inference: scan ±100pt of the field for labels like "Buyer", "Seller", "Client", "Developer", "Witness", "Notary". Map to recipient by role.

Sub-phase 4d: Drag-drop overlay (~3-4 hours)

  • Overlay absolute-positioned divs on top of the PDF viewer for each field
  • Each field shows: type icon + recipient color + delete (×) handle + drag affordance
  • Use dnd-kit to enable drag — update pageX/pageY in state on drop
  • Field palette toolbar: 11 buttons (one per Documenso field type) — click to enter "place mode" → next click on the PDF places a new field at that coord
  • Side panel for selected field:
    • Type changer (dropdown)
    • Recipient assignment (dropdown of configured recipients)
    • Required toggle
    • Per-type config (TEXT label, NUMBER min/max, CHECKBOX/DROPDOWN/RADIO options) — drives fieldMeta
    • Width/height inputs
    • Delete button

Sub-phase 4e: Send (~1 hour)

"Send for signing" button:

  • Validates: ≥1 recipient, ≥1 field, every field has a recipient assigned
  • POSTs to /api/v1/interests/[id]/upload-for-signing (Phase 3)
  • On success, closes dialog and refreshes the Contract/Reservation tab

Acceptance criteria

  • Upload a draft PDF → auto-detect runs → fields appear overlaid in their detected positions
  • Rep can drag any field to reposition (state updates, persists to backend on send)
  • Rep can change a field's type, recipient, or metadata via side panel
  • Rep can add new fields by clicking palette button + clicking on PDF
  • Rep can delete fields they don't want
  • Click Send → fields ship to Documenso, signing flow starts, Contract tab shows the active doc

Phase 5 — Embedded signing URL emission verification (~1-2 hours)

Why later: The Vue page on the marketing website already exists. This phase is a verification + documentation pass, not a code build.

Tasks

  1. Verify URL transformation matches website expectations:

    • Website route: /sign/[type]/[token] where type ∈ {client, cc, developer}
    • Our transformSigningUrl() emits /sign/<role>/<token> where role can be client | developer | approver | witness | other
    • Mismatch: website only handles client | cc | developer. Our email service may emit approver (which the website doesn't route).
    • Fix: either (a) update website's [type].vue to accept approver (and witness | other if needed), OR (b) map our role names to the website's expected names in transformSigningUrl().
  2. For contract + reservation document types: the website's signerMessages map only covers EOI-specific copy. When a contract goes out for signing and the recipient hits portnimara.com/sign/client/<token>, the page would show "Sign Your Expression of Interest" — wrong copy.

    • Fix: add document-type to the URL too: /sign/<docType>/<role>/<token>. Update website's signerMessages to be keyed on (docType, role).
  3. Webhook callback URL: website POSTs to client-portal.portnimara.com/api/webhook/document-signed after signing. The new CRM is at a different domain. Update website's handleDocumentSigned to POST to the new CRM's webhook (a thin "client confirmed sign" notification, separate from Documenso's own webhook).

  4. Apply nginx CORS block — already documented in docs/documenso-integration-audit.md. Apply via ssh when user grants access.

Acceptance criteria

  • Embedded URL points at a working website page that loads the right Documenso embed for any document type / role combo
  • Post-sign callback updates our document_signers row (redundant with the Documenso webhook but useful as a real-time UI signal)

Phase 6 — Polish & deferred items (~2-3 hours each, do as needed)

  • auto send mode delay: optional per-port eoi_send_delay_minutes setting. When set, the auto-send fires after N minutes (BullMQ scheduled job) so the rep can review + cancel during the window. Default 0 (immediate).
  • Audit log entries: every Documenso-related action (generate, send, remind, cancel, sign-event-received) writes to audit_logs with structured metadata. Mostly already there for the existing flow; extend to cover Phase 1-3 additions.
  • Per-document customization of email copy: rep can override the default signing-invitation body before send. New textarea in the upload dialog. Stored as documents.invitation_message.
  • Document expiration: Documenso supports expiresAt. Surface as a per-document field in the upload dialog.
  • Reminder rate-limit display: surface "next reminder available in X days" on each unsigned signer in the signing-progress UI.
  • Failed-webhook recovery UI: admin page showing webhooks that errored, with a "Replay" button. Old system has the foundation; CRM doesn't.

Phase 7 — Project Director role + RBAC layer (~6-8 hours)

Surfaced from Q8 conversation. The developer signer slot is conceptually the "Project Director" — the person at the port who countersigns deals on behalf of the port. Today every CRM user is either a sales rep or admin; there's no Project Director user role. Attack alongside the Documenso build because (a) the Documenso developer-label setting is meaningless if no user actually has the role, and (b) a few permissions naturally cluster around it.

What a Project Director needs (vs sales rep)

Capability Sales rep Project Director Admin
Generate EOI / contract / reservation
Approve / sign as the "developer" recipient on Documenso — (unless also designated PD)
View own deals
View other reps' deals
View audit logs (read-only)
Trigger CSV / report exports
Re-assign deals between reps
Edit per-port settings
Manage users + invitations
Manage Documenso config

So Project Director sits between sales rep and admin: read-everywhere + a few action capabilities (re-assign, export, sign-as-PD), but no settings/user management.

Tasks

  1. Add project_director to the role enum in src/lib/db/schema/users.ts (or wherever port_roles enum lives). Existing role values (sales, admin, super_admin) stay; this is additive.

  2. Permission flags: extend the per-port permissions matrix (src/lib/auth/permissions.ts or equivalent) with new flags:

    • viewAllDeals — true for project_director, admin, super_admin
    • viewAuditLogs — true for project_director, admin, super_admin
    • exportReports — true for project_director, admin, super_admin
    • reassignDeals — true for project_director, admin, super_admin
    • signAsProjectDirector — true for project_director only (admin can sign as PD only if also assigned the role on this port)

    These flags get checked in the relevant API handlers via the existing withPermission() middleware.

  3. Documenso developer-slot binding: per-port admin UI gets a "Project Director user" dropdown alongside the existing developer-name/email free-text inputs. When a real CRM user is selected, the admin UI:

    • Populates documenso_developer_name/email from the user's profile (read-only when bound)
    • When that user signs an EOI/contract via Documenso, the webhook handler can match by user-email and update the in-CRM signing UI in real time (signer chip turns green for them specifically)
    • Free-text fallback stays for ports without a CRM-PD user yet
  4. User invitations + role selection: extend src/components/admin/invite-user-dialog.tsx to surface "Project Director" alongside Sales / Admin as a selectable role at invitation time.

  5. Audit-log access: surface a new /[portSlug]/admin/audit-log route (or extend the existing one's permission gate) so Project Directors can read but not write. Hide write controls for non-admins.

  6. Reports page permission gate: existing /[portSlug]/reports (or wherever exports live) checks exportReports permission flag instead of admin-only.

  7. Re-assign deals UI: add a "Re-assign owner" action on the interest detail page, gated by reassignDeals. Writes to interests.owner_user_id (or whatever the assigned-rep field is) and audit-logs the change.

Schema migration

-- Add project_director as a valid role; depends on how roles are stored.
-- If port_roles uses an enum:
ALTER TYPE port_role ADD VALUE 'project_director';
-- Or if it's a text column with check constraint:
ALTER TABLE port_roles DROP CONSTRAINT port_roles_role_check;
ALTER TABLE port_roles ADD CONSTRAINT port_roles_role_check
  CHECK (role IN ('sales', 'admin', 'super_admin', 'project_director'));

-- Optional: link the per-port Documenso developer slot to a real user
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS user_id text REFERENCES users(id) ON DELETE SET NULL;
-- (Used for the documenso_developer_user_id setting; null for free-text fallback)

Acceptance criteria

  • A user invited as project_director can view all deals across the port (not just their own), read audit logs, trigger exports, and re-assign deals — but cannot edit settings or invite users
  • Admin can bind a CRM user to the per-port Documenso developer slot; the user's name + email auto-populate in invitations and emails
  • Non-PD users cannot trigger PD-only actions (server returns 403; UI hides the controls)
  • Existing sales / admin / super_admin permissions are unchanged

Why attack at the same time as the Documenso build

  • Both touch port-config.ts and admin/documenso/page.tsx — fewer rebases if done in one push
  • The documenso_developer_label setting (Q8 bonus) and the PD-user binding overlap; doing them together avoids re-touching the same admin card twice
  • The Documenso webhook's per-signer matching benefits from having a real users.email to bind against, not just a free-text developer name

Out of scope (defer to a later RBAC pass)

  • Custom permission templates (e.g. "PD with no audit-log access")
  • Per-deal ACLs (sharing a single interest with another rep)
  • Time-bound role grants
  • Cross-port role overrides for super_admin

Risks + decisions (resolved through code review)

Each entry below was checked against the current code. The original "open question" form is preserved in italics for traceability; the Decision is what the next session should implement.


1. fieldMeta on Documenso v1.32

Q: Does v1.32 silently ignore unknown properties, or does it reject the request?

Decision: not a risk in current code. src/lib/services/documenso-client.ts:491-501 shows the v1 path constructs its own body containing only recipientId, type, pageNumber, pageX/Y/Width/HeightfieldMeta is never sent on v1. The code comment at line 341-344 is misleading — update it. Action for next session: change the comment to "v1 does not receive fieldMeta (we never send it). v1 renders TEXT/NUMBER/CHECKBOX/DROPDOWN/RADIO as blank inputs; if the per-port admin chose v1 the field UI should warn 'Configurable field types require Documenso v2'." The placement UI in Phase 4d should disable the meta-config side panel when the resolved port is on v1.

2. PDF dimension extraction (non-A4 contracts)

Q: How do we get real page dimensions on the v1 path?

Decision: parse the PDF with pdf-lib in the upload service before calling placeFields(). pdf-lib is already a transitive dep via the EOI form-fill flow (src/lib/pdf/fill-eoi-form.ts). Concrete change for Phase 3:

// In src/lib/services/custom-document-upload.service.ts
import { PDFDocument } from 'pdf-lib';
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pageDims = pdfDoc.getPages().map((p) => {
  const { width, height } = p.getSize();
  return { width, height };
});
// Pass to placeFields as a per-page dimension map override

Then extend placeFields signature to accept an optional pageDimensionsOverride?: DocumensoPageDimensions[] (one entry per page). When provided, the v1 path uses pageDimensionsOverride[fieldPageIndex] instead of getPageDimensions()'s A4 default. Falls back to A4 when override is missing — keeps the EOI template path (which IS A4) unchanged.

3. Multi-page signature blocks not picked up by auto-detect

Q: What's the recovery path if the scanner misses a signature block on the last page?

Decision: not a risk — by design. Phase 4d's drag-drop overlay + field palette is the explicit fallback. Auto-detect populates initial state; rep MUST be able to add fields manually. The acceptance criterion at the end of Phase 4 already covers this. Demoted from "risk" to "design note": every page must be reachable in the PDF viewer (Phase 4b's page navigation) and the field palette must be enabled even on auto-detected pages.

4. Webhook payload differences v1 vs v2

Q: Does our webhook handler decode both v1 and v2 payload shapes correctly?

Decision: partially confirmed; finish the audit in Phase 2. Confirmed working today:

  • Secret transport: identical (X-Documenso-Secret plaintext) — see route.ts:53
  • Event names: both versions send the uppercase Prisma enum (DOCUMENT_SIGNED); CLAUDE.md note documents this. The route also normalizes lowercase-dotted variants for forward-compat.
  • Top-level shape { event, payload: { id, ... } }: same on both versions

Still unverified (defer to Phase 2 implementation):

  • v2 may rename payload.idpayload.documentId and recipient.idrecipient.recipientId (mirrors the API-response rename — see src/lib/services/documenso-client.ts normalizeDocument()). Apply the same dual-field read pattern in the webhook handler: const docId = payload.documentId ?? payload.id.
  • v2 may include payload.envelopeId instead of payload.id for envelope-level events (DOCUMENT_COMPLETED). Read both.
  • Recipient token field: v1 uses recipient.token; v2 may differ. Phase 2's token-based matching (step 5) needs to handle both.

Test with a v2 instance during Phase 2; until then keep the per-port API version setting on v1 only.

5. approver role → cc URL mapping

Q: How do we keep the website's signing page (which only routes client | cc | developer) working when our SignerRole includes approver | witness | other?

Decision: confirmed bug in current code; fix in Phase 5. Website route validation explicitly redirects to /sign/error for any signerType not in ['client', 'cc', 'developer']. Our transformSigningUrl() emits ${host}/sign/${signerRole}/${token} with the raw SignerRole value. Today, an approver invite would land on /sign/error.

Concrete fix in transformSigningUrl():

const ROLE_TO_URL_SEGMENT: Record<SignerRole, 'client' | 'cc' | 'developer'> = {
  client: 'client',
  developer: 'developer',
  approver: 'cc', // legacy: approver showed as "EmbeddedSignatureLinkCC"
  witness: 'cc', // route through cc page; copy needs a witness override (Phase 5)
  other: 'cc',
};
const urlRole = ROLE_TO_URL_SEGMENT[signerRole];
return `${host}/sign/${urlRole}/${token}`;

Two follow-ups for Phase 5:

  • Add the mapping above to transformSigningUrl() — DO this in Phase 1 already since Phase 1 fires the first invitation email.
  • Update website's signerMessages (currently EOI-specific) to be keyed on (documentType, signerType) so contract+reservation invites get the right copy — see Phase 5 task 2.

6. Storage backend for signed PDFs

Q: Does the on-completion download in Phase 2 use the pluggable storage backend?

Decision: confirmed — pattern already established, just follow it. getStorageBackend() is used by 9 services in the codebase (berth-pdf, brochures, expense-pdf, invoices, gdpr-export, reports, document-templates, document-sends, email-compose). The documents schema already has the signedFileId column with index idx_docs_signed_file_id. Phase 2 step 4 is just: const buffer = await downloadSignedPdf(docId, portId); const file = await ingestFile({ buffer, portId, ... }); await db.update(documents).set({ signedFileId: file.id }).... Demoted from "risk" to "implementation note" inside Phase 2.

7. Cross-port webhook secret collision

Q: Can two ports happen to share the same webhook secret?

Decision: real risk — fix at write-time, not schema. system_settings is unique on (key, port_id), so the same key+port combo is enforced unique, but there's no global uniqueness on the value. The webhook handler iterates all configured secrets and breaks on first match — if two ports paste the same secret, the second port's webhooks get attributed to the first. Three options, in preference order:

Option A (recommended): generate, never paste. Replace the textbox in admin/documenso/page.tsx for documenso_webhook_secret with a "Generate secret" button that calls crypto.randomBytes(32).toString('base64url') server-side and writes it. Display once, mask after. Collision probability is negligible. Admin still has a "Regenerate" button for rotation.

Option B: warn at write. Keep the textbox but on PUT to the setting, query system_settings WHERE key='documenso_webhook_secret' AND value=? and fail with a 409 if any other port has this value. Cheap, defensive, but exposes that a value exists somewhere.

Option C: schema-level enforcement. Add a partial unique index CREATE UNIQUE INDEX system_settings_documenso_secret_unique ON system_settings (value) WHERE key = 'documenso_webhook_secret'. Strongest, but requires careful ordering during port-clone or restore-from-backup operations.

Pick Option A. Add to Phase 1 as a polish item — small change, eliminates the risk class.


Open questions — RESOLVED 2026-05-07

All 10 questions plus the bonus role-label question have user-locked answers. Implementation must follow these decisions; do not re-litigate.

Q1. Reminder cadence — RESOLVED

Decision: Manual reminders by default. Rep clicks a "Send reminder" button in the EOI/Contract tab. Per-document opt-in: rep can configure auto-reminders on a specific doc at send time (e.g. "remind every 7 days until signed").

Implications:

  • No port-wide reminder schedule setting needed.
  • Phase 1 / 2: skip the BullMQ scheduled-reminder job for now. Add a POST /api/v1/documents/[id]/send-reminder endpoint that calls sendSigningReminder() for the next-pending signer. Track last_reminder_sent_at to enforce Documenso's 24h rate limit on the UI ("Next reminder available in X").
  • Phase 4a (upload dialog): add an optional "Auto-reminder schedule" field — None (default) / Every 3d / Every 7d. When set, store on documents.auto_reminder_interval_days; a once-daily worker iterates unsigned documents and fires due reminders.

Q2. Document expiration — RESOLVED

Decision: Never expire by default. No expiration UI in v1. Skip Documenso's expiresAt entirely.

Reasoning: link expiration doesn't help the regenerate flow (regen already voids+recreates). Adding the UI is overhead with no immediate user benefit.

Implications:

  • Phase 3 uploadDocumentForSigning: don't expose expiresAt.
  • Phase 4a recipient configurator: no expiration field.
  • Phase 6 deferred-items list: drop the "Document expiration" item.

Q3. Auto-detect confidence threshold — RESOLVED

Decision: Default ≥0.8 silent / 0.50.8 flagged / <0.5 drop, with the drag-drop overlay (Phase 4d) as the universal fix mechanism — rep can reposition or delete any auto-placed field.

Implications:

  • Phase 4c scanner: emit DetectedField.confidence; threshold checks live in the UI layer (Phase 4d) so they're easy to tune.
  • Phase 4d overlay: flagged fields render with a yellow border + "?" badge; rep can click to confirm-as-correct (clears the badge) or drag/delete.

Q4. Approver semantics — RESOLVED

Decision: TWO concepts, not one.

  1. APPROVER = real Documenso APPROVER recipient. Gates signing flow (e.g. client signs → approver approves → developer signs). Configured per-port (existing documenso_approver_name/email settings).
  2. Completion CC = passive recipient. Does NOT participate in signing. Receives only the final signed PDF as attachment when the doc completes. Set per-document by the rep at send time.

Implications:

  • Phase 3 uploadDocumentForSigning recipients: support role: 'SIGNER' | 'APPROVER' | 'CC'. CCs are NOT created as Documenso recipients — they're stored on documents.completion_cc_emails (text array) and emailed by our own service when DOCUMENT_COMPLETED webhook fires.
  • Phase 4a recipient configurator: split into two sections:
    • Signing recipients: name + email + role (Signer / Approver) + signing order
    • Copy on completion (CC): just email addresses, comma-separated
  • Phase 2 step 4 (on-completion email distribution): include documents.completion_cc_emails recipients with the signed PDF. Dedup by email (see Q5).
  • Schema migration: ALTER TABLE documents ADD COLUMN completion_cc_emails text[] DEFAULT '{}'::text[];

Q5. On-completion PDF distribution — RESOLVED

Decision: All signing recipients + rep who generated + per-deal CC, deduplicated by email address.

Implications:

  • Phase 2 step 4: build the recipient list as union of (a) all document_signers for this doc, (b) the user who created the doc (documents.createdByusers.email), (c) documents.completion_cc_emails. Lowercase + dedupe before calling sendSigningCompleted.
  • Common case (rep IS the approver): one email, not two.
  • Per-port distribution list (originally proposed) is NOT needed — the per-deal CC field covers it. If a port wants legal@portnimara.com on every deal, the rep types it once per doc; if it's truly always-on, add a port-default later (deferred to Phase 6).

Q6. documenso_contract_template_id / documenso_reservation_template_id — RESOLVED

Decision: DROP both settings. EOI is the only template-driven flow. Contracts and reservations are custom-uploaded per deal — no template fallback.

Implications:

  • Remove documenso_contract_template_id and documenso_reservation_template_id from port-config.ts SETTING_KEYS and PortDocumensoConfig type.
  • Remove the corresponding fields from admin/documenso/page.tsx. Card title becomes "Templates" with just the EOI template ID field.
  • Phase 3: contract/reservation tabs go straight into the upload dialog — no if (templateId) { ... } branch.
  • Locked design decisions table at top of this doc: update the "Contract / Reservation generation" row to remove the template-fallback option.

Q7. Witness role — RESOLVED

Decision: First-class. Configurable per-document at generation time. Witness goes through the full invitation/reminder/tracking flow same as any other signer; signs the document attesting to having witnessed.

Implications:

  • Keep witness in SignerRole.
  • Phase 4a recipient configurator: "Witness" is a selectable role in the role dropdown (alongside Signer / Approver / CC).
  • Phase 5 website edit: add witness copy to signerMessages map ("Witness this signing of…"). Add witness to the validated role list at line 175 of [type]/[token].vue — currently ['client', 'cc', 'developer'], becomes ['client', 'cc', 'developer', 'witness'].
  • Risk #5 mapping in transformSigningUrl(): witness → 'witness' (NOT mapped to cc). Update the role-to-URL-segment table accordingly.
  • Witness gets the same reminder/auto-reminder support as any signer — no special-casing.

Q8. Multiple developers/approvers per port — RESOLVED (with rename)

Decision: Stay single per port for the standard developer and approver slots. If a port needs more on a custom doc, the rep adds extra signers via the upload-for-signing dialog (Phase 4a recipient configurator).

Plus the bonus: the per-port "developer" label IS configurable via a new documenso_developer_label setting (default: "Developer"). Used in email subjects, signer chips, and signing-progress UI. Backend type-name stays developer so no schema churn.

Implications:

  • Add documenso_developer_label and documenso_approver_label to SETTING_KEYS + PortDocumensoConfig.
  • Admin UI in documenso/page.tsx Signers card: each signer card gets a "Display label" input next to name/email.
  • Email templates in document-signing.ts: read the label from the per-port branding config and use it in copy ("Your Project Director, {{name}}, has signed…").
  • Open follow-up (out of scope for Documenso build): the user mentioned the project-director user MIGHT need different CRM permissions/access from a sales rep (e.g. exclusive audit-log access, more prominent reports). That's a separate RBAC initiative — note it on the audit backlog and don't action here.

Q9. Field placement draft persistence — RESOLVED

Decision: No persistence. If the rep closes the dialog mid-placement, state is lost.

Implications:

  • Phase 4 architecture: keep all placement state in React component state. No localStorage, no DB drafts table.
  • Add a confirm-close on the dialog if the rep has placed any fields ("Discard placement work?").

Q10. Embedded signing host fallback — RESOLVED

Decision: Send raw Documenso URLs when host is unset. The Documenso API already returns a working signing URL per recipient (e.g. https://signatures.portnimara.dev/sign/<token>); transformSigningUrl() returns this raw URL untouched when embeddedSigningHost is null/empty (current behaviour, see document-signing-emails.service.ts:106-117).

Implications:

  • Phase 1: no behaviour change in transformSigningUrl(). The current null-host short-circuit IS the fallback.
  • Add a banner in the EOI/Contract tab when port has unset embedded_signing_host and at least one outstanding doc: "Signing emails currently link to signatures.portnimara.dev directly. Configure an embedded host in admin for branded signing pages."
  • No new env var. No blocking-on-send.

Schema migration summary (resolved)

Combining all resolved decisions, the migrations needed are:

-- Phase 1 (also covers Phase 2's lifecycle tracking)
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
ALTER TABLE document_signers ADD COLUMN opened_at timestamptz;
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
ALTER TABLE document_signers ADD COLUMN signing_token text;
CREATE INDEX idx_ds_signing_token ON document_signers (signing_token);

-- Phase 1 / Q4 (completion CCs are per-document)
ALTER TABLE documents ADD COLUMN completion_cc_emails text[] DEFAULT '{}'::text[];

-- Phase 1 / Q1 (auto-reminder opt-in per document)
ALTER TABLE documents ADD COLUMN auto_reminder_interval_days integer;

Settings to add / remove (resolved)

Add to SETTING_KEYS + PortDocumensoConfig:

  • documenso_developer_label (text, default "Developer") — Q8 bonus
  • documenso_approver_label (text, default "Approver") — Q8 bonus

Remove from SETTING_KEYS + PortDocumensoConfig:

  • documenso_contract_template_id — Q6
  • documenso_reservation_template_id — Q6

Remove from admin UI (admin/documenso/page.tsx):

  • Contract template ID input — Q6
  • Reservation template ID input — Q6

Add to admin UI:

  • Display-label inputs next to developer + approver name/email pairs — Q8 bonus

Status: Plan is now fully resolved. Phase 1 can start without further clarification.


Quick file reference

Existing — modify in place:

  • src/lib/services/documenso-client.ts (extend createDocument for v2; add recipient management functions)
  • src/lib/services/port-config.ts (no changes expected)
  • src/lib/email/index.ts (consider: add raw-Buffer attachment option to skip MinIO round-trip for one-off PDFs)
  • src/app/api/webhooks/documenso/route.ts (Phase 2 — major rewrite)
  • src/components/interests/interest-contract-tab.tsx (replace ComingSoonDialog with UploadForSigningDialog in Phase 4)
  • src/components/interests/interest-reservation-tab.tsx (same)
  • src/components/documents/eoi-generate-dialog.tsx (Phase 1 — add regenerate confirm)

New files to create:

  • src/lib/services/custom-document-upload.service.ts (Phase 3)
  • src/lib/services/document-field-detector.ts (Phase 4c)
  • src/components/documents/upload-for-signing-dialog.tsx (Phase 4)
  • src/components/documents/pdf-field-canvas.tsx (Phase 4b/4d)
  • src/components/documents/recipient-configurator.tsx (Phase 4a)
  • src/components/documents/field-palette-toolbar.tsx (Phase 4d)
  • src/components/documents/field-config-side-panel.tsx (Phase 4d)
  • src/app/api/v1/documents/[id]/send-invitation/route.ts (Phase 1)
  • src/app/api/v1/interests/[id]/upload-for-signing/route.ts (Phase 3)
  • DB migrations for document_signers.invited_at etc. (Phase 1, Phase 2)