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>
This commit is contained in:
2026-05-07 20:57:53 +02:00
parent 05babe57a0
commit a0e68eb060
8 changed files with 3008 additions and 0 deletions

196
docs/admin-ux-backlog.md Normal file
View File

@@ -0,0 +1,196 @@
# Admin / settings UX backlog — STATUS
Living tracker for the admin/UX backlog. Items are marked DONE or
REMAINING based on what landed in the autonomous-push session.
---
## DONE in the autonomous push
### Foundations
- **Currency API verified end-to-end**. `scripts/test-currency-api.ts`
fetches live Frankfurter rates → upserts → reads back → converts.
Inverse-rate drift confirmed at ≤0.001.
- **Storage abstraction audit complete**. Every byte path
(signed EOIs, contracts, brochures, berth PDFs, files, avatars,
branding logos) goes through `getStorageBackend()`. `/api/ready`
and the system-monitoring health probe now check the active
backend (S3 or filesystem) instead of always probing MinIO.
### User settings
- Country + Timezone selectors with cross-defaulting + auto-detect
banner ("Looks like you're in Europe/Paris — Update?")
- Email change with verification flow (`user_email_changes` table,
`/api/v1/me/email/confirm/<token>`, `/api/v1/me/email/cancel/<token>`)
- Password reset triggered via better-auth `requestPasswordReset`
- Profile photo upload + crop (square 256×256) via shared
`<ImageCropperDialog>` + `/api/v1/me/avatar`
### Branding
- Logo upload + crop modal in admin/branding (uses the same shared
cropper, persists via `/api/v1/admin/settings/image` → storage backend)
- Email header/footer HTML defaults injectable via "Insert default" button
- Brand colour picker, app-name field, logo URL all in one card
### Storage admin
- New layout: S3 config form FIRST, swap action SECOND
- Test connection button before any switch
- Two-button switch: "Switch + migrate" vs "Switch only" with warning modal
- `runMigration()` honours `skipMigration` flag
### Backup management
- Real `/admin/backup` page driven by new `backup_jobs` table
- `runBackup()` service spawns `pg_dump --format=custom`, streams to
active storage backend, records size + path
- Download button presigns the .dump for offline restore
- Super-admin gated
### AI admin panel
- Dedicated `/admin/ai` page consolidating master switch +
monthly token cap + provider credentials
- Per-feature settings (OCR, berth-PDF parser, recommender)
linked from the same page
### Onboarding
- Real `/admin/onboarding` page with auto-checked steps
- Reads each setting key + lists endpoint (roles / users / tags) to
decide completion
- Manual checkboxes for steps without an auto-detect signal
- Progress bar + "Mark done"/"Mark incomplete" buttons
- State persisted in `system_settings.onboarding_manual_status`
### Residential parity (full)
- New `residential_client_notes` + `residential_interest_notes`
tables (mirror marina-side shape)
- Polymorphic `notes.service.ts` extended with two new entity types
through verifyParent + listForEntity + create + update + delete
- New `<NotesList>` accepts `residential_clients` /
`residential_interests` entity types
- Activity endpoints: `/api/v1/residential/clients/[id]/activity` +
`/api/v1/residential/interests/[id]/activity`
- Notes endpoints: 4 new routes covering GET/POST/PATCH/DELETE
- `residential-client-tabs.tsx` + `residential-interest-tabs.tsx`
built using the marina-side `DetailLayout` pattern (Overview +
Notes + Activity tabs, Interests tab on the client)
- Detail header components mirror the marina-side strip
- `useBreadcrumbHint` wired into both detail components
### Residential pipeline stages — configurable
- New `residential-stages.service.ts` with list/save + orphan-check
- `/api/v1/residential/stages` GET/PUT
- `/admin/residential-stages` admin UI with reassign-on-remove
modal (select new stage per affected interest before save)
- Validators relaxed from `z.enum(...)` to `z.string()` so any
admin-defined stage id round-trips
### Documenso Phase 1 (EOI generate flow polish)
- Schema migrations applied:
`document_signers.invited_at / opened_at / last_reminder_sent_at / signing_token`,
`documents.completion_cc_emails / auto_reminder_interval_days`
- `transformSigningUrl()` now maps SignerRole → URL segment correctly
(approver→cc, witness→witness) so emails don't land on `/sign/error`
- New `POST /api/v1/documents/[id]/send-invitation` endpoint with
next-pending-signer auto-pick
- Per-port settings added: `documenso_developer_label`,
`documenso_approver_label`, `documenso_developer_user_id`,
`documenso_approver_user_id` (Phase 7 RBAC binding fields)
### Misc UI/UX
- Sidebar collapse removed (always expanded)
- Audit log filter inputs sized + dates widened
- Custom Settings section got a long-form description
- Reminder digest timezone uses `TimezoneCombobox`
- Port form: currency dropdown + timezone combobox + brand color
- Permissions count badge opens a modal with granted/denied
- Role names display-normalized via `prettifyRoleName`
- Sales email config: token list + tooltips on threshold + body fields
- Custom Fields page: amber heads-up about non-integration with
search / recommender / audit / merge tokens
- Tag form: native `<input type="color">`
- FilterBar Select crash fixed (no empty-string item values)
---
## REMAINING — large pieces that didn't fit this push
### 1. Documenso Phase 2 — Webhook handler enhancement (~3-4 hours)
Cascading "your turn" emails when each signer completes; on-completion
PDF distribution; token-based recipient matching; idempotency lock.
File to extend: `src/app/api/webhooks/documenso/route.ts`. The
schema columns are already in place (Phase 1).
### 2. Documenso Phase 3 — Custom doc upload-to-Documenso (~6-8 hours)
Backend service `custom-document-upload.service.ts` + endpoint
`POST /api/v1/interests/[id]/upload-for-signing`. Accepts a PDF +
recipient list + field-placement JSON, calls `createDocument`
`placeFields``sendDocument` on the per-port Documenso client.
Persists a row in `documents` table.
### 3. Documenso Phase 4 — Field placement UI (~10-14 hours)
The biggest piece. Needs:
- 4a: Recipient configurator dialog (~2-3h)
- 4b: PDF rendering with `react-pdf` (~3-4h)
- 4c: Auto-detect anchor scanner via `pdfjs-dist.getTextContent` (~4-6h)
- 4d: Drag-drop overlay using `dnd-kit` (~3-4h)
- 4e: Send button → calls Phase 3 endpoint (~1h)
Plan locked in `docs/documenso-build-plan.md` Phase 4 — the
field-detector regexes, the anchor patterns, and the type-to-bbox
sizing table are all spelled out.
### 4. Documenso Phase 5 — Embedded signing URL emission verification (~1-2 hours)
Verify the website's `/sign/<type>/<token>` page handles every signer
role + every documentType combination. Update website's
`signerMessages` map keyed on `(documentType, role)`. Apply the
nginx CORS block from `docs/documenso-integration-audit.md`.
### 5. Documenso Phase 6 — Polish items (deferred)
Auto-send delay, audit-log additions, per-document customisation,
document expiration, reminder rate-limit display, failed-webhook
recovery UI. Each ~2-3 hours; all deferred until Phases 1-4 ship.
### 6. Project Director — UI binding for the developer-user fields
Schema + setting keys are now in place
(`documenso_developer_user_id`, `documenso_approver_user_id` +
`documenso_developer_label` / `_approver_label`). The remaining
work is: add a "Linked to CRM user" dropdown in
`/admin/documenso/page.tsx` that lists port users; when bound,
auto-fill name/email from the user profile and mark name/email
fields read-only. Webhook handler can then match against the
linked user's email for in-CRM signing-status updates.
### 7. Custom-fields hardening (~ongoing)
Remediation paths for the heads-up banner concerns:
- **Search index**: extend the GIN tsvector to include
customFieldValues content
- **Audit diff**: extend `diffEntity` to walk the
customFieldValues blob
- **Merge tokens**: add `{{custom.<fieldName>}}` handling at
template-render time, plus surface them in the merge-tokens UI
### 8. Documenso v2 webhook payload audit (small)
Risk #4 from `docs/documenso-build-plan.md` — confirm v2 payload
shape (`payload.documentId` vs `payload.id`, recipient.token vs
`recipient.recipientId`) against a live v2 instance before relying
on Phase 2 cascading emails.

View File

@@ -0,0 +1,753 @@
# Comprehensive Audit — 2026-05-06
Conducted directly after the smart-archive / hard-delete / bulk-wizard /
audit-overhaul / synthetic-seed batches landed (commits `d07f1ed`
through `9890d06`). Prior comprehensive audit:
`docs/audit-comprehensive-2026-05-05.md`.
Findings are sorted by severity. Each has a concrete file:line, a
scenario, and a fix recommendation.
---
## CRITICAL
### C1. 5 of 10 BullMQ workers are never imported (production + dev)
**Files:** `src/worker.ts:13-17`, `src/server.ts:72-76`
`src/worker.ts` (production) and `src/server.ts` (dev fallback) both
import only:
- `emailWorker`
- `documentsWorker`
- `notificationsWorker`
- `importWorker`
- `exportWorker`
**Missing:** `aiWorker`, `bulkWorker`, `maintenanceWorker`, `reportsWorker`, `webhooksWorker`.
Because BullMQ workers are constructed at the top of each worker
module and only "start" when the module is imported, never importing
them means:
- **Webhooks never deliver.** `webhooksWorker` is what processes the
`webhooks` queue; the admin "Replay" button we just shipped enqueues
jobs that pile up in `pending` forever.
- **All maintenance crons silently no-op.** `maintenanceWorker` handles
`database-backup`, `backup-cleanup`, `session-cleanup`,
`currency-refresh`, `gdpr-export-cleanup`, `ai-usage-retention`,
`error-events-retention`, `website-submissions-retention`,
`alerts-evaluate`, `analytics-refresh`, `calendar-sync`,
`temp-file-cleanup`, `form-expiry-check` — none run.
- **Scheduled reports never generate.** `reportsWorker` handles
`report-scheduler` (every minute).
- **Bulk jobs never process** (the synchronous bulk endpoints work, but
any deferred-bulk path is dead).
- **AI usage features never run.**
**Impact:** Production CRM has been silently shedding webhook
deliveries, never running retention/cleanup, never sending scheduled
reports.
**Fix:**
```ts
// Append to src/worker.ts AND the inline section of src/server.ts:
import { aiWorker } from '@/lib/queue/workers/ai';
import { bulkWorker } from '@/lib/queue/workers/bulk';
import { maintenanceWorker } from '@/lib/queue/workers/maintenance';
import { reportsWorker } from '@/lib/queue/workers/reports';
import { webhooksWorker } from '@/lib/queue/workers/webhooks';
const workers = [
emailWorker,
documentsWorker,
notificationsWorker,
importWorker,
exportWorker,
aiWorker,
bulkWorker,
maintenanceWorker,
reportsWorker,
webhooksWorker,
];
```
After fix, run `pnpm dev` and watch `/admin/webhooks/{id}` deliveries
go from `pending``success` to confirm.
---
## HIGH
### H1. Hard-delete request endpoints have zero rate limiting
**Files:**
- `src/app/api/v1/clients/[id]/hard-delete-request/route.ts:1-37`
- `src/app/api/v1/clients/bulk-hard-delete-request/route.ts:1-32`
Each call writes a fresh code to Redis and emails it to the operator's
address. No `withRateLimit(...)`. An attacker who has compromised an
admin account (or even just the new `permanently_delete_clients`
permission) can:
1. Email-bomb the admin's own inbox (every request → email).
2. Probe whether arbitrary client IDs exist (200 + `sentToMaskedEmail`
vs 404 `client not found` is a UID oracle).
3. Burn SMTP quota.
**Fix:** add `withRateLimit('auth', ...)` or a new dedicated bucket
(e.g. 5 per hour per user). Pattern is already in
`src/app/api/v1/clients/[id]/gdpr-export/route.ts`.
### H2. Audit-page view fires on every paginated reload (log spam)
**File:** `src/app/api/v1/admin/audit/route.ts:48-72`
I added a "watch the watchers" `view` audit row for first-page audit
fetches. That's the right idea, but the page also re-fires the request
on every filter change (severity, source, action, date range, search).
A diligent admin filtering through the inspector for an investigation
will write dozens of `view` audit rows per minute — making it harder to
find the actual events they're looking for.
**Fix:** dedupe in Redis with a 60-second per-user TTL key, only emit
if the key didn't exist. Or only fire when no filters are active.
### H3. Hard-delete error messages distinguish "no code" vs "wrong code"
**File:** `src/lib/services/client-hard-delete.service.ts:166-174`
```ts
if (!stored) throw new ValidationError('Confirmation code expired or not requested');
if (!safeEqualStr(stored, args.code.trim())) {
throw new ValidationError('Confirmation code is incorrect');
}
```
The two messages let an attacker distinguish "you've never requested a
code" (so spam the request endpoint to open the window) from "wrong
code" (so brute-force more codes). 4-digit space is only 10,000 — with
distinguishable feedback an attacker can confirm code validity in
≤5,000 attempts on average.
**Fix:** collapse to a single `'Invalid or expired code'` message; the
operator already has the email open and knows what they typed.
### H4. Synthetic seed leaves `super_admin` linked-port-roles empty
**File:** `src/lib/db/seed-bootstrap.ts:147-160`
The bootstrap creates the `userProfiles` row with
`isSuperAdmin: true` for `super-admin-matt-portnimara`, but doesn't
create `userPortRoles` rows. The actual real `user` rows (admin@,
agent@, viewer@) are only created via the Playwright global-setup.
Anyone running `pnpm db:seed:synthetic` then `pnpm dev` and trying to
log in via the UI hits an unauthenticated state until they also run
playwright setup or sign up via better-auth manually.
**Fix:** either document this in `CLAUDE.md` Quick Reference, or add a
`pnpm db:seed:dev-users` companion script that signs up the three
test users + links roles. Today's synthetic-seed flow felt clean
because the playwright setup was still applied; in a fresh clone it
will surprise.
### H5. Documenso bad-secret 200 response is correct, but enables enum oracle
**File:** `src/app/api/webhooks/documenso/route.ts:67-86`
The route returns `200 ok=false error=Invalid secret` for a wrong
secret. That's webhook best-practice (don't leak signal to attackers),
but combined with the new audit row that captures
`metadata.providedLen`, an attacker can probe secret-length over time
without being detected (just a "warning" row per attempt). On an admin
inspector with 1000s of rows, a slow-rate probe is invisible.
**Fix:** add per-IP rate limit (5/min) to `/api/webhooks/documenso/`
when secret check fails. Don't block real Documenso traffic — it
shouldn't fail the secret check.
### H6. The audit-log inspector page itself isn't backed by a real "view" gate beyond `admin.view_audit_log`
**File:** `src/app/api/v1/admin/audit/route.ts:31`
Audit log has the most sensitive cross-cutting data in the system
(every login attempt with attempted email, every secret-regenerate,
every hard-delete). It's gated only by `admin.view_audit_log`. The
seed grants this to `director` AND `super_admin`. Consider:
- making the page super-admin-only for production, OR
- adding a secondary confirmation when viewing rows that contain
attempted emails / IP ranges (PII).
**Fix:** change `withPermission('admin', 'view_audit_log', ...)` to
add `if (!ctx.isSuperAdmin) check sensitive_audit_view`. Or accept
the current model but document it in the role docs.
### H7. Three "coming soon" stubs in production UI
**Files:**
- `src/components/clients/client-tabs.tsx:276` — "File attachments coming soon."
- `src/components/clients/client-reservations-tab.tsx:41` — "History is coming soon."
- `src/components/berths/berth-tabs.tsx:327` — "{label} coming soon"
Visible to every user on every client / berth detail page. Either ship
the feature or hide the tab.
**Fix:** for `client-tabs.tsx` line 276 (Files), the `files` table
already exists and supports clientId — ship a list view.
For `berth-tabs.tsx` line 327 — find the calling tab labels and
either implement or remove from the tabs array.
For `client-reservations-tab.tsx` line 41 — query past reservations
when the user toggles a "show history" filter.
---
## MEDIUM
### M1. `attachWorkerAudit` recurring job names list duplicates scheduler.ts (drift risk)
**File:** `src/lib/queue/audit-helpers.ts:23-46`
The 20 recurring job names are hardcoded in the audit helper; the
scheduler also has its own list. If someone adds a new cron without
updating both, the cron_run audit row never fires for that job.
**Fix:** export the list from `scheduler.ts` and import it in
`audit-helpers.ts`. Single source of truth.
### M2. `client-merge-log.surviving_client_id` deleted by hard-delete (history loss)
**File:** `src/lib/services/client-hard-delete.service.ts:200-202`
Hard-delete drops every `client_merge_log` row whose surviving id
matches. Those rows are the audit trail of WHO was merged INTO this
client. Once deleted, you've lost evidence of the prior merge.
**Fix:** replace `delete` with a column nullification, or move the row
to a `client_merge_log_archive` table. Audit trail per GDPR Article 5
should outlive the data.
### M3. Bulk hard-delete loops one-shot codes through Redis (5x writes)
**File:** `src/lib/services/client-hard-delete.service.ts:382-396`
For a 100-client bulk delete, the function writes 100 single-client
codes to Redis just to satisfy `hardDeleteClient`'s expectation. Each
write is a round-trip; on a Redis hiccup mid-loop, you can end up
with a half-deleted batch.
**Fix:** refactor `hardDeleteClient` so the inner deletion can be called
without the per-client code check (extract `_doHardDelete()` private
helper used by both single and bulk paths). Keeps Redis clean.
### M4. Smart-restore wizard has dead reversal applier for `berth_released`
**File:** `src/lib/services/client-restore.service.ts:360-372`
The `applyReversal` switch case for `'berth_released'` does nothing —
it just leaves the berth available. The wizard surfaces this as
"auto-reversible" if the berth is still free, but the actual restore
doesn't re-attach the berth to any interest. Operator clicks Restore
expecting their berth back; nothing changes on the berth.
**Fix:** either (a) at archive time, persist the original interestId
in the decision metadata so we can re-link, or (b) update the wizard
copy to make clear the berth is "available for re-attach" rather than
"will be re-attached."
### M5. Several services use `void createAuditLog(...)` without `.catch()`
**Files:** widespread; e.g. `src/lib/services/client-hard-delete.service.ts:127-136, 230-240`,
`src/lib/services/portal-auth.service.ts:269-276`
`createAuditLog` is documented as never-throwing (catches internally),
but defense-in-depth: a `void` Promise that throws produces an
unhandled rejection event. Most paths are fine because the helper
catches; if anyone refactors `createAuditLog` and removes the catch,
this becomes a process-killer.
**Fix:** convention rule: every `void someAsync()` must have a `.catch()`.
Codify with a custom ESLint rule, or wrap at call sites:
`void createAuditLog({...}).catch(() => undefined);`
### M6. Hard-delete audit metadata leaks client `fullName`
**File:** `src/lib/services/client-hard-delete.service.ts:241-247`
After the hard-delete the audit row carries
`metadata: { fullName: client.fullName }`. The client record itself is
gone but their name lives on in the audit log. For a GDPR data subject
who exercised their right-to-erasure, this is technically a retention
of personal data in audit history. Not necessarily wrong (audit logs
have a legitimate-interest basis), but should be conscious.
**Fix:** decide policy: either (a) keep as-is and document, (b) replace
with a hash of the name, or (c) substitute a tombstone identifier.
### M7. Webhook delivery DLQ admin-replay can re-trigger downstream side-effects
**File:** `src/lib/services/webhooks.service.ts:282-326`
Replaying a successful webhook (operator presses Replay on a delivery
that already had `status: 'success'`) re-fires the same payload to the
recipient. If the recipient's idempotency check is weak, you've just
caused a duplicate. The replay payload includes `retried_from` /
`retried_at` markers, which is good — but most recipients won't honor
them.
**Fix:** disable the Replay button when `status === 'success'`. The UI
already gates on `'failed' || 'dead_letter'` — verify it stays that
way (`webhook-delivery-log.tsx:118-131` looks correct; double-check
no regressions).
### M8. `audit_logs` table has no DELETE permission gate
**Files:** schema and routes
There's no admin endpoint to delete audit rows (good). But there's no
DB-level guard either. A super_admin who runs `db:reset` wipes audit
history. Audit retention should be enforced at the schema level so
even a misconfigured operator can't blow away the trail.
**Fix:** create a `audit_logs_no_delete_role` postgres role that lacks
DELETE on the table; document that the app's DB user should not have
DELETE on `audit_logs` in production deployments.
### M9. Documenso void worker uses dynamic import every time
**File:** `src/lib/queue/workers/documents.ts:25`
```ts
const { voidDocument } = await import('@/lib/services/documenso-client');
```
Dynamic import inside a hot per-job path is fine the first time but
slows every subsequent call slightly. Move to top-of-file import
unless there's a deliberate reason (circular dep?).
**Fix:** test moving to top-level import; if it works (no circular
deps), keep it there.
### M10. Bulk archive wizard "blocked" reason copy truncates at first line
**File:** `src/components/clients/bulk-archive-wizard.tsx:153-163`
The wizard shows `b.blockers[0]` for blocked clients. If the dossier
has multiple blockers, only the first is shown. Operators may fix the
first one, retry, and discover a second.
**Fix:** show all blockers (joined with `·`) or a "+N more" badge
with click-to-expand.
---
## LOW
### L1. `next-in-line-notify.service.ts` could double-fire on archive retry
**File:** `src/app/api/v1/clients/[id]/archive/route.ts:114-135`
If the smart-archive request succeeds at the DB transaction level but
the response upload-side fails (network blip, browser closes), the
operator may retry. Each retry re-fires the next-in-line notification
to all sales recipients. The `dedupeKey: berth-released:{berthId}`
inside the notification helper deduplicates within a cooldown window —
so this is mitigated, but worth verifying the cooldown is set and
not 0.
### L2. `interests.berth_id` reference in `seed-data.ts` (legacy seed)
**File:** `src/lib/db/seed-data.ts:973`
The realistic seed inserts `berthId: ...` on the interests table. Per
`CLAUDE.md`, that column was dropped in migration 0029 and replaced
with `interest_berths` junction. The synthetic seed uses the junction
correctly. The realistic seed will FAIL at insert time if anyone
tries to run it on a freshly-migrated DB.
**Fix:** rewrite `seed-data.ts:969-982` to insert into `interests`
without `berthId`, then insert the junction rows separately (mirror
the synthetic seed's pattern).
### L3. Audit log entry for failed login uses `entityId = attemptedEmail` (unbounded)
**File:** `src/app/api/auth/[...all]/route.ts:53-68`
If the entityId is very long (a 500-char "email"), it goes into the
DB column. The column is `text` (unbounded) so no DB error, but FTS
search-text may bloat.
**Fix:** truncate attempted email to 256 chars before using as
entityId.
### L4. The "watch the watchers" audit fires for filtered queries too
**File:** `src/app/api/v1/admin/audit/route.ts:48-72`
(See H2 above for the page-spam variant.) Even on a single search,
an audit row containing the search term is written. If the search
term itself is sensitive (e.g. an admin searches for a specific
client's name in audit logs), it's now in the audit log of audit-log
viewing. Acceptable but worth documenting.
### L5. Import worker is a stub
**File:** `src/lib/queue/workers/import.ts:13`
`// TODO(L2): implement import job handlers` — the worker is wired
into the queue and registered, but does nothing. If anyone enqueues
an `import:*` job, it returns immediately. Either ship the feature
or remove the queue.
### L6. `interest-form.tsx` two TODOs about company-yacht filter + add-yacht inline
**File:** `src/components/interests/interest-form.tsx:332-333`
Real product gaps. When creating an interest for a client who's a
member of a company, you can't pick a yacht owned by that company.
And there's no inline "Add yacht" shortcut in the form.
### L7. `berth-spec-template.ts` defaults to `'Price: TBD'` when price is null
**File:** `src/lib/pdf/templates/berth-spec-template.ts:128`
Generated berth-spec PDFs say "Price: TBD" for any berth without a
price. Cosmetic — verify whether sales considers this an acceptable
fallback or wants to suppress the line entirely.
---
## Things checked and found OK (so we don't re-audit)
- Tenant isolation on hard-delete (`portId` filter on every query and
inside the tx).
- `withPermission` gates on every new route (bulk-archive-preflight,
hard-delete-_, bulk-hard-delete-_, redeliver).
- Audit log: no public DELETE endpoint, no PATCH endpoint.
- Sidebar nav properly gates marina sections from `residential_partner`
via `hasMarinaAccess`.
- Auth wrapper rebuilds the request body correctly so the upstream
better-auth handler can re-read it (no body-already-consumed bug).
- Webhook outbound SSRF guard with DNS rebinding protection still
intact.
- 1175/1175 vitest suite passing as of last run.
---
## Recommended fix order (ROUND 1 + 2 combined — see below for Round 2)
See **"Triage list" at the end** of this document — combined ranking
across both audit rounds.
---
## Round 2 — focused agents (added 2026-05-06 evening)
After the original synthesis above, four scoped agents (smaller blast
radius, hard finding caps) successfully audited their domains and
produced dedicated docs. Findings are linked here with `R2-`-prefixed
IDs. Detail in:
- [audit-reliability-2026-05-06.md](audit-reliability-2026-05-06.md) — 11 findings
- [audit-frontend-2026-05-06.md](audit-frontend-2026-05-06.md) — 12 findings
- [audit-permissions-2026-05-06.md](audit-permissions-2026-05-06.md) — 9 findings
- [audit-missing-features-2026-05-06.md](audit-missing-features-2026-05-06.md) — 12 findings
### Round 2 — CRITICAL
**R2-C1. Bulk archive discards post-commit side effects** ([reliability C1](audit-reliability-2026-05-06.md))
- File: `src/app/api/v1/clients/bulk/route.ts:68-134`
- The bulk wizard's `runBulk` callback discards the return value from
`archiveClientWithDecisions`. **Documenso envelopes marked
`void_documenso` are never queued for void; "next-in-line" sales
notifications never fire**. The CRM ends up showing `documents.status='cancelled'`
while the live envelope is still out for signature — a signer can
legally complete a doc the CRM thinks is voided.
- Same severity tier as the original C1 (worker-imports).
**R2-C2. Frontend: Restore icon hovers destructive-red on archived clients** ([frontend C1](audit-frontend-2026-05-06.md))
- File: `src/components/clients/client-detail-header.tsx:174-186`
- Conditional `hover:text-destructive` is overridden by an unconditional
`hover:text-foreground` earlier in the class string. Result: the
Restore button on archived clients hovers blood-red, signalling
"destructive" on a fully reversible action. Users hesitate to click.
Promoted to "critical UX" because it's directly misleading on every
archived client view.
### Round 2 — HIGH
**R2-H1. Smart-restore wizard's `berth_released` reversal is a no-op but the audit log claims success**
([reliability H1](audit-reliability-2026-05-06.md))
- File: `src/lib/services/client-restore.service.ts:359-372`
- Already noted as M4 in the original synthesis. Round-2 reliability
agent escalated to HIGH because the wizard counter increments and
the audit log records "1 auto-reversed" — operator believes the berth
was re-attached when nothing happened. Same fix path: persist the
original `interestId` in the decision detail and re-link on restore.
**R2-H2. Smart-archive berth status update has TOCTOU race**
([reliability H2](audit-reliability-2026-05-06.md))
- File: `src/lib/services/client-archive.service.ts:191-207`
- Berth row read outside tx, mutated inside tx without `for update`
lock. Concurrent archive + sale of the same berth can race: the
archive flow flips a freshly-sold berth back to `available`. Add
`select … for update` on `berths` before the status flip.
**R2-H3. Bulk archive can pick the wrong interest for berth release**
([reliability H3](audit-reliability-2026-05-06.md))
- File: `src/app/api/v1/clients/bulk/route.ts:95-103`
- Lookup by `primaryBerthMooring` falls back to `dossier.interests[0]?.interestId ?? ''`.
Empty-string `interestId` reaches the delete and silently matches
zero rows; the link is silently retained while the audit log claims
it was removed.
**R2-H4. External EOI runs five operations outside a transaction**
([reliability H4](audit-reliability-2026-05-06.md))
- File: `src/lib/services/external-eoi.service.ts:67-155`
- Storage upload + 4 DB writes are independent. Mid-flight failure
leaves orphan PDFs in S3/MinIO and partial DB state.
**R2-H5. Bulk wizard double-submit treats `ConflictError('already archived')` as a per-row error**
([reliability H5](audit-reliability-2026-05-06.md))
- File: `src/app/api/v1/clients/bulk/route.ts:68-120`
- No idempotency key on the bulk endpoint. A double-submit (network
retry, double click) makes the second response look like all rows
failed even though the first succeeded.
**R2-H6. Webhook replay button has no UI permission gate (403 toast spam)**
([permissions H1](audit-permissions-2026-05-06.md))
- File: `src/components/admin/webhooks/webhook-delivery-log.tsx:118-131`
- Replay button renders for any user who can load the page. Server gates
on `admin.manage_webhooks`. Non-admins see enabled buttons; clicking
surfaces a generic 403 toast.
**R2-H7. Bulk Archive bulk action exposed to roles without `clients.delete`**
([permissions H2](audit-permissions-2026-05-06.md))
- File: `src/components/clients/client-list.tsx:182-190`
- `sales_agent` and `viewer` see the Archive bulk action; clicking
surfaces a 403 from preflight. Mirror the `canHardDelete` pattern:
`const canBulkArchive = can('clients', 'delete');`
**R2-H8. Bulk add_tag / remove_tag exposed to viewer**
([permissions H3](audit-permissions-2026-05-06.md))
- File: `src/components/clients/client-list.tsx:165-181`
- Same pattern as R2-H7 — no UI gate; server gates on `clients.edit`.
**R2-H9. Bulk hard-delete silently skips rows that vanish between preflight and execute**
([permissions H4](audit-permissions-2026-05-06.md))
- File: `src/lib/services/client-hard-delete.service.ts:377`
- `if (!c) continue;` swallows any client that was archived/restored/
deleted by another operator between preflight and execute. Operator
sees a `deletedCount` lower than requested and no signal which IDs
were skipped.
**R2-H10. Frontend: `webhook-delivery-log` and `audit-log-list` swallow fetch errors silently**
([frontend H3, H4](audit-frontend-2026-05-06.md))
- Files: `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74`,
`src/components/admin/audit/audit-log-list.tsx:150-175`
- Both wrap fetches in `try/finally` with no `catch`. Failed loads show
spinner forever or stale data; user has no signal that anything
failed. Surface via `toast.error` + inline retry banner.
**R2-H11. Frontend: `audit-log-card` renders as `<a href="#">` — page-jumps on mobile tap**
([frontend H5](audit-frontend-2026-05-06.md))
- File: `src/components/admin/audit/audit-log-card.tsx:96`
- Card view rows on mobile insert `#` in URL on tap (back-button trap).
Render as button or div, or link to a useful destination.
**R2-H12. Frontend: `smart-archive-dialog` doesn't invalidate the dossier or single-client query**
([frontend H6](audit-frontend-2026-05-06.md))
- File: `src/components/clients/smart-archive-dialog.tsx:197-212`
- Detail page header keeps showing client as un-archived after a
successful archive until hard reload. Add
`qc.invalidateQueries({queryKey: ['clients', clientId]})` and
`qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})`.
**R2-H13. Frontend: bulk tag mutation uses `alert()` and lacks `onError`**
([frontend H2](audit-frontend-2026-05-06.md))
- File: `src/components/clients/client-list.tsx:88-106`
- Native `alert()` blocks the page on partial failure; pure network
failure shows nothing. Replace with `toast.warning` / `toast.error`.
**R2-H14. Email-template subject overrides are no-ops for 6 of 8 templates**
([missing-features V1](audit-missing-features-2026-05-06.md))
- Files: `src/components/admin/email-templates-admin.tsx:24-72` (UI),
`src/lib/services/portal-auth.service.ts:120,332` (only consumers)
- Admin sees an "Overridden" badge after saving a custom subject for
CRM invite, inquiry confirmation, residential templates, etc. — but
the senders ship the hardcoded subject regardless. Wire
`loadSubjectOverride(portId, key)` into the 6 missing senders.
**R2-H15. Branding admin saves 5 settings that nothing reads**
([missing-features V2](audit-missing-features-2026-05-06.md))
- Files: `src/app/(dashboard)/[portSlug]/admin/branding/page.tsx`,
`src/lib/services/port-config.ts:240-272`
- Logo URL, app name, primary color, header HTML, footer HTML all
dead-end. `getPortBrandingConfig` has zero callers. **Multi-tenant
promise broken — every port's emails ship Port Nimara's branding.**
**R2-H16. Reminder admin saves digest defaults that no scheduler applies**
([missing-features V3](audit-missing-features-2026-05-06.md))
- Files: `src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx`,
`src/lib/services/port-config.ts:284-306`
- Sales reps think they configured a daily digest at 09:00 in their
TZ; they get fire-as-they-hit notifications instead. The digest
scheduler doesn't exist.
### Round 2 — MEDIUM (selected highlights)
**R2-M1. Portal "My Memberships" tile is a dead-end** ([missing-features V4](audit-missing-features-2026-05-06.md))
- Tile on `/portal/dashboard` has no `href`; route doesn't exist. Either
ship `/portal/memberships` or remove the tile.
**R2-M2. Company detail Documents tab is a "Coming soon" stub** ([missing-features V5](audit-missing-features-2026-05-06.md))
- `src/components/companies/company-tabs.tsx:230-234`. Same problem
as the three already-noted "coming soon" stubs but on a different
entity.
**R2-M3. Onboarding page is a static checklist not the wizard it advertises** ([missing-features V6](audit-missing-features-2026-05-06.md))
- The page literally says "what this page will become". Either build
the wizard or relabel the landing card.
**R2-M4. Backup admin page is a docs page despite landing copy promising "on-demand exports"** ([missing-features V7](audit-missing-features-2026-05-06.md))
- Once C1 (worker imports) is fixed, the existing `database-backup`
job is reachable; small lift to wire a "Take backup now" button.
**R2-M5. Inquiry inbox has zero triage actions** ([missing-features V8](audit-missing-features-2026-05-06.md))
- No "Convert to client", no "Resolve", no "Assign". `website_submissions`
table is permanent; sales has to copy-paste emails into client forms.
**R2-M6. external-eoi grants only `documents.upload_signed` but mutates interest state** ([permissions M1](audit-permissions-2026-05-06.md))
- A custom role with `documents.upload_signed:true` + `interests.edit:false`
can flip an interest to "signed" via the external-EOI route.
**R2-M7. `InlineStagePicker` never sends `override:true` — `override_stage` permission unreachable from the most-used UI path** ([permissions M2](audit-permissions-2026-05-06.md))
- Users with the perm have to fall back to the modal `InterestStagePicker`
to actually use it.
**R2-M8. `sales_agent` granted `interests.override_stage:true` — likely copy-paste from sales_manager** ([permissions M3](audit-permissions-2026-05-06.md))
- All other trust-elevated flags are stripped from sales_agent. Needs a
product decision; either flip to false or document intent.
**R2-M9. `bulk-archive-preflight` leaks dossier-loader error text in `blockers`** ([permissions M4](audit-permissions-2026-05-06.md))
- An attacker enumerating UUIDs can distinguish "doesn't exist" vs
"exists but you can't see it". Replace with generic "Could not load
dossier".
**R2-M10. Documenso void worker has no max-retry alert hook** ([reliability M2](audit-reliability-2026-05-06.md))
- A persistent 401/403 retries forever. On exhaustion, write back to
`documents` (`cancellation_failed=true`) and notify admin.
**R2-M11. Mobile More-sheet missing residential, notifications, berth-reservations, website-analytics** ([missing-features V9](audit-missing-features-2026-05-06.md))
- Mobile users have zero path to entire feature domains. Add to
`MORE_ITEMS`.
**R2-M12. Portal has no profile / change-password surface** ([missing-features V10](audit-missing-features-2026-05-06.md))
- Forces every portal user to use the forgot-password flow even when
they remember their old password. Ship `/portal/profile`.
**R2-M13. Portal invoices show amounts but no PDF download** ([missing-features V11](audit-missing-features-2026-05-06.md))
- Documents page does have downloads; mirror the pattern.
(Plus several more medium/low items in the dedicated docs; see those
for the full set.)
---
## TRIAGE LIST (combined Round 1 + Round 2)
### Ship now — CRITICAL
1. **C1** — wire the 5 missing BullMQ workers (`worker.ts`, `server.ts`)
— 5-line fix; every webhook + cron flow is currently dead.
2. **R2-C1** — make bulk archive enqueue Documenso voids + next-in-line
notifications (return value plumbing in `bulk/route.ts`).
3. **R2-C2** — fix the destructive-red hover on the Restore button
(`client-detail-header.tsx`). Trivial CSS fix.
### Ship this week — HIGH (security/UX with concrete user impact)
4. **H1** — rate-limit the hard-delete-request endpoints.
5. **H3** — collapse "no code" vs "wrong code" into one error message.
6. **H7** — three "coming soon" stubs in client/berth tabs.
7. **R2-H1** — fix smart-restore's silent `berth_released` no-op (or
reclassify as `reversibleWithPrompt`).
8. **R2-H2** — add `for update` lock on the smart-archive berth status
flip (TOCTOU race).
9. **R2-H3** — bulk-archive's wrong-interest fallback — empty-string
interestId silently no-ops.
10. **R2-H6, R2-H7, R2-H8** — three permission UI-gate misses on
bulk actions and the webhook-replay button. ~30 lines total.
11. **R2-H10, R2-H12, R2-H13** — frontend swallowed errors + missing
invalidation + alert() instead of toast. Small fixes, immediate UX
win.
12. **R2-H11**`audit-log-card` `href="#"` mobile back-button trap.
13. **R2-H14** — wire 6 missing email-subject overrides through their
senders.
### Next sprint — HIGH/MEDIUM (operational + multi-tenant correctness)
14. **R2-H4** — wrap external-EOI in a transaction.
15. **R2-H5** — bulk-archive idempotency key + treat already-archived as
success in bulk.
16. **R2-H9** — bulk hard-delete should return `skipped: string[]`.
17. **R2-H15, R2-H16** — branding + reminder admin pages save settings
nothing reads (silently broken multi-tenancy).
18. **H2** — audit-page-view de-dupe (don't spam on every filter change).
19. **H4** — synthetic seed needs documented dev-user setup or its own
bootstrap script.
20. **H5** — Documenso bad-secret rate-limit per IP.
21. **R2-M1 through R2-M5** — portal memberships dead-end, company
Documents stub, onboarding wizard, backup page, inquiry inbox triage.
### Backlog — MEDIUM/LOW + remaining items
22. The remaining MEDIUM/LOW from both rounds — see the dedicated docs.
---
## Headline numbers (combined)
- **3 CRITICAL** (worker imports, bulk-archive side-effects, restore-button hover)
- **22 HIGH** (security + UX with concrete impact)
- **~15 MEDIUM** (operational hygiene, multi-tenancy gaps, unfinished features)
- **~10 LOW** (cleanup, defensive)
Round 1 was a manual synthesis after agent-pool stalls; Round 2 was
four focused agents with hard finding caps that all completed inside
the watchdog window. Every finding is grounded in code references.

View File

@@ -0,0 +1,223 @@
# Frontend audit — 2026-05-06
Scope: new archive/restore/hard-delete dialogs, bulk archive wizard, client
detail header, audit log inspector, webhook delivery log, client list bulk
section. Companion to `docs/audit-comprehensive-2026-05-06.md` (does NOT
re-flag the Files-tab / reservations / berth-tab "coming soon" stubs already
covered there).
---
## Critical
### C1 — `client-detail-header` opens restore dialog from the Archive icon for archived clients
**File:** `src/components/clients/client-detail-header.tsx:174-186`
**Scenario:** On an archived client the icon button still renders `<Archive>`
when `isArchived` is true (`isArchived ? <RotateCcw /> : <Archive />` is
correct), BUT both states use the same `setArchiveOpen(true)` handler and
the conditional below routes `<SmartRestoreDialog>` vs `<SmartArchiveDialog>`
off of `isArchived`. That part is fine. The real problem: the destructive
hover colour `hover:text-destructive` is applied via
`isArchived ? 'hover:text-foreground' : 'hover:text-destructive'` — but the
preceding class string already sets `hover:text-foreground` unconditionally,
so the conditional is dead and the restore button hovers red the same as
archive. Misleading colour signal on a reversible action; users hesitate to
click it.
**Fix:** Drop the always-applied `hover:text-foreground` from the base class
list and let the conditional own the hover colour, or just colour the
restore icon emerald to differentiate.
---
## High
### H1 — `bulk-archive-wizard` lets users skip the reasons step by clicking Continue while preflight is loading then Cancel/reopen
**File:** `src/components/clients/bulk-archive-wizard.tsx:253-267, 80-107`
**Scenario:** In the `preflight` stage the Continue button is only disabled
when `archivable.length === 0 || preflight.isLoading`. But `archivable` is
derived from `items = preflight.data ?? []`. While loading, `archivable` is
`[]` so Continue is disabled — good. After load with all-blocked selection,
`archivable.length === 0` so still disabled — good. However, the
`reasonsByClientId: reasons` payload is sent verbatim, so a user who advances
to "reasons", types into one client's box, then uses the carousel back arrow
and edits another, can submit reasons for clients NOT in `archivable` (e.g.
if the preflight is refetched on stale-time). Reasons for blocked or removed
client IDs are forwarded to the API. Minor data-quality issue.
**Fix:** Filter `reasons` to `archivable` IDs before mutating:
`reasonsByClientId: Object.fromEntries(Object.entries(reasons).filter(([id]) => archivable.some(a => a.clientId === id)))`.
### H2 — `client-list` bulk tag mutation uses `alert()` for partial failures and has no `onError`
**File:** `src/components/clients/client-list.tsx:88-106`
**Scenario:** User bulk-adds a tag to 50 clients; backend returns 200 with
`{succeeded: 30, failed: 20}` → user sees a native browser `alert()` blocking
the page. If the request itself errors (network drop, 500), there is no
`onError` so the dialog closes via `onSettled` and the user sees nothing —
silent failure. Inconsistent UX vs. every other mutation in this audit which
uses `toast`.
**Fix:** Replace `alert(...)` with `toast.warning(...)`, add an
`onError: (err) => toast.error(...)` branch matching the pattern used in
`bulk-archive-wizard.tsx` and `bulk-hard-delete-dialog.tsx`.
### H3 — `webhook-delivery-log` swallows fetch errors silently
**File:** `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74`
**Scenario:** Admin opens a webhook detail page while the API is down or the
webhook was just deleted. `load()` catches and discards the error
(`} catch { /* ignore */ }`). UI shows "Loading deliveries…" forever on the
first load, or stays on the last successful page on subsequent loads, with
no indication that anything failed. No error state, no toast, no retry.
**Fix:** Surface errors via `toast.error` and show an inline error state
("Couldn't load deliveries — Retry") instead of swallowing.
### H4 — `audit-log-list` first-page fetch swallows errors and shows no error state
**File:** `src/components/admin/audit/audit-log-list.tsx:150-175`
**Scenario:** Filter form is fully interactive, user changes a date — request
fires, server 500s. The `try/finally` has no `catch`, so the rejected promise
becomes an unhandled rejection. The list shows whatever was previously
loaded (or empty state), and the user has no idea their filter didn't apply.
Same applies to `loadMore`.
**Fix:** Add `catch` blocks that set an error state and render an inline
error banner above the table, with a Retry button.
### H5 — `audit-log-card` renders as a link to `href="#"` — clicking jumps the page
**File:** `src/components/admin/audit/audit-log-card.tsx:96`
**Scenario:** On mobile / card view the audit log entries become clickable
cards with `href="#"`. Tapping any card scrolls the page to top and inserts
`#` in the URL (back-button trap). There's no detail view to navigate to.
**Fix:** Either render a non-link wrapper (button or div) when no detail
target exists, or link to a useful destination like
`/{portSlug}/{entityType}/{entityId}` when the entity is resolvable.
### H6 — `smart-archive-dialog` `archiveMutation` doesn't invalidate the dossier or single-client query
**File:** `src/components/clients/smart-archive-dialog.tsx:197-212`
**Scenario:** User archives a client successfully. The dialog invalidates
`['clients']`, `['berths']`, `['interests']` but NOT
`['client-archive-dossier', clientId]` nor `['clients', clientId]`. If the
parent screen (e.g. detail page) keeps the client query mounted, the
detail header continues to show the client as un-archived until a hard
reload. The Restore icon won't appear.
**Fix:** Add `qc.invalidateQueries({queryKey: ['clients', clientId]})` and
`qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})` so a
re-open re-fetches a fresh dossier (e.g. if user re-archives after restoring
in the same session).
---
## Medium
### M1 — `smart-archive-dialog` derives `interestId` from a name match against `primaryBerthMooring` — wrong key
**File:** `src/components/clients/smart-archive-dialog.tsx:158-167`
**Scenario:** When building per-berth decisions the code does
`dossier.interests.find((i) => i.primaryBerthMooring === b.mooringNumber)?.interestId`.
Multiple interests can share the same primary mooring (rare, but possible
historically), and worse, when no interest has this berth as primary it
falls back to `dossier.interests[0]?.interestId` regardless of which berth
is being decided. The wrong interest gets credited with the release, which
is then audit-logged.
**Fix:** Have the dossier API return `interestId` per berth row (it already
joins `interest_berths`), or look up by membership not by primary flag.
### M2 — `hard-delete-dialog` doesn't reset state when switching from intent → confirm if request fails midway
**File:** `src/components/clients/hard-delete-dialog.tsx:39-46, 64-79`
**Scenario:** User submits hard delete with wrong code → backend returns 400
→ toast fires, but the dialog stays on `confirm` stage with the bad code
still in the input and no clear cue. If the user then closes (X) and
reopens, the `useEffect` resets correctly. But if the email code expired
(10 min) and they request a fresh one, there's no "Resend code" button —
they must cancel and start over from intent. Minor.
**Fix:** Add a "Send a new code" link in the confirm stage that calls
`requestCode.mutate()` again and clears `code`.
### M3 — `bulk-hard-delete-dialog` doesn't refetch / invalidate after partial failure shows totals
**File:** `src/components/clients/bulk-hard-delete-dialog.tsx:64-85`
**Scenario:** Bulk delete returns `{deletedCount: 7}` for 10 selected; toast
warns but `qc.invalidateQueries({queryKey: ['clients']})` is invalidated
unconditionally — fine. However, the dialog closes immediately
(`onOpenChange(false)`), so the user can't see WHICH 3 failed. The toast
just says "see audit log". For a destructive bulk op this is too sparse;
users will repeat the action thinking it didn't work.
**Fix:** Stay open on partial failure and render a list of failed IDs (the
API likely already returns per-item results — if not, return them).
### M4 — `audit-log-list` doesn't validate that `dateFrom <= dateTo`
**File:** `src/components/admin/audit/audit-log-list.tsx:142-146`
**Scenario:** User picks From=2026-06-01, To=2026-05-01. Query fires with an
empty result range; user sees "No audit log entries found" and assumes
their data isn't there. No client-side validation hint.
**Fix:** Show an inline warning "From date must be before To date" and skip
the request when invalid.
### M5 — `bulk-archive-wizard` `Cancel` during `archiveMutation.isPending` discards mutation tracking
**File:** `src/components/clients/bulk-archive-wizard.tsx:248-251, 293-307`
**Scenario:** User clicks "Archive 50" → mutation in flight (10s) → user
clicks Cancel. The dialog closes; the mutation continues server-side and
its onSuccess fires later, showing a toast for an action the user thought
they cancelled. Worse, the dialog is gone so they can't tell which clients
got archived.
**Fix:** Disable Cancel while `archiveMutation.isPending`, or relabel to
"Cancel (won't stop in-progress)" and keep the mutation visible.
---
## Low
### L1 — `audit-log-list` filter row overflows on narrow viewports
**File:** `src/components/admin/audit/audit-log-list.tsx:321-467`
**Scenario:** 8 filter controls (`Search` 288px, `Entity` 144px, `Action`
176px, `Severity` 128px, `Source` 128px, `User id` 176px, `From` 144px,
`To` 144px, total ~1330px) sit in a single `flex-wrap` row. At <1280px
viewports they wrap onto multiple lines pushing the table down 200+px;
at <640px (mobile) each control wraps onto its own line and the "Clear"
button (`ml-auto`) lands on the wrong row.
**Fix:** Collapse rarely-used filters (User id / Severity / Source) into a
"More filters" Popover for sm: viewports.
### L2 — `audit-log-card` action map missing entries silently fall back to grey "Activity" icon and grey badge
**File:** `src/components/admin/audit/audit-log-card.tsx:27-44, 46-52`
**Scenario:** New webhook/cron/job actions are in `audit-log-list.tsx`
ACTION_COLORS but absent from `audit-log-card.tsx` ACTION_BADGE_COLORS and
ACTION_ACCENT. Card view of these entries looks identical to a generic
"unknown" entry — visual loss vs. table view.
**Fix:** Sync the two maps; consider extracting to a shared module so they
can't drift.

View File

@@ -0,0 +1,405 @@
# Missing-Features Audit — 2026-05-06
Focused pass on **features that look done in the UI but aren't fully
wired through the service layer**, plus **admin settings exposed to
users that no code reads**. Companion to
`docs/audit-comprehensive-2026-05-06.md` — the three "coming soon" stubs
already documented there (client Files tab, client reservations history,
berth tabs), the import-worker stub, the two interest-form TODOs, and
the EOI "Price: TBD" finding are NOT re-flagged here.
Hard cap: 12 findings. Severity tiers below.
---
## VISIBLE-BROKEN (admin sees a control, click is a no-op or wrong)
### V1. 6 of 8 admin-editable email subject overrides are silently ignored at send time
**Files:**
- `src/components/admin/email-templates-admin.tsx:24-72` (UI)
- `src/lib/email/template-catalog.ts:16-25` (catalog of 8 keys)
- `src/lib/services/portal-auth.service.ts:120-127, 332-339` (the only
consumers of `loadSubjectOverride`)
The `/admin/email-templates` page lets an admin override the subject
line on **eight** transactional templates:
`portal_activation`, `portal_reset`, `portal_invite_resend`,
`crm_invite`, `inquiry_client_confirmation`,
`inquiry_sales_notification`, `residential_inquiry_client_confirmation`,
`residential_inquiry_sales_alert`. The save endpoint persists each one
to `system_settings` (`email_template_<key>_subject`).
Only **two** of those eight are ever read at send time —
`portal_activation` and `portal_reset` in `portal-auth.service.ts`.
A repo-wide search for `loadSubjectOverride` / `settingKeyForSubject`
returns no other consumers. The other six templates use their hardcoded
subject regardless of the admin override.
**Impact:** sales/ops teams will customize an inquiry confirmation
subject, hit Save, see the "Overridden" badge, and silently ship the
default subject to every prospect.
**Fix:** small per template — call `loadSubjectOverride(portId, key)`
in each sender (`crm-invite.service.ts`, the inquiry sender, the
residential inquiry sender, the portal-invite-resend path) and pass the
result through as the email subject.
**Scope:** small (5 callsites + tests).
---
### V2. Branding admin (logo / app name / primary color / email header & footer HTML) saves to settings but no code reads them
**Files:**
- `src/app/(dashboard)/[portSlug]/admin/branding/page.tsx:7-46` — UI
with five fields.
- `src/lib/services/port-config.ts:240-272``getPortBrandingConfig()`
resolves the five `branding_*` settings into a typed config.
- Repo-wide: `getPortBrandingConfig` has **zero callers** outside its
declaration. The five `SETTING_KEYS.branding*` constants are only
read inside `getPortBrandingConfig` itself.
The admin panel is functional end-to-end (write hits the settings API,
"Reset to default" works), and the email-templates module hardcodes
`s3.portnimara.com/...` for the logo URL plus a fixed table layout.
None of the email-rendering helpers (`renderEmail`, the template
modules in `src/lib/email/templates/`) call `getPortBrandingConfig`,
and the `<BrandedAuthShell>` component sources its logo + colors from
constants too.
**Impact:** every multi-tenant assumption made about branding is
broken. A second port wired into this CRM will see Port Nimara's logo
- colors in every transactional email and on the auth pages, even
after their admin "configures branding" successfully.
**Fix:** plumb `getPortBrandingConfig(portId)` through the email
renderer (header/footer HTML + primary button color), and through
`<BrandedAuthShell>` via a server-fetched prop.
**Scope:** medium (touches every transactional email + auth shell).
---
### V3. Reminder admin page configures defaults that no service applies
**Files:**
- `src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx:7-50` — UI
for default-enabled, default-days, digest-enabled, digest-time,
digest-timezone.
- `src/lib/services/port-config.ts:284-306`
`getPortReminderConfig()` defines the schema.
- Repo-wide: the keys (`reminder_default_*`, `reminder_digest_*`) and
`getPortReminderConfig` have **zero callers**.
Same pattern as V2. The admin sets "enable reminders by default on new
interests" → toggles to true → save succeeds → newly-created interests
still default to `reminderEnabled=false`. The digest-time +
timezone fields go nowhere — there is no scheduler that batches
pending reminders into a daily digest.
**Impact:** the entire reminder UX is decorative. Sales reps think
they configured a daily digest at 09:00 Europe/Warsaw, get
fire-as-they-hit notifications instead.
**Fix:** wire `getPortReminderConfig` into (a) the interest-create
service (defaults), (b) the maintenance/notifications worker that
fires reminders (digest batching + delivery window). The `digest`
behavior didn't exist before this audit — needs a new scheduled job.
**Scope:** medium (defaults are small, digest job is new code).
---
### V4. Portal dashboard "My Memberships" tile has no link, no destination page, and isn't reachable from nav
**Files:**
- `src/app/(portal)/portal/dashboard/page.tsx:58-63` — `<PortalCard
title="My Memberships" ... icon={Building2} />` — note no `href`
prop.
- `src/components/portal/portal-nav.tsx:8-15` — six nav entries, no
memberships.
- Filesystem: `src/app/(portal)/portal/memberships/` does not exist.
The dashboard shows a count of "memberships" (companies the portal
user belongs to) but the tile is non-clickable and there is no
`/portal/memberships` route. A user with 3 memberships sees the tile,
clicks → nothing happens.
**Impact:** dead-end on the portal home for any client tied to a
company (the residential and yacht-ownership use-cases).
**Fix:** ship `/portal/memberships/page.tsx` listing the companies
returned by the existing `companyMemberships` query (already
aggregated in `getPortalDashboard`), and add it to `PortalNav`. Or
pull the tile if memberships isn't a portal feature.
**Scope:** small.
---
### V5. Company detail page Documents tab is a "Coming soon" stub
**File:** `src/components/companies/company-tabs.tsx:230-234`
```ts
{
id: 'documents',
label: 'Documents',
content: <EmptyState title="Documents" description="Coming soon" />,
},
```
Visible alongside the working Notes / Activity / Addresses / Members
tabs on every company detail page. NOT covered by the existing audit
doc's H7 (which lists clients, client reservations, and berths).
**Impact:** the same UX problem H7 calls out for clients.
**Fix:** mirror what client-Files-tab needs — query `documents` joined
to a polymorphic billing-entity = company link, render a list, ship a
download button. Or hide the tab.
**Scope:** small to medium.
---
## HALF-WIRED (the page works but the surrounding promise overstates it)
### V6. "Onboarding" admin page is a static checklist, not the wizard the page itself promises
**File:** `src/app/(dashboard)/[portSlug]/admin/onboarding/page.tsx`
The page renders 8 stepwise links and explicitly says (lines 71-72,
98-110): "The future onboarding wizard will track progress per port…",
"What this page will become", "The wizard will record completion per
port in `system_settings`, gate the public marketing-site cutover…".
The admin landing card describes it as the "Initial-setup wizard for
fresh ports" — admins clicking through expect a wizard, get a static
table of contents.
**Impact:** the only "fresh port" workflow doesn't exist; cutover
gating logic mentioned in the page body is also unimplemented.
**Fix:** either (a) build the wizard with progress in `system_settings`
- banner integration, or (b) re-label both this page and the admin
landing card to "Setup checklist" so expectations match reality.
**Scope:** large for the wizard; tiny for the relabel.
---
### V7. Backup & Restore admin page is informational only — admin landing card promises actions
**Files:**
- `src/app/(dashboard)/[portSlug]/admin/backup/page.tsx`
- `src/app/(dashboard)/[portSlug]/admin/page.tsx:148` — landing card
description: "Database snapshots and on-demand exports."
The landing card sells "on-demand exports". The actual page renders a
two-card explainer: "Current backup posture" (read-only) and "What
this page will become" (the entire interactive surface — list
snapshots, "Take backup now" button, per-port logical export, restore
preview, GDPR per-client export). None of those exist.
**Impact:** the "Backup & Restore" tile is functionally a docs page.
Compliance officers / users expecting a self-serve GDPR export
button have to file a support ticket.
**Fix:** match the language on the landing card to the page reality
("Backup posture" → docs only) until the snapshot/export buttons
ship. The maintenance worker already runs `database-backup` (per
`docs/audit-comprehensive-2026-05-06.md` C1 — though that worker isn't
imported), so wiring "Take backup now" against the existing job is
small once C1 is fixed.
**Scope:** small (doc tweak) or medium (button + per-port export
endpoint).
---
### V8. Inquiry inbox is read-only — no "Convert to Client" / "Mark resolved" / "Assign" actions
**File:** `src/components/admin/inquiry-inbox.tsx` (entire file, 207
lines, ends at the View payload toggle)
The inbox lists website-form submissions (berth_inquiry,
residence_inquiry, contact_form) with filter chips and a
"View payload" expand. There is no action to:
- create a client/interest from the submission,
- assign the inquiry to a sales rep,
- mark it resolved / triaged,
- reply directly,
- archive or trash the row,
- export.
The `website_submissions` table appears to be permanent — every
inquiry ever received remains in the inbox forever, with no triage
state. Sales has to manually copy the email into a new client form
and back-reference the original submission.
**Impact:** the inquiry-to-pipeline conversion step isn't supported in
the CRM. The marketing-site cutover (per the user's
`project_email_ownership_at_cutover.md` memory) will increase volume
on this surface and make the missing triage UX painful.
**Fix:** add a per-submission "Convert" action that prefills the
client + interest forms with the payload, plus a `triage_state`
column (open / converted / dismissed) and a default filter that hides
non-open rows.
**Scope:** medium.
---
## MOBILE PARITY
### V9. Mobile More-sheet is missing several real top-nav destinations
**File:** `src/components/layout/mobile/more-sheet.tsx:38-50`
`MORE_ITEMS` lists 11 entries. The dashboard route directory has at
least these top-level segments not represented anywhere in the mobile
bottom-tabs OR more-sheet:
- `residential` — exists at `/[portSlug]/residential/...`
- `notifications` — exists at `/[portSlug]/notifications/...`
- `berth-reservations` — exists at `/[portSlug]/berth-reservations/...`
- `documents` — exists as a top-level page (separate from the bottom
tab `documents`, which IS in mobile-bottom-tabs)
- `website-analytics` — exists at `/[portSlug]/website-analytics/...`
A mobile-only user has no path to any of them. The Documents bottom
tab does cover the doc list, but residential is an entire feature
domain (per the `(dashboard)/.../residential` directory) with no
mobile entry point.
**Impact:** anyone using the mobile chrome to triage on the go can't
reach residential clients/interests, alerts (`alerts` IS in the
sheet), or notifications.
**Fix:** add the missing segments to `MORE_ITEMS`. If the grid feels
too dense, reorganize into sections.
**Scope:** small.
---
### V10. Portal has no "Profile" / "Change password" surface
**Files:**
- `src/components/portal/portal-nav.tsx:8-15` — six tabs, no profile.
- Filesystem: no `src/app/(portal)/portal/profile/` directory.
A portal user who wants to change their email, phone, mailing address,
or password has no UI. The portal sign-in flow goes through the
better-auth session but the app exposes zero account-management
controls. The "Need assistance?" card on the dashboard tells the user
to contact the port team — which is the explicit answer for data
edits, but does not cover password changes (a security expectation,
not a per-port-staff burden).
**Impact:** every portal user who forgets their password (after
already activating) has to use `/portal/forgot-password` even if they
remember the old one. There's no proactive password rotation. A user
who changes their phone number has to email the port to update it.
**Fix:** ship `/portal/profile` with at minimum: read-only PII view +
"Change password" form (re-uses the existing reset-password endpoint
or a new `change-password` endpoint that takes the current pw).
Phone/address editing is a longer fix because of the audit-trail
implications.
**Scope:** small for password; medium with PII edits.
---
### V11. Portal invoices page lists invoices but offers no view/download — even though documents do
**File:** `src/app/(portal)/portal/invoices/page.tsx:53-99`
Each invoice row shows number, status, due/paid dates, amount, and a
small payment-status caption. There is no link, no PDF view, no
download. By contrast, the portal Documents page (peer route) ends
each row with a `<DocumentDownloadButton documentId={doc.id} />` that
fetches a signed S3 URL.
Compare to admin/CRM where invoices have a full PDF render flow
(invoice service generates the PDF + signed URL).
**Impact:** a portal user can see they owe money and cannot retrieve
the actual invoice document. They have to email the port to ask for a
PDF copy.
**Fix:** add an invoice-PDF endpoint under `/api/portal/invoices/[id]/
download` mirroring the documents one, and a download button on each
row. The invoice PDF generator already exists (`src/lib/services/
invoices.ts`).
**Scope:** small.
---
## DEV-NOTES (legitimately staged-for-later, calling out so they're not forgotten)
### V12. Email-templates admin only edits subject lines — body editing is a documented "next iteration"
**Files:**
- `src/components/admin/email-templates-admin.tsx:78-79` —
"Customize the subject line of transactional emails per port. Body
editing is the next iteration; for now the layout and HTML stay
locked to the default template."
- `src/lib/email/template-catalog.ts:5-9` — same statement in the
catalog header.
The page is honest about the limitation, so this isn't a "broken"
finding. But it's a notable shipped-without-the-killer-feature gap:
the multi-tenant promise of per-port email customization can't deliver
the body changes that ports actually want (logo placement, signature,
language). Combined with V2 (branding HTML fragments aren't read at
all), there is currently NO way for a non-super-admin per-port admin
to customize the email body in any way.
**Impact:** confined to admin expectations — most ports will assume
"Email templates" = "edit the email", click in, see only a subject
field, and request the missing body editor.
**Fix:** scope a body-editing flow that reuses the
`merge_fields.ts` token catalog (the validator already exists for
document templates) for safety. Until that's built, V2 + this finding
together mean a "rebrand the emails" task is single-tenant only.
**Scope:** large (HTML editor + token validator + per-port override
storage + render-side composition).
---
## Summary
12 findings, four severity tiers:
- **Visible-broken (V1-V5):** five admin/portal controls produce no
effect. V1 (email overrides) and V2 (branding) are the highest
impact — both silently break the multi-tenant promise.
- **Half-wired (V6-V8):** three pages where the surrounding wrapper
oversells what's there. V8 (inquiry inbox) is the largest scope.
- **Mobile parity (V9-V11):** mobile users can't reach several real
features; portal users have no profile/password surface and can't
download invoices.
- **Dev-notes (V12):** documented limitations called out for the
roadmap.
The two highest-leverage quick wins are **V1** (wire 6 missing
template subject overrides — a few hours) and **V11** (portal invoice
download — small, fixes a real customer pain point).

View File

@@ -0,0 +1,266 @@
# Per-role permission audit — 2026-05-06
Focused review of UI/server permission divergence on the new endpoints
shipped during the smart-archive / hard-delete / bulk-wizard /
external-EOI / webhook-replay work bundle. Skips items already covered
in `docs/audit-comprehensive-2026-05-06.md` (audit-log gating H6,
residential_partner sidebar nav).
The pattern hunted for: `<PermissionGate>` (or `usePermissions().can`)
on the UI side hides a control under permission **X**, while the
matching API route gates on permission **Y** (or doesn't gate at all,
or gates strictly — producing 403 toast spam for users who can see the
button but can't use it).
Scope: 8 routes + 5 components + the seed permission matrix. Hard cap
of 10 findings, ranked by impact. Critical/High/Medium/Low.
---
## CRITICAL
_None._ The four new hard-delete endpoints all gate on
`admin.permanently_delete_clients` on both layers (UI hides the button
via `<PermissionGate resource="admin" action="permanently_delete_clients">`
in `client-detail-header.tsx:162` and via `canHardDelete = can('admin',
'permanently_delete_clients')` in `client-list.tsx:53`; the four routes
all wrap with `withPermission('admin', 'permanently_delete_clients', …)`).
The webhook-replay route gates on `admin.manage_webhooks` — see H1 below
for the matching UI gap.
---
## HIGH
### H1. Webhook replay button has no UI permission gate (403 toast for non-admins)
- **UI:** `src/components/admin/webhooks/webhook-delivery-log.tsx:118-131`
— the Replay `<Button>` renders for any user who can load the page,
with no `<PermissionGate>` wrapper and no `usePermissions().can('admin',
'manage_webhooks')` check.
- **Server:** `src/app/api/v1/admin/webhooks/[webhookId]/deliveries/[deliveryId]/redeliver/route.ts:15`
`withPermission('admin', 'manage_webhooks', …)`.
**Divergence:** A `sales_manager` / `sales_agent` / `viewer` who
somehow lands on `/admin/webhooks/{id}` (e.g. via a deep link from a
shared message) sees enabled Replay buttons. Clicking surfaces a
generic 403 toast — the user has no signal that the action is
restricted, just that "Replay failed".
**Fix:** wrap the Replay `<Button>` in
`<PermissionGate resource="admin" action="manage_webhooks">…</PermissionGate>`,
or skip rendering the entire "Replay" column when
`!can('admin', 'manage_webhooks')`. The page-level guard on
`/admin/webhooks` should prevent non-admins from reaching the route in
the first place, but defense-in-depth is cheap and the toast UX is
poor.
---
### H2. Bulk-archive bulk action exposed to roles without `clients.delete`
- **UI:** `src/components/clients/client-list.tsx:182-190` — the
"Archive" entry in `bulkActions` is unconditionally rendered (only
the "Permanently delete" entry checks `canHardDelete`).
- **Server:** `src/app/api/v1/clients/bulk/route.ts:40-57` — gates
`archive` action on `clients.delete`. Also
`src/app/api/v1/clients/bulk-archive-preflight/route.ts:30`
`withPermission('clients', 'delete', …)`.
**Divergence:** `sales_agent` (`clients.delete:false`,
seed-permissions.ts:246) and `viewer` (`clients.delete:false`,
seed-permissions.ts:323) both see the Archive bulk action. Selecting
clients and pressing it fires the `BulkArchiveWizard`, which calls
`bulk-archive-preflight` (returns 403) followed by `bulk` archive
(also 403). The wizard surfaces this as an opaque error.
**Fix:** mirror the `canHardDelete` pattern — compute
`const canBulkArchive = can('clients', 'delete');` near
`client-list.tsx:53` and conditionally include the Archive entry.
---
### H3. Bulk add_tag / remove_tag exposed to viewer (clients.edit:false)
- **UI:** `src/components/clients/client-list.tsx:165-181` — the "Add
tag" / "Remove tag" bulk actions render with no permission check.
- **Server:** `src/app/api/v1/clients/bulk/route.ts:40-57` — both gate
on `clients.edit`.
**Divergence:** A `viewer` can multi-select rows, click "Add tag" or
"Remove tag", pick a tag in the dialog, hit "Apply", and receive a 403. The standalone bulk tag dialog has no inline gating to prevent
this.
**Fix:** the bulk action menu entries should gate on
`can('clients', 'edit')`. (Sales agent and above pass; only `viewer`
and `residential_partner` see the bug.)
---
### H4. `client-merge-log.surviving_client_id` enforcement absent from per-row port check on bulk hard-delete
- **Server:** `src/lib/services/client-hard-delete.service.ts:269-272`
The bulk preflight loads **every** row in the port
(`db.select(...).from(clients).where(eq(clients.portId, args.portId))`)
into memory, then validates the requested `clientIds` against that map.
That's correct for tenant isolation — a foreign-port id can't appear in
the map — but the inner loop at lines 364-389 then re-fetches each
client by `(id, portId)` and **silently skips** rows where the second
fetch returns nothing (line 377: `if (!c) continue;`). If a client is
archived between preflight and execute by another operator, the bulk
delete reports `deletedCount` lower than the requested set with no
error — the operator has no way to tell which ids were skipped.
**Divergence (perm-adjacent):** the per-row gate is enforced for
tenancy but the failure mode masquerades as success. Combined with
the route's all-or-nothing `withPermission` at the top, a
`permanently_delete_clients`-bearing operator can quietly under-delete.
**Fix:** when `c` is null, push the id into a `skipped: string[]`
array and return it in the response so the UI can surface "3
deleted, 1 skipped (not archived / removed by another user)".
---
## MEDIUM
### M1. `external-eoi` upload allows any role with `documents.upload_signed` regardless of `interests.edit`
- **UI:** `src/components/interests/interest-detail-header.tsx:382-395`
`<PermissionGate resource="documents" action="upload_signed">`.
- **Server:** `src/app/api/v1/interests/[id]/external-eoi/route.ts:8`
`withPermission('documents', 'upload_signed', …)`.
**Divergence:** UI and server agree on the permission, but the seed
matrix has `documents.upload_signed:true` for `sales_agent` (line 264) AND any custom role with that flag — uploading an externally
signed EOI mutates the **interest** (it's the operative `signedDocument`
that flips the interest into a "signed" state inside
`uploadExternallySignedEoi`). The user only needs `documents.upload_signed`,
not `interests.edit`. A custom role with `documents.upload_signed:true`
- `interests.edit:false` can mutate the interest's effective state.
**Fix:** add a second gate inside the route handler:
`if (!ctx.isSuperAdmin && !ctx.permissions?.interests?.edit) throw new ForbiddenError(...)`.
Rationale: signing a doc against an interest is an interest-state
change, not just a document upload. Mirror the same check in
`<PermissionGate>` (use `<PermissionGate resource="interests" action="edit">`
nested inside the `documents.upload_signed` gate).
---
### M2. `change_stage` UI doesn't expose override checkbox in `InlineStagePicker` — server still accepts override
- **UI:** `src/components/interests/inline-stage-picker.tsx:52-58`
the inline picker (used in the detail header at
`interest-detail-header.tsx:221`) sends only
`{ pipelineStage, reason }` and never sets `override:true`. Users
with `override_stage` get no UI affordance to actually use the
permission from the inline picker; they have to open the modal
`InterestStagePicker` (which does expose the checkbox at line 137).
Worse, when a user picks a stage that isn't a legal forward
transition, the inline picker just shows the toast from the server's
`ConflictError` — instead of "you need override; toggle this box".
- **Server:** `src/app/api/v1/interests/[id]/stage/route.ts:14-22`
reads `body.override` and re-checks `interests.override_stage`
permission.
**Divergence:** UI and permission map diverge in the affordance, not
the gate. End-result: the `override_stage` permission is partially
unreachable from the inline picker. Sales managers / agents can
override only via the modal picker.
**Fix:** when the inline picker sees a transition that isn't allowed
by `canTransitionStage(currentStage, newStage)`, check
`can('interests', 'override_stage')` and either auto-set
`override:true` (with a confirmation) or surface a "Use override"
secondary action. Keep the inline picker UX; just don't let the
override permission be silently inaccessible from the most-used
path.
---
### M3. `sales_agent` granted `interests.override_stage:true` — possible copy-paste from sales_manager
- **Seed:** `src/lib/db/seed-permissions.ts:253``SALES_AGENT_PERMISSIONS.interests.override_stage = true`.
This is identical to `SALES_MANAGER_PERMISSIONS.interests.override_stage = true`
at line 176. The same `sales_agent` block has `delete:false` for
clients/interests/yachts/companies/files/etc — all the other
"trust-elevated" flags are explicitly stripped from sales_agent. The
ability to bypass the pipeline-stage transition table is a meaningful
trust elevation: it lets an agent skip prerequisites (e.g. mark an
interest as `eoi_signed` without an actual signed doc) which has
downstream implications for the public berths feed (`Under Offer`
status), the recommender's tier ladder, and the EOI bundle.
**Divergence:** likely intent vs. permission map. Worth confirming
with a product owner; if intentional, leave a code comment. If
unintentional, flip to `false`.
**Fix:** product decision. If demoted, also update
`src/components/admin/roles/role-form.tsx → DEFAULT_PERMISSIONS`
(noted in the file header at seed-permissions.ts:9) so the UI
default for new roles matches.
---
### M4. `bulk-archive-preflight` returns dossier even when client is in another port (defense-in-depth)
- **Server:** `src/app/api/v1/clients/bulk-archive-preflight/route.ts:33-62`
The route loops through `ids` and calls `getClientArchiveDossier(id, ctx.portId)`
for each. If a `clientId` belongs to another port, `getClientArchiveDossier`
throws and the route catches it (line 52-61) and returns a fallback row
with `blockers: ['<error message>']`. This leaks **the existence of an
unknown client id** — an attacker enumerating UUIDs can distinguish
"client doesn't exist" from "client exists but you can't see it" by
parsing the blocker text. The bulk hard-delete route has the same
shape but returns `NotFoundError`.
**Divergence (perm-adjacent):** the preflight route doesn't enforce a
per-id port check before falling through to the dossier service, and
the catch block leaks the failure mode in the response.
**Fix:** in the catch block, replace the dossier error message with a
generic `'Could not load dossier'` blocker. The operator already
selected these ids so they know the count; they don't need the inner
error.
---
## LOW
### L1. `external-eoi` route doesn't enforce `interests.edit` defense-in-depth on the interest port
- **Server:** `src/app/api/v1/interests/[id]/external-eoi/route.ts:8-14`
The route receives `interestId` from the URL and passes it +
`ctx.portId` into `uploadExternallySignedEoi`. The service is
expected to enforce port isolation, but the route itself does no
upfront `(interestId, portId)` existence check before reading the
multipart body — meaning a cross-port id will fully process the
upload (read the file into memory) before the service rejects.
**Divergence:** not strictly a permission divergence; it's resource
waste from missing early port-ownership check. Low because the
service-level reject does close the security hole.
**Fix:** add a one-row `select` on `interests` matching `id` + `portId`
before parsing form data, throw `NotFoundError` on miss.
---
## Summary
- 0 critical
- 4 high (H1H4)
- 4 medium (M1M4)
- 1 low (L1)
Top recommendation: H1 (webhook-replay UI gate) is a
ten-line fix that closes a 403-toast UX bug. H2 + H3 (bulk-archive +
bulk-tag UI gates) are also trivial and remove the same class of bug
across the bulk actions menu. M3 (sales_agent override_stage) needs a
product decision, not code; flag it before shipping the audit.

View File

@@ -0,0 +1,220 @@
# Reliability audit — 2026-05-06 (focused, post-batch deltas)
Scope: NEW services from the recent archive/restore/hard-delete/external-EOI batches.
Out of scope (already covered in `docs/audit-comprehensive-2026-05-06.md`):
worker imports, rate limits, hard-delete error message UX, smart-restore
dead reversal applier, bulk hard-delete redis loop, audit log spam.
---
## Critical
### C1. Bulk archive enqueues zero post-commit side effects
- **File:** `src/app/api/v1/clients/bulk/route.ts:68-134`
- **Scenario:** When the bulk wizard archives 100 clients with high-stakes
reasons, `archiveClientWithDecisions` returns `externalCleanups` and
`releasedBerths` arrays per-client, but `runBulk` discards the return
value. Documenso envelopes that the wizard marked `void_documenso`
never get queued, and "next-in-line" notifications never fire. The
database is left in `documents.status='cancelled'` with the live
Documenso envelope still out for signature — the signer can complete
a legally-binding envelope that the CRM thinks is voided.
- **Fix:** Make the per-row callback return the result, then loop over
`results` after `runBulk` to enqueue Documenso voids and fire
next-in-line notifications (mirroring the single-client route).
Defaulting `documentDecisions` to `'leave'` (line 113-116) hides the
symptom for the bulk wizard but isn't enough — the single-client
service can still surface this if the bulk path is ever generalized.
---
## High
### H1. Restore wizard silently drops every released berth
- **File:** `src/lib/services/client-restore.service.ts:359-372`
- **Scenario:** `applyReversal` for `berth_released` is a no-op with a
comment saying "v1 leaves the berth available". But the dossier (line
122-129) classifies these as `autoReversible` and the UI tells the
operator "still available — re-attaching to the restored client". The
wizard increments `autoReversed` and the audit log records a
successful auto-reverse — but nothing actually happens. Operator
thinks restore re-linked their berth; it didn't.
- **Fix:** Either (a) actually re-link by persisting the original
`interestId` in the `berth_released` decision detail (it's already
there, line 211) and re-inserting an `interestBerths` row + flipping
the berth status back to `under_offer`, or (b) reclassify these as
`reversibleWithPrompt` with copy that says "berth left available —
re-add via the interest detail page".
### H2. Smart-archive berth status update has TOCTOU race
- **File:** `src/lib/services/client-archive.service.ts:191-207`
- **Scenario:** Berth row is read via `dossier.berths` (read outside the
tx) and modified inside the tx without a `for update` lock on
`berths`. Two concurrent flows — e.g. operator A archives client X
while operator B sells berth A1 to client Y — can race: A reads
`berth.status === 'sold' → false`, B's tx commits sold, A's tx then
flips it back to `available`. The "still under offer" subselect
doesn't catch this because berth.status is the source of truth, not
interest_berths.
- **Fix:** Add `tx.select(...).from(berths).where(eq(berths.id, d.berthId)).for('update')`
before the status flip and re-check `status !== 'sold'` against the
locked row.
### H3. Bulk archive can pick the wrong interest for berth release
- **File:** `src/app/api/v1/clients/bulk/route.ts:95-103`
- **Scenario:** When a client has multiple interests linked to the same
berth, the bulk wizard picks `dossier.interests.find((i) =>
i.primaryBerthMooring === b.mooringNumber)` and falls back to
`dossier.interests[0]?.interestId ?? ''`. The fallback to the
first-interest-or-empty-string can hand `archiveClientWithDecisions`
an `interestId` that was never linked to that berth — so the
`delete from interest_berths where berthId=… and interestId=…`
matches zero rows and the link is silently retained. Worse: an empty
string `''` reaches the delete, which still matches zero rows but
leaves the berth status check believing the link was removed.
- **Fix:** Build the berth→interest map from `interestBerthRows` (the
authoritative join) rather than guessing by `primaryBerthMooring`,
and skip berths with no resolvable interest rather than emitting an
empty-string interestId.
### H4. External EOI runs four writes outside a transaction
- **File:** `src/lib/services/external-eoi.service.ts:67-155`
- **Scenario:** `getStorageBackend().put()`, `files.insert`,
`documents.insert`, `documentEvents.insert`, and the interests
update happen as five independent operations. If any one fails after
the storage upload, you're left with an orphan PDF in S3/MinIO and
partial DB state. If the documents insert fails after the file
insert, the file row points to a storage key with no document
referencing it — and the interest never advances.
- **Fix:** Wrap files/documents/documentEvents/interests in a single
`db.transaction`. Storage upload stays outside (S3 isn't
transactional) but on tx failure, schedule a cleanup job that deletes
the orphan storage object, or accept the orphan and add a janitor.
### H5. Bulk wizard double-submit re-archives the same client and racy errors
- **File:** `src/app/api/v1/clients/bulk/route.ts:68-120` +
`src/lib/services/client-archive.service.ts:165-173`
- **Scenario:** The single-client `archiveClientWithDecisions` locks
the row and throws `ConflictError('Client is already archived')` on
re-entry — good. But `runBulk` swallows the error string and returns
it as `{ok:false, error:"Client is already archived"}` for that
client. If the bulk wizard double-submits (network retry, double
click), partial successes from the first request now look like
per-client failures in the response, confusing the operator. There's
no idempotency key on the bulk submit.
- **Fix:** Treat `ConflictError('already archived')` as success in the
bulk per-row handler (the desired end state is reached). Or add an
idempotency-key header on the bulk endpoint that short-circuits a
duplicate request with the cached response.
---
## Medium
### M1. Hard-delete `clientMergeLog.surviving_client_id` deletes audit history
- **File:** `src/lib/services/client-hard-delete.service.ts:209`
- **Scenario:** The comment says "merged records remain in the log
because mergedClientId has no FK", but the delete is wider than
needed: it removes every merge-log row where this client was the
survivor. If client X (being deleted) previously absorbed clients
A/B/C, the audit trail of those merges is lost on X's deletion. The
surviving rows that remain (`mergedClientId = X`) are now
inconsistent — they reference a survivor that no longer exists.
- **Fix:** Either preserve the survivor rows by setting
`surviving_client_id = NULL` (requires column nullable) or keep the
current behavior but document it more visibly. At minimum, log the
deleted merge-log row count so operators can investigate gaps.
### M2. Documenso void worker has no max-retry guard for non-404 errors
- **File:** `src/lib/queue/workers/documents.ts:19-37`
- **Scenario:** `voidDocument` throws `CodedError` on non-404 failures
(auth error, network blip, Documenso 500). BullMQ retries with
backoff, but there's no per-job idempotency check — the second
retry hits the same envelope, voidDocument's 404 short-circuit only
kicks in if Documenso has actually voided it on the first retry
before the API call returned an error. A persistent 401 / 403 will
retry forever (until BullMQ exhausts attempts) and the documents row
stays `cancelled` in the CRM with the envelope still live in
Documenso. The DLQ is mentioned in the comment but the worker
doesn't surface a DLQ alert hook.
- **Fix:** On exhaustion, write back to `documents` (e.g.
`cancellation_failed=true`) and emit an admin notification so the
envelope can be voided manually.
### M3. Next-in-line notification fan-out unhandled rejection
- **File:** `src/lib/services/next-in-line-notify.service.ts:75-87`
- **Scenario:** Each `void createNotification(...)` is a fire-and-forget
promise with no `.catch` handler. If `notifications.service`
dispatches to a DB that's transiently down, the unhandled rejection
will surface in the Node process with no recipient context (the
closure captured `userId` is in the stack but pino won't include it
unless explicitly logged). Process-level handlers will log it but
individual recipients silently lose their notification.
- **Fix:** `.catch((err) => logger.warn({err, userId, berthId:
input.berthId}, 'next-in-line notification failed'))`.
### M4. Restore service uses `any` for transaction type
- **File:** `src/lib/services/client-restore.service.ts:354-355`
- **Scenario:** `applyReversal(tx: any, ...)` defeats Drizzle's type
safety. A future schema rename (e.g. `yachts.status` enum change)
won't fail at compile time inside this function. Combined with the
documented v1 no-op for `berth_released`, the function looks
innocuous but carries the most risk.
- **Fix:** Use the proper Drizzle tx type — `Parameters<Parameters<typeof
db.transaction>[0]>[0]` or a named type alias from
`@/lib/db/types.ts` if one exists.
### M5. interests.changeInterestStage milestones write outside tx
- **File:** `src/lib/services/interests.service.ts:630-648`
- **Scenario:** The override path (and normal path) writes
`pipelineStage` in one update and milestone fields
(`dateEoiSent`, `dateContractSigned`, etc.) in a second update. If
the process crashes between the two, the stage advances but the
milestone is never recorded. Funnel/conversion math then under-
counts that interest. Over-the-wire this is rare but the audit log
fires before the milestone update succeeds, so the audit trail
claims a complete transition that's actually half-applied.
- **Fix:** Combine both into a single update statement, computing the
milestone fields in JS and merging them into the `set({...})` clause.
---
## Low
### L1. Smart-archive coalesces invoice notes via SQL string concat
- **File:** `src/lib/services/client-archive.service.ts:288-291`
- **Scenario:** `notes: sql\`coalesce(${invoices.notes}, '') || ${...}\``embeds`new Date().toISOString()`and the action label inside a
parameterized string. The values are bound, so it's not an injection
risk, but the`\n[archive ...]` marker is appended unconditionally —
re-running the archive on a not-yet-committed client would double
the marker. Combined with H5 (no idempotency on bulk), a retry could
bloat invoice notes with duplicate markers.
- **Fix:** Append only when the marker isn't already present, or rely
on the `clients.archivedAt is null` precheck (which already guards
re-entry) and accept the duplicate as theoretically impossible.
### L2. Hard-delete `requestHardDeleteCode` reveals client existence pre-archive
- **File:** `src/lib/services/client-hard-delete.service.ts:77-85`
- **Scenario:** A user without `admin.permanently_delete_clients`
shouldn't reach this service, so this is theoretical, but the
ConflictError "Client must be archived" leaks the existence of an
unarchived client to anyone who can reach the route. The audit doc
flagged hard-delete error messages already (out of scope), but this
specific error path isn't covered there.
- **Fix:** Same as the audit-doc finding for the symmetric path —
return a generic `NotFoundError` instead of distinguishing
"not found" from "not archived" externally; log the distinction
internally only.

View File

@@ -0,0 +1,722 @@
# 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.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.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)

View File

@@ -0,0 +1,223 @@
# Documenso integration audit
Reference for the multi-port Documenso signing pipeline in this CRM. Mirrors the legacy client portal's flow ([generate-quick-eoi.ts](../client-portal/server/api/eoi/generate-quick-eoi.ts), [documeso.ts](../client-portal/server/utils/documeso.ts), [documenso.post.ts](../client-portal/server/api/webhooks/documenso.post.ts), [website /sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)) but rewired for multi-tenant + better-auth + Drizzle.
---
## Per-port configuration
All Documenso settings live in `system_settings` keyed by `(key, port_id)` and are read via [`getPortDocumensoConfig(portId)`](../src/lib/services/port-config.ts). Falls back to env vars when no per-port row exists. Surfaced in the admin UI at `/[portSlug]/admin/documenso`.
| Setting key | Type | Purpose |
| ----------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------- |
| `documenso_api_url_override` | string | Per-port Documenso instance URL. Falls back to `DOCUMENSO_API_URL` env. |
| `documenso_api_key_override` | string | API key. Stored plaintext. |
| `documenso_api_version_override` | `'v1' \| 'v2'` | Different ports may run different Documenso versions. |
| `documenso_eoi_template_id` | int | Template ID for EOI generation. |
| `documenso_client_recipient_id` | int | Template recipient slot — client (signing order 1). |
| `documenso_developer_recipient_id` | int | Template recipient slot — developer (signing order 2). |
| `documenso_approval_recipient_id` | int | Template recipient slot — approver (signing order 3). |
| `documenso_developer_name` | string | Display name for developer signer (legacy hardcoded "David Mizrahi"). |
| `documenso_developer_email` | string | Developer signer email. |
| `documenso_approver_name` | string | Approver display name. |
| `documenso_approver_email` | string | Approver email. |
| `documenso_webhook_secret` | string | Per-port webhook secret. Receiver tries each enabled secret with timing-safe equal. |
| `eoi_default_pathway` | `'documenso-template' \| 'inapp'` | Which path is used when EOI is generated without explicit choice. |
| `eoi_send_mode` | `'auto' \| 'manual'` | Auto = send branded invitation email immediately; manual = rep clicks Send. |
| `embedded_signing_host` | string | Public host that wraps Documenso URLs into `{host}/sign/<type>/<token>`. |
| `documenso_contract_template_id` | int (optional) | Optional template for sales contracts. Blank = upload-and-place-fields per deal. |
| `documenso_reservation_template_id` | int (optional) | Optional template for reservation agreements. Same logic as contract. |
---
## Document type matrix
| Type | Generation flow | Signers | Field placement |
| --------------- | ----------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------- |
| **EOI** | Documenso template (`eoi_template_id`) + form-fill values | Static: client, developer, approver (per-port) | Templated — fields baked into Documenso template |
| **Contract** | Per-deal upload (drafted custom). Template fallback if configured | Custom per deal — rep specifies | Per-deal placement — default footer-anchored fallback |
| **Reservation** | Per-deal upload OR template if configured | Custom per deal | Per-deal placement |
## Documenso field types
Custom-uploaded documents (contracts, reservations) need a per-deal field placement step — different documents need different mixes. The CRM exposes the full Documenso-supported field palette so reps can place whatever the document calls for without code changes.
| Field type | Use case | Needs `fieldMeta`? | What goes in meta |
| ---------------- | ------------------------------------------------------- | ------------------ | --------------------------------------------------- |
| `SIGNATURE` | Drawn signature — almost every signing flow | No | — |
| `FREE_SIGNATURE` | Type-or-draw signature variant | No | — |
| `INITIALS` | Per-page initials block | No | — |
| `DATE` | Auto-fills the date when the recipient signs | No | — |
| `EMAIL` | Auto-fills the recipient's email | No | — |
| `NAME` | Auto-fills the recipient's name | No | — |
| `TEXT` | Free text input (e.g. address, notes, place of signing) | Yes | `{ text?, label?, required?, readOnly? }` |
| `NUMBER` | Numeric input with optional min/max | Yes | `{ numberFormat?, min?, max?, required? }` |
| `CHECKBOX` | Boolean / single checkbox | Yes | `{ values: [{ checked, value }], validationRule? }` |
| `DROPDOWN` | Pick from a fixed list | Yes | `{ values: [{ value }], defaultValue? }` |
| `RADIO` | Mutually-exclusive options | Yes | `{ values: [{ checked, value }] }` |
Helper: [`fieldTypeNeedsMeta(type)`](../src/lib/services/documenso-client.ts) returns true for the configurable types so the placement UI knows when to surface a config side-panel.
`fieldMeta` is forwarded verbatim by [`placeFields()`](../src/lib/services/documenso-client.ts) on the v2 path. v1 silently ignores the property — fields render as blank inputs. Configurable behaviour (validation, defaults) only fires on v2 instances.
---
## Documenso v1 vs v2 endpoint mapping
The [`documenso-client.ts`](../src/lib/services/documenso-client.ts) abstracts both. Each function picks v1 or v2 from `getPortDocumensoConfig(portId).apiVersion`.
| Operation | v1 (1.131.32) | v2.x |
| ------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------- |
| Create document from upload | `POST /api/v1/documents` (body: `{ title, document, recipients }`) | `POST /api/v2/envelope` |
| Generate document from template | `POST /api/v1/templates/{id}/generate-document` | (template-from-envelope path) |
| Send for signing | `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/{id}/send` |
| Place a field | `POST /api/v1/documents/{id}/fields` (PIXEL coords, one at a time) | `POST /api/v2/envelope/field/create-many` (PERCENT, bulk) |
| Get document state | `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{id}` |
| Send reminder to one recipient | `POST /api/v1/documents/{id}/recipients/{rid}/remind` | `POST /api/v2/envelope/{id}/recipient/{rid}/remind` |
| Download finalized PDF | `GET /api/v1/documents/{id}/download``{ downloadUrl }` then GET that URL | `GET /api/v2/envelope/{id}/download` (same shape) |
| Cancel / void | `DELETE /api/v1/documents/{id}` | `DELETE /api/v2/envelope/{id}` |
| Healthcheck | `GET /api/v1/health` | (v1 path used) |
**Field key rename in v2 responses**: `id``documentId` and recipient `id``recipientId`. Our [`normalizeDocument()`](../src/lib/services/documenso-client.ts) handles both shapes.
---
## Signing-flow lifecycle
```
[rep clicks Generate] (CRM)
buildEoiContext(interestId, portId) service
generateAndSign(templateId, ctx, signers) creates Documenso doc
POST /documents/{id}/send {sendEmail:false} Documenso starts the chain;
it does NOT email signers
extract signing URLs from response service
transformSigningUrl(url, host, role) wrap as {host}/sign/<role>/<token>
if eoi_send_mode === 'auto':
sendSigningInvitation(client) our branded HTML email goes out
else:
UI shows the URL + Send button rep dispatches manually
```
When the client signs:
```
Documenso fires DOCUMENT_SIGNED webhook ──► /api/webhooks/documenso
verify x-documenso-secret (per-port lookup)
update document_signers row: status='signed', signedAt=...
if next signer in chain has not been notified:
sendSigningInvitation(developer) cascading "your turn" email
```
When the document reaches fully-signed:
```
Documenso fires DOCUMENT_COMPLETED webhook
download signed PDF from Documenso
store in storage backend → creates files row
update document: status='completed', completedAt=...
sendSigningCompleted([client, developer, approver], pdfFileId)
all parties get the signed PDF
update interest: pipelineStage='eoi_signed' (or contract_signed, etc)
```
---
## Embedded signing on the marketing website
The CRM emits signing URLs in the form `{embeddedSigningHost}/sign/<role>/<token>`. The marketing website ([Port Nimara/Website/pages/sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)) hosts the page, embeds Documenso via `@documenso/embed-vue`'s `<EmbedSignDocument>`, and POSTs back to the CRM webhook on completion.
For the embed to work, the Documenso instance MUST send `Access-Control-Allow-Origin` headers permitting the website origin.
### nginx CORS block to apply on `signatures.portnimara.dev`
Add to the relevant `server { ... }` block:
```nginx
location / {
# CORS for embedded signing — allow the marketing-website origin
# to load the Documenso signing iframe.
add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
# Preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
# ... your existing proxy_pass block to Documenso
}
```
To support multiple website origins (e.g. Port Amador hosted on a different domain), use a regex:
```nginx
set $cors_origin "";
if ($http_origin ~* "^https://(portnimara\.com|portamador\.com)$") {
set $cors_origin $http_origin;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
```
---
## What's deferred vs landed in this build
**Landed:**
- Per-port admin settings — every Documenso config knob is exposed at `/admin/documenso`
- Branded invitation, completion, and reminder email templates
- `transformSigningUrl()` for `{host}/sign/<role>/<token>` URL wrapping
- Documenso v1 + v2 dual-version client (existing)
- Webhook handler with timing-safe per-port secret resolution (existing)
- Contract + Reservation tab UI shells with paper-signed upload + "send for signing" placeholder
- Stage-conditional tab visibility for EOI / Contract / Reservation
**Deferred (separate sessions):**
- Custom document upload-to-Documenso service for contract/reservation (POST PDF → place fields → send). The tabs currently surface a "coming soon" dialog.
- Recipient + signing order configurator UI (rep specifies signers per deal for custom-uploaded docs).
- Drag-and-drop field placement UI on uploaded PDF previews. The fallback when this lands will be `computeDefaultSignatureLayout()` (footer-anchored fields).
- Webhook handler enhancements to track per-signer `sent_at`/`opened_at`/`signed_at` and trigger the cascading "your turn" branded emails. Currently the webhook just updates document status.
- Auto-store signed PDFs in storage backend and trigger `sendSigningCompleted()` on `DOCUMENT_COMPLETED`. Old system has this; needs porting.
**Manual ops work for you:**
- Apply the nginx CORS block above on your prod Documenso instance.
- Decide whether to upgrade prod Documenso to v2 (would unlock cleaner field placement + better envelope semantics).
- Configure each port's developer/approver names and template IDs at `/[portSlug]/admin/documenso`.