# 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:**

- [docs/documenso-integration-audit.md](./documenso-integration-audit.md) — what's already built, v1/v2 endpoint mapping, nginx CORS block
- Old system reference: [client-portal/server/api/eoi/generate-quick-eoi.ts](../client-portal/server/api/eoi/generate-quick-eoi.ts), [client-portal/server/api/webhooks/documenso.post.ts](../client-portal/server/api/webhooks/documenso.post.ts), [client-portal/server/services/documenso-notifications.ts](../client-portal/server/services/documenso-notifications.ts), [Port Nimara/Website/pages/sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)

---

## 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](./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.ts` — `signingInvitationEmail`, `signingCompletedEmail`, `signingReminderEmail` with per-port branding
- `src/lib/services/document-signing-emails.service.ts` — `sendSigningInvitation`, `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`:

   ```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`:
   ```sql
   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 = `eoi` → `eoi_signed`
     - If document type = `contract` → `contract_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

```sql
-- 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`:

   ```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`:

```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](./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

```sql
-- 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](../src/lib/services/documenso-client.ts#L491) shows the v1 path constructs its own body containing only `recipientId, type, pageNumber, pageX/Y/Width/Height` — `fieldMeta` is never sent on v1. The code comment at [line 341-344](../src/lib/services/documenso-client.ts#L341) 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](../src/lib/pdf/fill-eoi-form.ts)). Concrete change for Phase 3:

```ts
// 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](../src/lib/services/documenso-client.ts#L427). 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](../src/app/api/webhooks/documenso/route.ts#L53)
- 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.id` → `payload.documentId` and `recipient.id` → `recipient.recipientId` (mirrors the API-response rename — see [src/lib/services/documenso-client.ts](../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](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue#L175) explicitly redirects to `/sign/error` for any `signerType` not in `['client', 'cc', 'developer']`. Our [transformSigningUrl()](../src/lib/services/document-signing-emails.service.ts#L106) emits `${host}/sign/${signerRole}/${token}` with the raw `SignerRole` value. Today, an `approver` invite would land on `/sign/error`.

Concrete fix in `transformSigningUrl()`:

```ts
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()`](../src/lib/storage/index.ts) 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](../src/lib/db/schema/documents.ts) 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](../src/lib/db/schema/system.ts#L137) 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](../src/app/api/webhooks/documenso/route.ts#L62) 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](<../src/app/(dashboard)/[portSlug]/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.5–0.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.createdBy` → `users.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](../src/lib/services/document-signing-emails.service.ts#L106)).

**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:

```sql
-- 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)
