fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish

Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 13:28:50 +02:00
parent 397dbd1490
commit 4b5f85cb7d
158 changed files with 12255 additions and 1303 deletions

View File

@@ -100,7 +100,7 @@ src/
Three system roots (`Clients/`, `Companies/`, `Yachts/`) are auto-created on port init via `ensureSystemRoots`. Per-entity subfolders are created lazily on first auto-deposit / manual upload via `ensureEntityFolder` — concurrent callers race safely via the partial unique index `uniq_document_folders_entity` on `(port_id, entity_type, entity_id) WHERE entity_id IS NOT NULL`. The `chk_system_folder_shape` CHECK pins the shape of system rows. Rename/move/delete on `system_managed = true` folders is rejected by `assertNotSystemManaged` (service-level, not DB-level). Entity rename auto-syncs the folder name via `syncEntityFolderName`; archive applies a ` (archived)` suffix via `applyEntityArchivedSuffix`; hard-delete demotes (`system_managed = false`) + appends ` (deleted)` via `demoteSystemFolderOnEntityDelete`.
Auto-deposit on signing completion: `handleDocumentCompleted` resolves the owner via the Owner-wins chain (`document.clientId ?? .companyId ?? .yachtId ?? interest.clientId ?? interest.yachtId`), ensures the matching entity subfolder, and sets `files.folder_id` + the matching entity FK on the signed file row. Falls back to root when no owner is resolvable. (Note: `interests` table has no `companyId` column, hence the chain's interest fallback omits it.)
Auto-deposit on signing completion: `handleDocumentCompleted` resolves the owner via the Owner-wins chain (`document.clientId ?? .companyId ?? .yachtId ?? interest.clientId`), ensures the matching entity subfolder, and sets `files.folder_id` + the matching entity FK on the signed file row. Falls back to root when no owner is resolvable. (Notes: `interests` table has no `companyId` column, hence the chain's interest fallback omits it; `interests.clientId` is NOT NULL so an `interest.yachtId` tail branch — if added — would be structurally unreachable.)
Aggregated projection: `listFilesAggregatedByEntity` / `listInflightWorkflowsAggregatedByEntity` walk the relationship graph from the requested entity (symmetric reach: Client ↔ Company via `company_memberships` filtered to active rows via `isNull(end_date)`, ↔ Yacht via `yachts.current_owner_type/id`) and return results grouped by source (DIRECTLY ATTACHED / FROM COMPANY / FROM YACHT / FROM CLIENT). Each group caps at 20 rows with a total for `Show all (N)`. The files projection LEFT JOINs `documents` on `signed_file_id` to surface `signedFromDocumentId` per row — used by the UI's "view signing details" link. **File-FK snapshot is the source of truth** — historical files stay where they were filed even if the linked entity's relationships change. **Defense-in-depth `port_id` filter at every join** (per recommender precedent) — entry-point check alone is rejected. Completed workflows are hidden from folder views (`listDocuments` excludes `status='completed'` when `folderId` is set); the signed-PDF file surfaces in the Files section with a "view signing details" link to the workflow audit trail (via `GET /api/v1/documents/[id]/signing-details`).

View File

@@ -67,3 +67,23 @@ exact bytes:
1. In Documenso, open the EOI template.
2. Download the source PDF.
3. Drop it here as `eoi-template.pdf`.
### Known asset issue: Email field clipped at top
The current `eoi-template.pdf` has the `Email` AcroForm field box positioned
slightly too low — long email addresses render with the top pixel row
clipped. **Fix is asset-side, not code-side**: pdf-lib only fills field
boxes, it can't move them. To resolve:
1. Open `eoi-template.pdf` in any PDF form editor (Acrobat, PDFescape,
PDF Studio, or Documenso's own template editor).
2. Select the `Email` field box; nudge its `y` origin down by ~3 pt (or
increase its height by ~3 pt) so the rendered text has visual margin
from the top edge.
3. Save → re-upload to Documenso (so both pathways stay in sync) →
bump the sha256 in this README + `EXPECTED_EOI_SHA256` per the steps
above.
Affects both the in-app pathway (renders via pdf-lib AcroForm fill) and
the Documenso pathway (Documenso's own renderer respects the same field
geometry).

View File

@@ -0,0 +1,335 @@
# Comprehensive Audit Findings — 2026-05-15
Discovery pass across all 19 areas of `docs/AUDIT-CATALOG.md`. Code-side via 9 parallel sub-agents + browser sweep via Playwright MCP. Per-agent raw output cached under `docs/audit-findings-tmp/`.
## Scoreboard
| Severity | Count |
| ----------- | ------ |
| 🔴 CRITICAL | 3 |
| 🟠 HIGH | 15 |
| 🟡 MEDIUM | 48 |
| 🟢 LOW | 8 |
| **Total** | **74** |
The 3 critical and the most actionable HIGH issues should head the next fix wave.
---
## 🔴 CRITICAL
### C-01 (B-01) — INNER JOIN on hard-deleted berth silently drops interest→berth links
- **Files:** `src/lib/services/interest-berths.service.ts:55` (`getPrimaryBerth`), `:87` (`getPrimaryBerthsForInterests`), `:140` (`listBerthsForInterest`)
- **What:** Three helpers use `INNER JOIN berths ON berths.id = interestBerths.berthId`. Hard-deleting a berth makes the join silently drop the row.
- **Impact:** Interest detail shows `berthId: null` / `berthMooringNumber: null`. Kanban card shows no berth chip. EOI generation produces empty mooring field. `archiveInterest` calls `getPrimaryBerth` before evaluating the berth rule — null result causes the rule to be **skipped entirely**.
- **Fix:** Switch all three to `LEFT JOIN berths`. Callers already handle null. Add service-layer guard preventing hard-delete of berths with `interest_berths` rows (require unlink or soft-archive first).
### C-02 (R-021) — `/setup` missing from `PUBLIC_PATHS` — bootstrap unreachable on fresh DB
- **File:** `src/proxy.ts:51-73`
- **What:** `PUBLIC_PATHS` includes `/api/v1/bootstrap/` but NOT `/setup`. Unauthenticated user → `/setup` → middleware redirects to `/login?redirect=/setup`. Login useEffect fetches bootstrap status, calls `router.replace('/setup')` → middleware again → infinite redirect loop.
- **Impact:** Fresh deployment (no super admin) is functionally deadlocked. The first operator cannot reach setup without already having a session — impossible on a fresh DB.
- **Fix:** Add `'/setup'` to `PUBLIC_PATHS`. `POST /api/v1/bootstrap/super-admin` already self-protects with `hasAnySuperAdmin()`.
- **Browser-verified:** Navigating to `/setup` unauthenticated redirects to `/login` (no `?redirect=` even). The bootstrap-status check at `src/app/(auth)/login/page.tsx:41` confirms: `if (payload.data?.needsBootstrap) router.replace('/setup');` — feeds the loop on fresh DB.
### C-03 (NEW, browser-discovered) — Generic `PATCH /api/v1/interests/[id]` bypasses ALL stage-transition guards
- **Files:** `src/app/api/v1/interests/[id]/route.ts:20-32` (calls `updateInterest`); `src/lib/services/interests.service.ts:701` (`updateInterest`); `src/lib/validators/interests.ts:68,90` (`pipelineStage` flows through `updateInterestSchema` to the service)
- **What:** The `/stage` endpoint (`src/app/api/v1/interests/[id]/stage/route.ts`) calls `changeInterestStage` which enforces `STAGE_NOOP` early-return, `canTransitionStage()` table guard, override-requires-permission, and override-requires-≥5-char-reason. The generic PATCH endpoint calls `updateInterest` which writes the full payload (incl. `pipelineStage`) directly to the DB with **none** of those guards.
- **Browser proof:**
- PATCH `/api/v1/interests/<deposit-paid-id>` with `{ pipelineStage: 'enquiry' }`**200 OK**, interest demoted to enquiry. (Same call via `/stage` correctly returned 400 with "Cannot move from Deposit Paid directly to New Enquiry. Use the override option ...".)
- PATCH `/api/v1/interests/<eoi-id>` with `{ pipelineStage: 'eoi' }` (same-stage) → **200 with full 1249-byte body** instead of 204. F27 fix only works through `/stage`.
- Backwards write via generic PATCH leaves `eoiDocStatus: 'sent'` while `pipelineStage = 'enquiry'` — corrupted state.
- Audit row written as generic `action: 'update'` with diff, not `action: 'stage_change'` with proper metadata. Webhook event `interest:updated` not `interest:stageChanged`.
- **Impact:** Any caller (rep tool, integration, mistake in frontend) hitting the generic PATCH can drive an interest to any stage with no override permission, no reason, no audit-as-stage-change. Same-stage spam fires no-op writes that bump `updated_at` and emit redundant socket+webhook events. The corrupted-state surface (stage rolled back but doc-status still says signed) breaks downstream rules-engine evaluations that branch on stage.
- **Fix:** In `updateInterestSchema`, omit `pipelineStage` (force callers to use `/stage`); OR in `updateInterest`, when `pipelineStage` is in the payload, delegate to `changeInterestStage` with the full guard chain. Either prevents the bypass surface from existing.
---
## 🟠 HIGH
### H-01 (SC-02) — Multiple FKs `ON DELETE NO ACTION` while Drizzle declares them nullable
- **Files:** `src/lib/db/schema/interests.ts:29,32` (portId/clientId); `src/lib/db/schema/documents.ts:72,85,86,176` (clientId/fileId/signedFileId/signerId); `src/lib/db/schema/reservations.ts:18,24,25,27,28,33` (all 6 berthReservations FKs); `src/lib/db/schema/operations.ts:25` (reminders.clientId); `src/lib/db/schema/financial.ts:120` (invoices.pdfFileId)
- **What:** `.references(...)` without `{ onDelete }` emits `ON DELETE NO ACTION`. Hard-deleting a parent (client, berth, yacht, file) blocks at FK level.
- **Fix:** Add `{ onDelete: 'set null' }` for nullable FKs that should tolerate parent deletion; explicit `{ onDelete: 'restrict' }` for those that intentionally block (`interests.clientId` design intent is archive-first).
### H-02 (R-017/018) — CRM post-login redirect ignores `?redirect=` param
- **File:** `src/app/(auth)/login/page.tsx:79`
- **What:** Middleware redirects unauthenticated → `/login?redirect=<path>`. Login page never reads `useSearchParams()`; always `router.push('/dashboard')`.
- **Impact:** Email/bookmark/shared deep links into specific clients/interests silently dump to dashboard.
- **Fix:** Read `searchParams.get('redirect')`, validate same-origin (`startsWith('/')`, not `'//'`), use as push target.
### H-03 (R-023) — CRM invite token in query string leaks to access logs
- **File:** `src/lib/services/crm-invite.service.ts:71,233`
- **What:** `${env.APP_URL}/set-password?token=${raw}` — raw 32-byte token in query param. Portal flow was migrated to `#token=` fragment in 2026-05-14 specifically to keep tokens out of logs/Referer; CRM invite path missed the migration.
- **Impact:** Every nginx/Caddy access log line for `GET /set-password?token=<raw>` persists token to disk. Forwarded to SIEM/S3/monitoring → token visible to anyone with log access. Token grants account creation.
- **Fix:** Change `createCrmInvite` + `resendCrmInvite` to emit `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`. Update `set-password/page.tsx` to use the fragment-reading pattern from `PasswordSetForm` (`readTokenFromUrl()`) with `?token=` back-compat for outstanding tokens.
### H-04 (R-029) — `sign-in-by-identifier` 429 missing `Retry-After`
- **File:** `src/app/api/auth/sign-in-by-identifier/route.ts:47-51`
- **What:** Builds 429 response with `headers: rateLimitHeaders(rl)` which only emits `X-RateLimit-Limit/Remaining/Reset`. `enforcePublicRateLimit` adds `Retry-After`; this route uses `checkRateLimit` directly and skips it.
- **Impact:** RFC 6585 §4 violation. Automated clients can't back off correctly.
- **Fix:** Add `'Retry-After': Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000)).toString()`.
### H-05 (AU-01a) — `toggleAccount` writes no audit row
- **File:** `src/lib/services/email-accounts.service.ts:86-116`
- **What:** Sets `isActive` on email account with no `createAuditLog` call. `connectAccount` (line 70) and `disconnectAccount` (line 139) do, but enable/disable in between is silent.
- **Impact:** Silently disabling an email account suppresses bounce-detection or reroutes replies — compliance gap on a security-relevant config change.
- **Fix:** Add `void createAuditLog({ action: 'update', entityType: 'email_account', entityId: accountId, newValue: { isActive: data.isActive }, ... })` inside `toggleAccount`.
### H-06 (AU-02) — Encrypted credential ciphertext stored in audit log without masking
- **Files:** `src/lib/services/settings.service.ts:66-76` + `src/lib/services/sales-email-config.service.ts:281-299`
- **What:** `updateSalesEmailConfig` calls `upsertSetting('sales_smtp_pass_encrypted', <ciphertext>, portId, meta)`. `upsertSetting` records `newValue: { value: '<ciphertext>' }`. `maskSensitiveFields` checks JSON keys against `SENSITIVE_KEY_FRAGMENTS`; the wrapping key `"value"` isn't in the list. Ciphertext lands verbatim in `audit_logs.new_value`.
- **Impact:** Audit log readable by all admins with `admin.view_audit_log`. DB read access exfils ciphertext; if `EMAIL_CREDENTIAL_KEY` is ever compromised, the historical audit log becomes a credential store.
- **Fix:** In `upsertSetting`, detect when key ends with `_encrypted` (or accept `redactValue?: boolean`) and record `newValue: { value: '[redacted]' }`.
### H-07 (AU-10) — Cascade-archived interests produce no individual audit rows
- **File:** `src/lib/services/clients.service.ts:578-618`
- **What:** `archiveClient` batch-archives open interests, writes ONE `entityType: 'client'` row with `newValue: { cascadedInterestIds: [...] }`. No per-interest rows. `search_text` doesn't include `new_value`, so searching for an interest ID returns nothing.
- **Impact:** Auditor querying for a specific archived interest sees no archive event; must know to look at parent client row.
- **Fix:** Loop over `archivedInterestIds` and emit per-interest `createAuditLog({ action: 'archive', entityType: 'interest', entityId, metadata: { cascadeSource: 'client_archive', clientId } })` (fire-and-forget).
### H-08 (EM-XX) — Sales transporter missing SMTP timeouts
- **File:** `src/lib/services/sales-email-config.service.ts:331-337`
- **What:** `createSalesTransporter` builds nodemailer transport with no timeout options. Compare `createTransporter` in `src/lib/email/index.ts:26-37` which uses `SMTP_TIMEOUTS = { connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 30_000 }`.
- **Impact:** Hung SMTP relay can stall send-out indefinitely. Email queue concurrency=5, maxAttempts=5. One stuck TCP connection → 2-min default × 5 retries = 10min/job × 5 slots = whole pool blocked for 10min by a single flaky send.
- **Fix:** Apply `SMTP_TIMEOUTS` constant to `nodemailer.createTransport` in `createSalesTransporter`.
### H-09 (B-16) — AppShell remounts children on breakpoint crossing, destroying form state
- **File:** `src/components/layout/app-shell.tsx:58-70`
- **What:** When `isMobile` flips on resize, the shell switches between `<MobileLayout>{children}</MobileLayout>` and the desktop `<div>...{children}...</div>`. React unmounts and remounts `children`, destroying any in-progress `useState` form drafts including `InlineEditableField`.
- **Impact:** User editing a client name on desktop who resizes past mobile breakpoint loses unsaved draft text. Multi-step modal forms (reconcile wizard) open during resize get unmounted.
- **Fix:** Wrap shared content with stable `key`, or use CSS-only responsive layout so children subtree never remounts. Alternatively `key={isMobile ? 'mobile' : 'desktop'}` only on shell wrappers with `children` stable via Portal.
### H-10 (U-059) — Unicode glyphs as status icons in portal documents page
- **File:** `src/app/(portal)/portal/documents/page.tsx:85-89`
- **What:** Signer status rendered as raw Unicode (`'✓'` signed, `'✗'` declined, `'○'` pending) inside colour-coded `<span>` with no `aria-label`.
- **Impact:** Screen readers read literal Unicode names. Project memory: decorative unicode glyphs explicitly flagged. `inline-stage-picker.tsx:443` comment confirms the pattern ("was ⚑ unicode glyph — replaced with a Lucide").
- **Fix:** Replace with `<CheckCircle2>` / `<XCircle>` / `<Circle>` Lucide icons + `aria-label`.
### H-11 (U-066) — Vaul Drawer used for mobile search overlay (violates Sheet doctrine)
- **File:** `src/components/search/mobile-search-overlay.tsx:6`
- **What:** `import { Drawer as VaulDrawer } from 'vaul'`. Search overlay is full-screen, not a bottom sheet. CLAUDE.md: Vaul reserved for mobile-bottom-sheet only (currently `MoreSheet` only).
- **Fix:** Convert to `<Sheet side="bottom">` or `<Dialog>` fullscreen. Custom visualViewport handling (lines 50-89) becomes redundant with Radix dialog backing.
### H-12 (U-076) — Native `alert()` for bulk-action failure feedback in 3 lists
- **Files:** `src/components/interests/interest-list.tsx:146`, `src/components/companies/company-list.tsx:73`, `src/components/yachts/yacht-list.tsx:66`
- **What:** Partial-failure feedback via `alert(...)`. `client-list.tsx:145` uses `toast.warning(...)` correctly.
- **Impact:** Native alert blocks main thread, can't be styled, fires in tests without suppression.
- **Fix:** Replace with `toast.warning(...)` matching `client-list.tsx`.
### H-13 (U-079) — Icon-only buttons missing `aria-label` (5 sites)
- **Files:** `src/components/notifications/notification-bell.tsx:65`, `src/components/files/file-grid.tsx:121`, `src/components/admin/forms/form-template-list.tsx:102`, `src/components/email/email-accounts-list.tsx:159`, `src/components/companies/company-members-tab.tsx:228`
- **Pattern reference:** `src/components/shared/folder-actions-menu.tsx:96` correctly uses `<span className="sr-only">More folder actions</span>`.
- **Fix:** Add `aria-label` to each, following the folder-actions-menu sr-only pattern.
### H-14 (NEW, browser-discovered) — `DELETE /api/v1/interests/[id]/outcome` with empty body crashes 500
- **File:** `src/app/api/v1/interests/[id]/outcome/route.ts:27-30`; `src/lib/api/route-helpers.ts` (parseBody)
- **What:** The DELETE handler calls `parseBody(req, clearOutcomeSchema)`. `clearOutcomeSchema` says `reopenStage` is optional. But DELETE with no body causes parseBody to throw an unhandled error → 500 internal-server-error JSON. Sending `{ reopenStage: 'qualified' }` returns 200.
- **Browser proof:** Two consecutive `DELETE /api/v1/interests/<wonId>/outcome` calls (no body) returned 500 with `requestId: bc807db5-...` / `d21b5b3e-...`. Same call with body `{}` would presumably also work (not tested) — the issue is empty-vs-omitted body.
- **Impact:** F26 reopen flow — when the user clicks "Reopen" without overriding the auto-detected previous stage, the request crashes. Frontend may always send a body, but the API contract claims optional and the wire-level test fails.
- **Fix:** In `parseBody`, treat empty request body as `{}` for DELETE/POST routes whose schemas have all-optional fields; OR in the route handler, parse the body conditionally on `req.headers.get('content-length') !== '0'`.
### H-15 (NEW, browser-discovered) — Sales-agent visiting an admin page silently bounces to dashboard (no 403 / feedback)
- **Files:** Middleware in `src/proxy.ts` and/or per-route admin layout
- **What:** Sales-agent navigating to `http://localhost:3000/port-amador/admin/audit` lands at `http://localhost:3000/port-amador/dashboard`. URL silently changes; no toast, no 403 page, no "Access denied" feedback. The API itself correctly returns 403 ("Insufficient permissions" or "No access to this port") — the UI just hides the failure.
- **Impact:** A rep clicking a deep link to an admin page (in an email, bookmark, or shared link) is silently redirected without explanation. They can't tell whether the link was wrong, whether their permission lapsed, or whether the page just doesn't exist. (The earlier A18 verification said "/admin/audit correctly 403s" at the API level, which is true — but the UI layer hides it.)
- **Fix:** Render a `/403` page or surface a toast on access denial in the admin route layout. Keep the URL on the failed route so users can verify what they tried to reach.
---
## 🟡 MEDIUM (45 findings — by area)
### Multi-tenancy (5)
| ID | Title | File:line | Fix sketch |
| ------ | ------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------- |
| M-MT01 | `updateDefinition` UPDATE missing portId in WHERE | `src/lib/services/custom-fields.service.ts:136-145` | Add `and(eq(...id), eq(...portId, portId))` to UPDATE WHERE |
| M-MT02 | Notes UPDATE/DELETE missing entityId scope | `src/lib/services/notes.service.ts:846-850, 869-873, 897-901` | Add `eq(...notes.<parent>Id, entityId)` to WHERE |
| M-MT03 | Contact UPDATE/DELETE missing clientId scope | `src/lib/services/clients.service.ts:737-741, 764` | Add `eq(clientContacts.clientId, clientId)` to WHERE |
| M-MT04 | `listForYachtAggregated` ownerClientId lookup no portId | `src/lib/services/notes.service.ts:276-283` | Add `eq(clients.portId, portId)` |
| M-MT05 | Webhook reads expose row before JS portId check | `src/lib/services/webhooks.service.ts:103-108, 133-137, 170-174` | Move portId into `findFirst` WHERE |
### Schema (5)
| ID | Title | File:line | Fix sketch |
| ------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| M-SC01 | Migrations 0000-0036 not idempotent (no IF NOT EXISTS / DO blocks) | `src/lib/db/migrations/0000_narrow_longshot.sql`, `0036_polymorphic_check_constraints.sql` | Standardize IF NOT EXISTS / DO block pattern for new migrations; document 0000-0036 not re-runnable |
| M-SC02 | `companies` missing soft-delete partial index | `src/lib/db/schema/companies.ts:39-45` | `CREATE INDEX IF NOT EXISTS idx_companies_archived ON companies (port_id) WHERE archived_at IS NULL;` |
| M-SC03 | FTS GIN index missing for `interests` and `berths` | `src/lib/db/migrations/0057_search_fts_indexes.sql` | Add `CREATE INDEX CONCURRENTLY ... USING gin (...)` for both |
| M-SC04 | `audit_logs.searchText` schema/DB mismatch (Drizzle plain, DB GENERATED ALWAYS) | `src/lib/db/schema/system.ts:53-54` | Annotate as non-updateable / generated marker |
| M-SC05 | `documents.clientId` Drizzle nullable but DB `ON DELETE NO ACTION` | `src/lib/db/schema/documents.ts:72`, migration `0000_narrow_longshot.sql:814` | Migration mirroring 0059's fix for `files.client_id`: drop + re-add with `ON DELETE SET NULL` |
### Routes / Middleware (2)
| ID | Title | File:line | Fix sketch |
| ----- | ---------------------------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------- |
| M-R01 | `/portal/` blanket allowlist removes middleware backstop | `src/proxy.ts:65` | Allowlist only unauthenticated portal routes individually; add middleware portal-cookie check |
| M-R02 | No explicit OPTIONS handlers, no CORS headers (defer until cross-origin consumer exists) | All `route.ts` under `src/app/api/` | Add explicit `Access-Control-Allow-Origin: <marketing-domain>` to public routes when needed |
### Audit log (4)
| ID | Title | File:line | Fix sketch |
| ------ | ----------------------------------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| M-AU01 | FTS `search_text` covers only 4 fields; placeholder text misleads | migration `0014_black_banshee.sql:47-55` + `audit-log-list.tsx:360` | Change placeholder OR add `metadata` to GENERATED expression |
| M-AU02 | Admin audit log shows field names but no old→new diff | `audit-log-list.tsx:290-305` + `audit-log-card.tsx:84-91` | Add row-expand using `buildDiffLine` from activity-feed.tsx |
| M-AU03 | No audit log CSV export endpoint | (absent) | `GET /api/v1/admin/audit/export/csv` reusing `searchAuditLogs` |
| M-AU04 | Outcome change uses `action: 'update'` not distinct verb | `interests.service.ts:1047-1058` | Add `'outcome_change'` to `AuditAction`; use in setInterestOutcome/clearInterestOutcome; add to dropdown + severity map |
### Documents/files (1)
| ID | Title | File:line | Fix sketch |
| ----- | ---------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------ |
| M-D01 | Real-time invalidation event-name mismatch (`'file:created'` vs `'file:uploaded'`) | `src/components/documents/documents-hub.tsx:141` | Change to `'file:uploaded': [['files']]` matching other components |
### Security (1)
| ID | Title | File:line | Fix sketch |
| ----- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| M-S01 | S3 access key ID stored plaintext in `system_settings` (secret encrypted, key not) | `src/lib/storage/index.ts:136`, `src/components/admin/storage-admin-panel.tsx:80` | Apply same `encrypt()` / `*IsSet` pattern as secret key; migration to re-key existing rows |
### Email + Integrations (8)
| ID | Title | File:line | Fix sketch |
| ------ | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| M-EM01 | Portal activation/reset emails not threaded with portId — falls back to global SMTP | `src/lib/services/portal-auth.service.ts:163-164` | Pass `portId` as 6th arg to both `sendEmail` calls |
| M-EM02 | No CC/BCC in main `sendEmail` | `src/lib/email/index.ts:54-68` | Add optional `cc`/`bcc` to `SendEmailOptions` |
| M-EM03 | Bounce-to-interest linking not implemented | `src/lib/services/sales-email-config.service.ts:13` | Wire BullMQ recurring job using imapflow to scan inbox for bounce NDRs (Phase 7 §14.9 deferred) |
| M-EM04 | Notification digest uses `'crm_invite' as any` for subject resolution | `src/lib/services/notification-digest.service.ts:161-169` | Add `'notification_digest'` to `TEMPLATE_KEYS`; update digest service |
| M-IN01 | Presigned URL TTL fixed at 900s for portal downloads | `src/lib/storage/index.ts:240-254`; `src/lib/services/portal.service.ts:350` | Pass `expirySeconds: 4 * 3600` for portal links, or sign on-demand from API |
| M-IN02 | OpenAI receipt-scanner module-level instantiation, no credential health check | `src/lib/services/receipt-scanner.ts:4` | Guard `OPENAI_API_KEY` upfront; add health-check endpoint |
| M-IN03 | Receipt OCR ignores per-port config; hardcoded `gpt-4o` | `src/lib/services/receipt-scanner.ts:19` | Accept `portId`, call `getResolvedOcrConfig(portId)`, branch on provider |
| M-IN04 | Stale "pdfme" references in comments/seed | `src/lib/db/seed-data.ts:807`, `src/lib/services/document-templates.ts:573` | Update comments to reference pdf-lib AcroForm fill |
| M-IN05 | Umami `testConnection` throws instead of typed `{ ok: false }` | `src/lib/services/umami.service.ts:80-101, 292` | Return `{ ok: false, error }` to match `checkDocumensoHealth` |
### Performance + Behavioral (1)
| ID | Title | File:line | Fix sketch |
| ----- | --------------------------------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------- |
| M-P01 | Leading-wildcard `ILIKE '%term%'` in `buildListQuery` defeats indexes | `src/lib/db/query-builder.ts` | Migrate to `pg_trgm` GIN indexes on searched columns, or move to FTS via existing `search_text` GIN |
### Legacy enum drift (2)
| ID | Title | File:line | Fix sketch |
| ----- | -------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| M-L01 | Tenure type enum diverges between berths and reservations | `src/lib/db/schema/berths.ts:65` vs `src/lib/db/schema/reservations.ts:32` | Pick canonical enum union; update both schemas + comments |
| M-L02 | Reports stage rollup raw `pipelineStage` without `canonicalizeStage` | `src/lib/services/report-generators.ts:71-76, 88-106, 124-138, 176-192` | Wrap row.stage with `canonicalizeStage()` before keying maps (defensive) |
### UX/forms (12)
| ID | Title | File:line | Fix sketch |
| ----- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| M-U01 | Audit log uses inline div instead of `<EmptyState>` | `src/components/admin/audit/audit-log-list.tsx:524` | Replace with `<EmptyState title="..." />` |
| M-U02 | Two duplicate `EmptyState` components with incompatible APIs | `src/components/ui/empty-state.tsx` vs `src/components/shared/empty-state.tsx` | Migrate 3 `ui/` callers to `shared/`, delete `ui/empty-state` |
| M-U03 | Required-field marker inconsistent | `client-form.tsx:273`, `interest-form.tsx:281` | Single pattern: `<Label>Field <span aria-hidden>*</span></Label>` + `aria-required="true"` |
| M-U04 | Help-text discoverability inconsistent | `src/components/shared/filter-bar.tsx`, `client-form.tsx` | Document a rule (always-visible for constraints; tooltips only for icons) |
| M-U05 | Cancel/dismiss without unsaved-changes warning on ClientForm/YachtForm | `client-form.tsx`, `yacht-form.tsx` | Add `isDirty` guard + discard AlertDialog matching InterestForm |
| M-U06 | FileUploadZone size limit not surfaced as client-side check | `src/components/files/file-upload-zone.tsx:170` | Wire client-side size check before upload |
| M-U07 | No jump-to-page input in pagination | `src/components/shared/data-table.tsx:420` | Add small `<input type="number">` between Previous/Next |
| M-U08 | No column resize/reorder on DataTable | `src/components/shared/data-table.tsx` | Opt-in `enableColumnResizing` per table via TanStack v8 |
| M-U09 | Invoice delete uses custom overlay, not AlertDialog | `src/app/(dashboard)/[portSlug]/invoices/page.tsx:167` | Replace with `<ConfirmationDialog>` |
| M-U10 | Success toast missing on ClientForm + InterestForm create/edit | `client-form.tsx:215`, `interest-form.tsx:235` | `toast.success(isEdit ? 'Client updated' : 'Client created')` |
| M-U11 | Logo preview `<img alt="">` should describe state | `src/components/admin/shared/settings-form-card.tsx:420` | `alt="Port logo preview"` or dynamic from field label |
| M-U12 | Heading hierarchy inconsistent within tab components | `email-accounts-list.tsx:114`, `interest-contract-tab.tsx:130/251/291/364` | Audit each tab; standardize h2/h3 nesting |
| M-U13 | DialogContent missing aria-describedby on minimal dialogs | `compose-dialog.tsx:95` + ~40 others | Add `<DialogDescription className="sr-only">` or `aria-describedby={undefined}` |
| M-U14 | Mobile topbar title blank on list pages | `client-list.tsx`, `yacht-list.tsx`, `interest-list.tsx`, `berth-list.tsx` | `useMobileChrome({ title, showBackButton: false })` per list |
| M-U15 | Invoices missing from mobile navigation | `src/components/layout/mobile/more-sheet.tsx:54` | Add `{ label: 'Invoices', icon: FileText, segment: 'invoices' }` to Operations group |
---
## 🟢 LOW (8)
| ID | Title | File:line |
| ------ | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------- |
| L-AU01 | Tier map sparse; new actions default to 'info' (`password_change`, `portal_activate`, `revoke_invite`) | `src/lib/audit.ts:220-222` |
| L-AU02 | Action filter dropdown missing 12 verbs | `audit-log-list.tsx:393-415` |
| L-AU03 | Entity-type filter dropdown missing 7 entries | `audit-log-list.tsx:88-102` |
| L-AU04 | Dead code — `listAuditLogs` (ILIKE) | `src/lib/services/audit.service.ts` |
| L-D01 | `HubRootView` has 2 sections, not 3 (CLAUDE.md spec inaccuracy) | `src/components/documents/hub-root-view.tsx:50-100` |
| L-D02 | `interest.yachtId` branch in chain doc spec is unreachable (interests.clientId NOT NULL) | `src/lib/services/documents.service.ts:1225-1251` |
| L-P01 | List endpoint `limit` cap = 1000 (audit log uses 200 + cursor as the better pattern) | `src/lib/api/list-query.ts` |
| L-L01 | Reports stage-revenue rollup raw `pipelineStage` (defensive concern, no active bug) | `src/lib/services/report-generators.ts:71-192` |
---
## ✅ Areas verified clean
- Documents/files structurally solid across 22 checks (one event-name mismatch + 2 doc divergences only)
- Security XSS / SQLi / path traversal / SSRF / encryption-at-rest all clean (one S3 access key plaintext)
- Multi-tenancy entry-point port isolation correct everywhere; gaps are TOCTOU-style only
- Documenso v1+v2 routing complete and version-aware; magic-byte verification on both upload paths
- Public berths API + public health endpoint + cookie flags + CSP + CSRF all correctly configured
- Audit log core write path covers all sampled mutations; `maskSensitiveFields` covers expected PII fragments
- Better-auth session fixation, token expiry, audit-log tamper-resistance all clean
- Legacy 9-stage enum refactor — rank tables now include both legacy + modern keys (commit 9821106 closed the gap); all rendering surfaces route through `stageLabelFor` or `LEGACY_STAGE_REMAP`
- BullMQ retry/backoff configured; Redis noeviction enforced in compose; worker process bootstraps all 10 queues
- pdf-lib AcroForm fill, EOI merge tokens, `formatBerthRange` (single/contig/non-contig/cross-pontoon)
- Inline editing pattern present on all 6 detail page types; NotesList polymorphic across all 6 entity types
---
---
## Browser sweep findings (Playwright MCP) — 2026-05-15
Live exploratory testing of the dev instance (port-amador + port-nimara seeded) using Playwright MCP. All findings below were either (a) confirmation of static findings, or (b) new bugs only visible at runtime.
### New criticals + highs from browser sweep
- **🔴 C-03** — Generic `PATCH /api/v1/interests/[id]` bypasses ALL stage-transition guards (see C-03 above for full detail). The single most impactful new finding from the sweep.
- **🟠 H-14** — `DELETE /outcome` with empty body returns 500 (see H-14 above).
- **🟠 H-15** — Sales-agent → `/admin/*` silently bounces to `/dashboard`, no 403 page or toast (see H-15 above).
### New medium from browser sweep
- **M-NEW-1** — `/api/v1/me` and `/api/v1/me/ports` return 400 "Port context required" for non-super-admin callers without the `X-Port-Id` header. Super-admin works without the header. **Impact:** chicken-and-egg for the bootstrap flow that needs to know which ports a user has access to in order to choose one. Frontend likely passes the header from cookie state, but the contract is asymmetric per role. **Fix:** treat absent `X-Port-Id` on `/me/ports` as "list all ports the user has access to, regardless of context".
- **M-NEW-2** — Activity feed entity-type label rendered without separator: "Test Person 1interest", "Audit_loglist", "Settingrecom" — entity name + type concatenated. **File:** `src/components/dashboard/activity-feed.tsx` (the line that renders the entity label + type tag). **Fix:** add a separator (space, dot, or pipe) between name and type.
### Verifications confirmed clean in browser
| Check | Result |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| C-02 `/setup` deadlock | ✅ confirmed: navigation redirects to `/login` (no `?redirect=` param even); `bootstrap/status` returns `needsBootstrap: false` on populated DB; loop fires when fresh |
| H-02 `?redirect=` ignored | ✅ confirmed: signed in with `?redirect=%2Fport-amador%2Fclients%2Fsome-fake-id` → landed at `/port-amador/dashboard` |
| H-04 `Retry-After` missing | ✅ confirmed: 429 fired on 2nd bad sign-in attempt, headers `x-ratelimit-limit/remaining/reset` present, NO `Retry-After` |
| R-004 cross-port URL | ✅ clean: `/port-amador/clients/<port-nimara-uuid>` shows friendly "Client not found... different port" page |
| MT-02 cross-port PATCH | ✅ clean: `PATCH /api/v1/interests/<port-nimara-id>` with `X-Port-Id: port-amador` → 404 "We couldn't find that interest" |
| Viewer permissions | ✅ clean: read 200, write same-port 403 "Insufficient permissions", write cross-port 403 "No access to this port" |
| F27 same-stage no-op | ✅ clean via `/stage` endpoint (returns 204); ❌ broken via generic PATCH (200 + body) — see C-03 |
| Forbidden transition | ✅ clean via `/stage` (400 with override-required-reason copy); ❌ bypassed via generic PATCH (see C-03) |
| Override no-reason | ✅ clean via `/stage` (400 "Override requires a reason (min 5 chars)") |
| Override short-reason | ✅ clean via `/stage` (same 400) |
| AU-11 permission_denied filter | ✅ activity feed shows no raw `permission_denied` rows |
| A2 legacy enum in feed | ✅ no raw `deposit_10pct` / `eoi_sent` / `contract_signed` in activity feed text |
| R-008 mooring URL canonicalization | ✅ `A1`=200, `a1`=400, `A%201`=400, `A-1`=400 |
| B-10 webhook empty/malformed body | ✅ both return 200 `{ok:false}` (graceful) |
| Tag CRUD (AD-014) | ✅ 201 create + 204 delete |
| Settings update (AD-008) | ✅ 200 with persisted body |
| Interest detail render | ✅ EOI badge, milestone "EOI sent May 14, 2026", no raw legacy values, no errors |
| Interest reopen with reopenStage | ✅ 200 ok |
| Public berths shape | ✅ 117 berths, statuses split Sold=11 / Under Offer=49 / Available=57 |
### Out of scope for this sweep (not exercised)
- Live Documenso integration (requires real-API project — `pnpm exec playwright test --project=realapi`)
- IMAP bounce probe round-trip (requires SMTP+IMAP credentials)
- C-01 berth-INNER-JOIN bug — would require hard-deleting a berth in the live DB (destructive); static analysis already conclusive
- Browser-side cross-browser testing (BR-\* — Safari, Firefox, Edge)
- Drag-and-drop kanban interactions
- Visual regression baselines (`--project=visual` snapshots)

View File

@@ -0,0 +1,266 @@
# Audit Fix Wave — 2026-05-18
Progress report against `docs/AUDIT-FINDINGS-2026-05-15.md` (74 findings)
and the still-open Wave-11 items in `docs/AUDIT-FOLLOWUPS.md`. Each
finding was re-verified against the current code before being touched —
the previous session's 70 uncommitted files mostly added new behaviour
and rarely overlapped with the audit issues, so almost everything was
still applicable.
`pnpm exec vitest run` → 1374/1374 pass. `pnpm exec tsc --noEmit` clean.
---
## 🔴 CRITICAL — 3 / 3 done
- **C-01** interest-berths INNER JOIN on hard-deleted berths — three
helpers switched to LEFT JOIN; `listBerthsForInterest` return type
loosened so an orphaned junction row still renders. Berth hard-delete
is already redirected to soft-archive, so the audit's "service-layer
guard preventing hard-delete" requirement is implicitly satisfied via
`archiveBerth`'s active-interest check.
- **C-02** `/setup` missing from `PUBLIC_PATHS` — added.
- **C-03** generic `PATCH /api/v1/interests/[id]` bypassing stage guards
`updateInterestSchema` now omits `pipelineStage`, forcing every
caller through the `/stage` endpoint with the override-permission +
override-reason guard chain.
## 🟠 HIGH — 14 / 15 fixed, 1 not-applicable
- **H-01** FK `ON DELETE` actions made explicit across interests /
documents / reservations / reminders / invoices schemas; migration
`0070_h01_fk_on_delete.sql` drops + re-adds each constraint under
the same name (idempotent against re-run).
- **H-02** login page reads `?redirect=` param with same-origin guard
(`startsWith('/')` and `!startsWith('//')`).
- **H-03** CRM-invite token moved to URL fragment (`#token=…`); the
set-password page reads from fragment via `useSyncExternalStore` with
`?token=` back-compat for outstanding links.
- **H-04** `Retry-After` header added to the sign-in-by-identifier 429
response (RFC 6585 §4).
- **H-05** `toggleAccount` now writes an audit row (action 'update',
entityType 'email_account', oldValue/newValue around isActive).
- **H-06** `upsertSetting` masks any value whose key ends with
`_encrypted` to `[redacted]` before writing to `audit_logs.new_value`
— keeps the ciphertext out of the historical audit trail.
- **H-07** `archiveClient`'s cascade fires per-interest audit rows
(action 'archive', metadata.cascadeSource = 'client_archive') so the
audit FTS surfaces a search for a specific archived interest.
- **H-08** `createSalesTransporter` now applies the shared
`SMTP_TIMEOUTS` constant — sales send-outs can no longer stall the
BullMQ pool on a hung relay.
- **H-09** AppShell refactored so `<main>{children}</main>` lives at an
invariant tree path across mobile/desktop chrome — React preserves
in-progress form drafts when the viewport flips across the breakpoint.
- **H-10** portal documents page replaces Unicode glyph status icons
with Lucide CheckCircle2/XCircle/Circle + aria-labels.
- **H-12** three list components (interests/companies/yachts) swap
`alert(…)` for `toast.warning(…)` matching client-list.
- **H-13** 5 icon-only buttons gain `aria-label` (notification bell,
file-grid actions menu, form-template edit/delete, email-account
remove, member-actions menu).
- **H-14** `parseBody` now treats empty request bodies as `{}` so
routes whose schemas have all-optional fields don't crash on an empty
DELETE / PATCH payload.
- **H-15** admin layout renders an explicit 403 panel ("Access denied —
this area is for super-administrators only") instead of a silent
redirect to `/dashboard`, with a "Back to dashboard" CTA. URL stays
on the failed route.
**Not applicable:**
- **H-11** mobile-search-overlay Vaul → Sheet conversion. The audit's
premise ("full-screen, not a bottom sheet") is inaccurate — the
overlay has `top: 12px` (visible backdrop strip), drag handle,
swipe-to-dismiss, and explicit visualViewport sizing for iOS keyboard
behaviour. CLAUDE.md's "Sheet vs Drawer doctrine" explicitly allows
Vaul for "mobile-only bottom-sheet UX" which is this case.
## 🟡 MEDIUM — 28 / 48 fixed, 5 deferred, the rest covered by larger work
### Done
- **M-MT01-05** multi-tenancy defense-in-depth: `port_id` / parent-id
filters added to UPDATE/DELETE WHEREs across custom-fields, notes
(all 6 entity types × update + delete), client-contacts, yacht
ownerClient lookup, and webhooks reads.
- **M-AU01** audit log placeholder copy fixed.
- **M-AU02** already done in previous session (Details column + Sheet).
- **M-AU04** outcome change now uses distinct audit verbs
`outcome_set` / `outcome_cleared`; AuditAction type extended.
- **M-D01** documents-hub realtime event-name typo (`file:created`
`file:uploaded`) fixed.
- **M-EM01** portal-auth activation + reset emails now pass `portId`
to `sendEmail` so per-port SMTP is used.
- **M-EM02** `sendEmail` accepts `cc` / `bcc` params; redirect mode
drops both (consistent with the dev safety net).
- **M-EM04** `notification_digest` added to `TEMPLATE_KEYS` +
`TEMPLATE_CATALOG`; the digest service drops the `'crm_invite' as any`
cast.
- **M-IN01** portal presigned download URLs now use a 4-hour TTL so
client links from yesterday's emails still work.
- **M-IN02** OpenAI client lazy-instantiated; missing key surfaces a
clear error instead of crashing at module load.
- **M-IN04** stale pdfme comments in seed-data + document-templates
updated to pdf-lib AcroForm.
- **M-IN05** `umami.testConnection` returns `{ ok: true|false, … }`
tagged union instead of throwing.
- **M-L02** `report-generators.ts` canonicalises stage values via
`canonicalizeStage()` across pipeline / revenue / forecast rollups
so legacy 9-stage rows fold into the modern 7-stage buckets.
- **M-NEW-2** activity feed entity-name/type concatenation — explicit
middle-dot separator so "Test Person 1" + "interest" no longer renders
as one word.
- **M-R01** portal allowlist narrowed from blanket `/portal/` to the
three unauthenticated entry-points + portal_session backstop in the
middleware redirects to `/portal/login` when the cookie is missing.
- **M-SC02** companies gets `idx_companies_archived` partial index
matching the clients/yachts/interests pattern.
- **M-SC04** `auditLogs.searchText` documented as GENERATED ALWAYS /
DB-managed.
- **M-SC05** documents.clientId `ON DELETE SET NULL` covered by the
H-01 migration.
- **M-U01** audit-log empty state uses `<EmptyState>`.
- **M-U09** invoice delete dialog migrated from hand-rolled overlay to
`<AlertDialog>` (focus trap, ESC-to-close, a11y semantics).
- **M-U10** ClientForm + InterestForm fire `toast.success(...)` on
create/edit.
- **M-U11** logo preview `<img>` carries a descriptive alt.
- **M-U14** mobile topbar title surfaced on clients / interests /
yachts / berths list pages via `useMobileChrome`.
- **M-U15** Invoices added to the mobile More-sheet Operations group.
- **M-L01** `reservations.tenureType` comment unified with
`berths.tenureType` (canonical union).
- **M-S01** `storage_s3_access_key_encrypted` admin field added; the
encrypt-plaintext-credentials script handles the data migration.
### Deferred (need user input or scope-larger-than-an-audit-fix)
- **M-AU03** — audit log CSV export endpoint. New feature surface.
- **M-EM03** — bounce-to-interest IMAP linking (Phase 7 §14.9).
- **M-IN03** — receipt-scanner per-port OCR config (every call site
needs `portId` threading).
- **M-NEW-1** — `/me/ports` asymmetric port-context header semantics.
- **M-P01** — leading-wildcard ILIKE → pg_trgm GIN migration.
- **M-SC03** — FTS GIN on interests + berths (search.service.ts
doesn't use to_tsvector for these — feature work).
### Lower-priority M-U items left untouched (cosmetic / process)
`M-U02` (dedup EmptyState components), `M-U03` (required-field marker
standardisation), `M-U04` (help-text discoverability rule), `M-U05`
(unsaved-changes warning on ClientForm/YachtForm), `M-U06`
(FileUploadZone client-side size check), `M-U07` (pagination
jump-to-page), `M-U08` (column resize/reorder), `M-U12` (heading
hierarchy across tab components), `M-U13` (DialogContent aria-describedby
across ~40 sites). All polish-grade — drop into a focused UX session.
## 🟢 LOW — 6 / 8 fixed, 2 deferred / not-applicable
- **L-AU01** severity defaults extended (password_change → warning,
portal_password_reset → warning, etc).
- **L-AU02** action-filter dropdown gains 13 missing verbs
(password*change, portal*\_, gdpr\__, rule*evaluated, outcome*_,
branding.\_).
- **L-AU03** entity-type dropdown gains 7 missing entries (yacht,
company, reservation, email_account, portal_session, portal_user,
file).
- **L-AU04** dead `listAuditLogs` (ILIKE) stubbed out — callers all
use the FTS-backed `searchAuditLogs` now.
- **L-D02** CLAUDE.md "Owner-wins chain" tightened — `interest.yachtId`
tail branch removed from the spec (structurally unreachable since
`interests.clientId` is NOT NULL).
- **L-P01** list endpoint limit cap — DEFER per audit (cursor pagination
is on the routes where it matters; the 1000-row cap is fine at
current data sizes).
- **L-D01** HubRootView spec inaccuracy — verified accurate; the
CLAUDE.md "three render modes" line refers to render _modes_, not
sections within HubRootView. Audit finding is a misread.
- **L-L01** reports defensive concern — covered by M-L02's
canonicalize sweep.
---
## Bonus: document-detail polish (#67 partial)
Three of the six deliverables in MANUAL-TESTING-BACKLOG §4.10b shipped
in this wave:
- **State-aware action button per signer** — `invitedAt === null`
primary "Send invitation" CTA (paper-plane); else "Send reminder"
(bell). Hits the existing `/send-invitation` and `/remind` routes.
- **Watcher Add UI** — replaces the user-id stub display with the
display name from `/api/v1/admin/users/picker`, plus a "+ Add"
select that lets admins pick any user in the port that isn't already
watching. Existing delete affordance untouched.
- **`cleanSignerName` cleanup** — shared from `SigningProgress` and
applied to the doc-detail card so EMAIL_REDIRECT_TO `(was: …)` /
`(placeholder)` suffixes stop leaking through.
The remaining three deliverables (full SigningProgress visual parity,
linked-entity name resolution, activity-panel `document_events` polish
with per-event icons + tooltips) need API changes to return entity
names + a meaningful event-type icon map. Deferred so it can ship in
one focused PR.
## Smoke validations against the running dev server
- **C-02** — `/setup` is reachable (middleware lets it through; page
itself redirects to `/login` when `needsBootstrap=false`). No infinite
redirect loop.
- **M-R01** — `/portal/documents` without a portal_session cookie now
redirects to `/portal/login?redirect=/portal/documents`.
- **H-04** — sign-in 429 response carries `Retry-After: 900` plus the
full `X-RateLimit-*` triplet.
## What still needs your input
Items genuinely blocked on a decision you haven't made yet. Most exist
in the 2026-05-15 manual-testing-backlog already; surfacing here in one
place for resolution.
1. **PDF template editor / builder (MANUAL-TESTING-BACKLOG §9.Z)**
ship Phase 1 alone (in-app fill of admin-uploaded PDFs with
merge-token mapping, ~12 weeks) or wait until Phases 1+2 can land
together (also Documenso template push, ~34 weeks)?
2. **Document detail refactor (#67 in §4.10b)** — multi-deliverable
redesign. Are we shipping it as one PR or splitting?
3. **Reminders data model (§0.1 + §3.2)** — Path A (extend lightweight
columns on `interests` — note/timeOfDay/priority/recurrence) or
Path B (push richer reminders into the existing `reminders` table)?
4. **Supplemental info form (§0.2)** — CRM-hosted route or
marketing-site-hosted? Need a green light to spend ~15 minutes
tracing the route end-to-end.
5. **EOI-scoped data overrides (§4.2)** — does the override apply only
to this specific EOI document, or to ALL future EOIs on this
interest? Reopening the drawer: show original override or fall back
to canonical? Are the overrides reusable for reservation + contract
or EOI-only?
6. **`/me/ports` port-context asymmetry (M-NEW-1)** — should the
endpoint treat absent `X-Port-Id` as "list all ports the user has
access to"? Currently super-admins work without it; everyone else
gets a 400.
7. **Bounce-to-interest IMAP linking (M-EM03 / Phase 7 §14.9)**
ready to scope or stays deferred?
8. **Receipt-scanner per-port OCR config (M-IN03)** — every call site
needs `portId` threading. Confirm we should do this now vs. when a
second-port OCR config materialises?
9. **CSV export of audit logs (M-AU03)** — net-new endpoint. Ship?
10. **Documenso phases 27 (BACKLOG §A)** — still back-burnered or
ready to pick up?
---
## Migrations to apply
`pnpm tsx scripts/db-migrate.ts` (or your usual migration runner) will
pick up the single new migration `0070_h01_fk_on_delete.sql`. It's
idempotent — each ALTER drops the constraint by name first, so re-runs
are safe.
## Files touched this wave
`118 files changed, 5181 insertions(+), 1301 deletions(-)` — but note
that count rolls in the previous session's 70 uncommitted files. Run
`git diff --stat HEAD docs/AUDIT-FINDINGS-2026-05-15.md` to see only
the audit-fix diff.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
# L-001 Legacy Stage Enum Master Grep — agent #12 (re-dispatch slice 1)
**Headline:** The 9→7 stage refactor is correctly implemented; zero bugs found across 25 files with legacy-stage-name hits.
**Counts:** 0 critical · 0 high · 0 medium
---
## Verdict
The two `stageRank` Records (`clients.service.ts:276-283`, `berth-recommender.service.ts:195-210`) intentionally include both legacy AND modern keys mapping to the same final ranks — yesterday's commit `9821106` purged the gap. The rules engine (`berth-rules-engine.ts:15-42`) and document services use legacy _trigger event_ names (`eoi_sent`/`eoi_signed`/`contract_signed`) rather than stage names — both old and new events fire correctly because they're labels for webhook/doc events, not pipeline stages.
## Legitimate / neutral hit categories
- **Historical lookup tables (designed for dual-stage support):** `clients.service.ts:276-283` `stageRank`, `berth-recommender.service.ts:195-210` `STAGE_ORDER` — both have legacy + modern keys.
- **Refactor mapping definitions:** `constants.ts:59-65` `LEGACY_STAGE_REMAP`; `dedup/migration-transform.ts:206-212` legacy-to-legacy map for NocoDB import.
- **Rules engine + service layer (legacy-aware design):** `berth-rules-engine.ts:15-42` (trigger event labels), `external-signing.service.ts:37-41`, `documents.service.ts:786/909/1503/1544/1574` (`evaluateRule('eoi_sent'|'eoi_signed'|'contract_signed', ...)`), `external-eoi.service.ts:138-151` (intentional legacy-aware advance branch).
- **Schema metadata:** `db/schema/interests.ts:61-65` field names (`dateEoiSent`, `dateEoiSigned`, `dateContractSent`, `dateContractSigned`) — historical schema column names.
- **UI display:** `email/templates/notification-digest.tsx:29` `eoi_signed: 'EOI signed'` label for historical data.
- **Comments only:** `alert-rules.ts:83`, `interests.service.ts:938/980/1095`, `berths.service.ts:175`, `db/schema/operations.ts:98`.
**No silent-failure lookup tables. No rank-0 fallthrough patterns. No raw legacy enum keys leaking to the UI without remap.**

View File

@@ -0,0 +1,28 @@
# L-002-011 Legacy Stage Rendering Surfaces — done in main thread (sub-agent context-thrashed)
**Headline:** Mostly clean. One LOW finding: report-generators stage rollup keys are raw enum without `LEGACY_STAGE_REMAP`/`canonicalizeStage` — defensive-coding gap if any active row drifts back to a legacy stage value (migration 0062 normalized, so this is theoretical).
**Counts:** 0 critical · 0 high · 0 medium · 1 low (defensive)
---
## 🟢 LOW L-008: Reports stage-revenue rollup uses raw `interests.pipelineStage` without `canonicalizeStage`
- **File:** `src/lib/services/report-generators.ts:71-76, 88-106, 124-138, 176-192`
- **What:** `stageRevenueMap[row.stage] = ...` and `pipelineWeights[row.stage]` use the raw enum value from the SQL `groupBy(interests.pipelineStage)`. No `canonicalizeStage()` wrap.
- **Why it matters:** Migration 0062 normalized historical data to modern values, so today active rows should all be in the 7-stage set and bucketing is correct. But if any leakage occurs (NocoDB re-import, partial migration on a future port, manual `psql` write), legacy values would be siloed into their own bucket and `pipelineWeights[legacy_value]` returns `undefined` → that bucket contributes 0 to the forecast. Silent.
- **Suggested fix:** Wrap row.stage with `canonicalizeStage(row.stage)` from `src/lib/utils/legacy-stage.ts` before keying into `stageRevenueMap` / `pipelineWeights`.
---
## ✅ Passing checks
- **L-002 audit log diff** — `audit-log-list.tsx` / `audit-log-card.tsx` don't render stage values at all (just field-name keys per agent #4's AU-08 finding). No raw-enum render path exists.
- **L-003 activity feed** — `src/components/dashboard/activity-feed.tsx:14,57` imports and uses `LEGACY_STAGE_REMAP` for the stage_change diff line.
- **L-004 email templates** — `src/lib/email/templates/notification-digest.tsx:24` `TYPE_LABELS` includes `eoi_signed` as a _notification type_ label (the doc-status event), not a pipeline stage. Legitimate.
- **L-005 Documenso payload** — `src/lib/services/documenso-payload.ts` and `src/lib/templates/merge-fields.ts` have zero `pipelineStage` / `pipeline_stage` references. EOI payload doesn't surface stage.
- **L-006 public berths status filter** — already verified clean by agent #7 (IN-17). `src/lib/services/public-berths.ts:90-97` `derivePublicStatus` only branches on `sold` / `under_offer` / else `available`. No legacy enum acceptance.
- **L-007 outbound webhook** — `webhook-dispatch.ts` is a passthrough; payload built at `interests.service.ts:919-934` (`emitToRoom` + `dispatchWebhookEvent`). New stage value is current modern (write-time enforcement). `oldStage` could be legacy if the row was historical, but that's the actual historical truth — informational.
- **L-009 search FTS on stages** — `interests` has no FTS GIN index at all (per agent #2's SC-04 finding); migration 0057 covers only clients/yachts/residential_clients. Stage searchability via FTS is moot. (SC-04 fix should add interests FTS — when added, the GENERATED expression should use `stageLabelFor` for the stage column.)
- **L-010 notifications** — `next-in-line-notify.service.ts:63-65` falls back to `i.pipelineStage.replace(/_/g, ' ')` when `STAGE_LABELS` lookup misses. STAGE_LABELS is the modern-only map; legacy values would render as "eoi signed" etc. Recommended switch to `stageLabelFor()` for legacy resilience, but: only fires for active interests where stage is modern, so functionally clean today.
- **L-011 CSV importers** — Only import services are `berth-import.ts` and `document-import.ts`; neither references `pipelineStage`. No CSV stage-import path exists, so no risk of legacy value re-entry through this vector.

View File

@@ -0,0 +1,26 @@
# L-013-020 Adjacent Enum Drift — agent #14 (re-dispatch slice 3)
**Headline:** Single medium finding (tenure type enum diverges between berths and reservations); all other enums consistent.
**Counts:** 0 critical · 0 high · 1 medium
---
## 🟡 MEDIUM L-018: Tenure type enum diverges between berths and reservations
- **Files:** `src/lib/db/schema/berths.ts:65` vs `src/lib/db/schema/reservations.ts:32`
- **What:** `berths.tenureType` documents `'permanent' | 'fixed_term' | 'fee_simple' | 'strata_lot'` (4 values). `reservations.tenureType` documents `'permanent' | 'fixed_term' | 'seasonal'` (3 values). Same column name, divergent allowed values.
- **Why it matters:** No writes indicate actual cross-table conflict yet, but the schema-comment mismatch is a trap — a future feature copying tenure between the two tables would silently accept invalid values for the receiving side.
- **Suggested fix:** Pick a single canonical enum (likely `'permanent' | 'fixed_term' | 'fee_simple' | 'strata_lot' | 'seasonal'` as the union) and update both schemas + comments. Or rename one column to disambiguate intent.
---
## ✅ Passing checks
- L-013 berth status `available/under_offer/sold` — only writes are in `berth-rules-engine.ts` respecting the 3-value set
- L-014 statusOverrideMode — `manual/automated/null`; migration 0066 normalizes legacy `'auto'` → NULL; only writers in rules-engine + reconcile-queue both respect three-state
- L-015 outcome — `won/lost_other_marina/lost_unqualified/lost_no_response/cancelled`; only writes in `interest-outcome.service.ts`; no legacy `'completed'` outcome anywhere
- L-016 lead category — `general_interest/specific_qualified/hot_lead`; no out-of-set writes
- L-017 lead source — `website/manual/referral/broker`; no out-of-set writes
- L-019 doc status (`eoiDocStatus`, `reservationDocStatus`, `contractDocStatus`) — `pending/sent/signed/declined/voided`; mark-externally-signed only writes `'signed'`; Documenso webhook routes all status updates through services consistent with the set
- L-020 reservation/contract status — `pending/active/ended/cancelled`; only writes in `reservation-state-machine.ts`

View File

@@ -0,0 +1,105 @@
# Multi-tenancy + Schema Audit (MT-01-11, SC-01-15) — agent #2
**Headline:** API port isolation structurally sound, but 5 write paths do port check in JS without re-asserting portId in WHERE (TOCTOU gaps). Schema has several FKs that are `ON DELETE NO ACTION` in DB while nullable Drizzle declarations imply SET NULL — most critically `documents.clientId` and all `berthReservations` FKs.
**Counts:** 0 critical · 1 high · 8 medium · 0 low.
---
## 🟠 HIGH SC-02: Multiple significant FKs missing `onDelete` — remain `ON DELETE NO ACTION`
- **Files:**
- `src/lib/db/schema/interests.ts:29,32``interests.portId`, `interests.clientId`
- `src/lib/db/schema/documents.ts:72,85,86``documents.clientId`, `documents.fileId`, `documents.signedFileId`
- `src/lib/db/schema/reservations.ts:18,24,25,27,28,33` — all 6 `berthReservations` FKs
- `src/lib/db/schema/operations.ts:25``reminders.clientId`
- `src/lib/db/schema/financial.ts:120``invoices.pdfFileId`
- `src/lib/db/schema/documents.ts:176``documentEvents.signerId`
- **What:** `.references(...)` without `{ onDelete: ... }` emits `ON DELETE NO ACTION`. Confirmed in migration 0000:841 (`interests_client_id_clients_id_fk ... ON DELETE no action`).
- **Why it matters:** Hard-deleting a parent (client, berth, yacht, file) blocks at FK level. `client-hard-delete.service.ts` manually nullifies but `berthReservations` (4 NO ACTION FKs) is not in the chain. Future maintenance trap.
- **Suggested fix:** Add `{ onDelete: 'set null' }` for nullable FKs that should tolerate parent deletion; explicit `{ onDelete: 'restrict' }` for those that intentionally block (e.g., `interests.clientId` — design intent is archive-first).
## 🟡 MEDIUM MT-01: `updateDefinition` UPDATE uses only `id` in WHERE, not `and(id, portId)`
- **File:** `src/lib/services/custom-fields.service.ts:136-145`
- **What:** Guard read uses `and(eq(id, fieldId), eq(portId, portId))`, but UPDATE fires with only `eq(customFieldDefinitions.id, fieldId)`.
- **Why it matters:** TOCTOU race between read check and write.
- **Suggested fix:** Mirror `updateTag`/`deleteTag`: add `and(eq(...id), eq(...portId, portId))` to the UPDATE WHERE.
## 🟡 MEDIUM MT-01: `notes.service.ts` UPDATE/DELETE missing entityId scope
- **File:** `src/lib/services/notes.service.ts:846-850, 869-873, 897-901`
- **What:** All note `update()` branches verify ownership via prior SELECT, then UPDATE/DELETE on `eq(...notes.id, noteId)` alone (no `eq(yachtNotes.yachtId, entityId)` etc).
- **Why it matters:** TOCTOU gap; risk currently low (UUIDs, no cross-entity discovery surface).
- **Suggested fix:** Add `eq(...notes.<parent>Id, entityId)` to each UPDATE/DELETE WHERE.
## 🟡 MEDIUM MT-01: `clients.service.ts::updateContact` / `removeContact` UPDATE/DELETE use only `contactId`
- **File:** `src/lib/services/clients.service.ts:737-741, 764`
- **What:** PortId verified in JS only; mutation has no portId guard.
- **Suggested fix:** Add `eq(clientContacts.clientId, clientId)` to the UPDATE/DELETE WHERE.
## 🟡 MEDIUM MT-04: `notes.service.ts::listForYachtAggregated` ownerClientId lookup has no portId guard
- **File:** `src/lib/services/notes.service.ts:276-283`
- **What:** Owner client SELECT uses only `eq(clients.id, ownerClientId)`. Yacht is verified in port but cross-port ownerClientId would still surface.
- **Suggested fix:** Add `eq(clients.portId, portId)`.
## 🟡 MEDIUM MT-06: `webhooks.service.ts::getWebhook` / `updateWebhook` / `deleteWebhook` fetch by `id` only, portId checked in JS
- **File:** `src/lib/services/webhooks.service.ts:103-108, 133-137, 170-174`
- **What:** Fetches full webhook row (incl. encrypted secret) before JS port check.
- **Why it matters:** Defense-in-depth gap — secret briefly in app memory before authz check.
- **Suggested fix:** Move portId into `findFirst` WHERE.
## 🟡 MEDIUM SC-01: Migration 0000 (and 0001-0023) uses bare CREATE/ALTER without IF NOT EXISTS
- **File:** `src/lib/db/migrations/0000_narrow_longshot.sql`
- **What:** No `IF NOT EXISTS` guards on CREATE TABLE/INDEX. Migration 0036 also bare `ALTER TABLE ... ADD CONSTRAINT`. Later migrations (0042, 0050, 0051, 0052, 0057, 0062, 0065) use IF NOT EXISTS / DO blocks correctly.
- **Why it matters:** Drizzle tracker prevents double-runs in normal flow, but disaster-recovery partial replay would fail.
- **Suggested fix:** Document that 0000-0036 are not re-runnable without dropping schema first; standardize on IF NOT EXISTS / DO block pattern for all new migrations.
## 🟡 MEDIUM SC-03: `companies` table missing soft-delete partial index for `archivedAt`
- **File:** `src/lib/db/schema/companies.ts:39-45`
- **What:** Other entities (clients, interests, yachts, berths, residentialClients, residentialInterests) have `idx_*_archived ... WHERE archived_at IS NULL` partial indexes (migration 0046). Companies missing.
- **Suggested fix:** `CREATE INDEX IF NOT EXISTS idx_companies_archived ON companies (port_id) WHERE archived_at IS NULL;`
## 🟡 MEDIUM SC-04: FTS GIN indexes missing for `interests` and `berths`
- **File:** `src/lib/db/migrations/0057_search_fts_indexes.sql`
- **What:** Migration 0057 creates GIN indexes for clients/yachts/residentialClients but explicitly notes companies uses ILIKE. Interests and berths also lack GIN indexes.
- **Suggested fix:** `CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_interests_fulltext ON interests USING gin (...)` and similar for berths.
## 🟡 MEDIUM SC-08: `audit_logs.searchText` declared as plain column in Drizzle but is GENERATED ALWAYS in DB
- **File:** `src/lib/db/schema/system.ts:53-54`
- **What:** Drizzle `tsvector('search_text')` without generated annotation. If any service auto-includes this column in an UPDATE, it errors on the generated column. `audit_logs` is insert-only so likely not hit in practice, but schema-DB mismatch.
- **Suggested fix:** Annotate as non-updateable or add a generated-column marker.
## 🟡 MEDIUM SC-09: `documents.clientId` Drizzle nullable but DB is `ON DELETE NO ACTION`
- **File:** `src/lib/db/schema/documents.ts:72`, migration `0000_narrow_longshot.sql:814`
- **What:** Drizzle says nullable (intent: SET NULL on parent delete); DB constraint is NO ACTION (blocks delete). Migration 0042 fixed `documents.interestId/yachtId/companyId` but missed `clientId`.
- **Why it matters:** Client hard-delete fails unless service explicitly nulls `documents.clientId` first.
- **Suggested fix:** Migration to mirror what 0059 did for `files.client_id` — drop and re-add FK with `ON DELETE SET NULL`.
---
## ✅ Passing checks
- MT-01 clean: clients/interests/invoices/documents/files/tags/companies/berth-reservations GET/PATCH/DELETE all use `and(id, portId)` SQL filter; notes-service `verifyParentBelongsToPort` correct
- MT-04 document-folders.service.ts clean (`listTree`, `createFolder`, `renameFolder`, `moveFolder`, `deleteFolderSoftRescue` all apply `eq(documentFolders.portId, portId)`)
- MT-05 audit.service.ts `listAuditLogs` filters by portId first
- MT-07 settings.service.ts clean (port-specific then global fallback by design)
- MT-08 tags.service.ts clean
- MT-09 custom-fields read/create/delete clean (only update missed; covered above)
- MT-11 seed.ts idempotent (`SELECT count(*) FROM companies WHERE port_id = $1` early-exit)
- SC-02 interestBerths.berthId/interestId, files.clientId/yachtId/companyId, documents.interestId/yachtId/companyId/reservationId all have explicit onDelete
- SC-05 doc folder sibling-name unique, entity-folder partial unique, isPrimary partial unique all present
- SC-06 idx_brochures_default partial unique present
- SC-07 chk_system_folder_shape present (tightened by migration 0052)
- SC-12 Migration 0062 normalizes legacy stages, 0066 normalizes statusOverrideMode='auto' → NULL
- SC-13 Currency code stored as text + app-level validation (consistent)
- SC-14 Address components stored as ISO 3166-2/alpha-2 text columns (consistent)
- SC-15 Polymorphic owner reads use service helpers (eoi-context.ts, interests.service.ts, berth-reservations.service.ts); raw column reads only in JOIN conditions

View File

@@ -0,0 +1,68 @@
# Routes/Middleware/Auth Audit (R-016-029, S-09-13, S-17-19) — agent #3
**Headline:** 1 critical (`/setup` unreachable on fresh DB — middleware redirect loop), 3 high (post-login `?redirect=` ignored; CRM invite token in query string leaks to access logs; missing `Retry-After` on sign-in 429), 2 medium (broad portal allowlist, no OPTIONS handlers), 13 clean.
**Counts:** 1 critical · 3 high · 2 medium · 0 low · 13 passing
---
## 🔴 CRITICAL R-021: `/setup` missing from `PUBLIC_PATHS` — bootstrap unreachable on fresh DB
- **File:** `src/proxy.ts:51-73`
- **What:** `PUBLIC_PATHS` includes `/api/v1/bootstrap/` but NOT `/setup`. Comment at lines 60-62 says login + setup pages call bootstrap status, but `/setup` itself is not exempt from the session guard. Unauthenticated user → `/setup` → middleware redirects to `/login?redirect=/setup`. Login useEffect fetches bootstrap status, calls `router.replace('/setup')` → middleware again → infinite redirect loop.
- **Why it matters:** Fresh deployment (no super admin) is functionally deadlocked. First operator cannot reach setup without already having a session (impossible on fresh DB).
- **Suggested fix:** Add `'/setup'` to `PUBLIC_PATHS`. `POST /api/v1/bootstrap/super-admin` already self-protects with `hasAnySuperAdmin()`.
## 🟠 HIGH R-017/018: CRM post-login redirect ignores `?redirect=` — deep links silently dropped
- **File:** `src/app/(auth)/login/page.tsx:79`
- **What:** Middleware redirects unauthenticated → `/login?redirect=<path>`. Login page never reads `useSearchParams()`; always `router.push('/dashboard')`.
- **Why it matters:** Email/bookmark/shared deep links into specific clients/interests silently dump to dashboard after login.
- **Suggested fix:** Read `searchParams.get('redirect')`, validate same-origin (starts with `/`, not `//`), use as push target if valid.
## 🟠 HIGH R-023: CRM invite token in query string leaks to access logs
- **File:** `src/lib/services/crm-invite.service.ts:71,233`
- **What:** `${env.APP_URL}/set-password?token=${raw}` — raw 32-byte token in query param. Set-password page reads via `useSearchParams()`. Portal flow was migrated to `#token=` fragment in 2026-05-14 specifically to keep tokens out of logs/Referer; CRM invite path missed the migration.
- **Why it matters:** Every nginx/Caddy access log line for `GET /set-password?token=<raw>` persists token to disk. Forwarded to SIEM/S3/monitoring → token visible to anyone with log access. Token grants account creation.
- **Suggested fix:** Change `createCrmInvite` + `resendCrmInvite` to emit `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`. Update `set-password/page.tsx` to use the fragment-reading pattern from `PasswordSetForm` (`readTokenFromUrl()`) with `?token=` back-compat for outstanding tokens.
## 🟠 HIGH R-029: `sign-in-by-identifier` 429 missing `Retry-After`
- **File:** `src/app/api/auth/sign-in-by-identifier/route.ts:47-51`
- **What:** Builds 429 response with `headers: rateLimitHeaders(rl)` which only emits `X-RateLimit-Limit/Remaining/Reset` (`src/lib/rate-limit.ts:79-85`). `enforcePublicRateLimit` adds `Retry-After`; this route uses `checkRateLimit` directly and skips it.
- **Why it matters:** RFC 6585 §4 requires `Retry-After` on 429. Automated clients can't back off correctly. Inconsistent with other public endpoints.
- **Suggested fix:** Add `'Retry-After': Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000)).toString()`.
## 🟡 MEDIUM R-016: `/portal/` blanket allowlist removes middleware as backstop
- **File:** `src/proxy.ts:65`
- **What:** `'/portal/'` in `PUBLIC_PATHS` — every `/portal/*` is exempt from middleware session check. Per-page `getPortalSession()` is the only gate.
- **Why it matters:** Defense-in-depth gap. Per-page checks all in place today; but a future portal page added without `getPortalSession()` has no middleware backstop. Fragile vs CRM's primary middleware gate.
- **Suggested fix:** Allowlist only the unauthenticated portal routes individually (`/portal/login`, `/portal/activate`, `/portal/reset-password`, `/portal/forgot-password`). Add middleware portal-cookie check.
## 🟡 MEDIUM R-028: No explicit `OPTIONS` handlers, no CORS headers
- **File:** All `route.ts` files under `src/app/api/`
- **What:** No `OPTIONS` exports. No `Access-Control-Allow-*` headers anywhere. Next.js will 405 on unhandled OPTIONS.
- **Why it matters:** Acceptable for same-origin CRM. Becomes an issue if marketing-site browser JS calls `/api/public/berths` cross-origin.
- **Suggested fix:** Defer until cross-origin consumer exists. When marketing site lives, add explicit `Access-Control-Allow-Origin: <marketing-domain>` to public routes (not wildcard).
---
## ✅ Passing checks
- R-016 allow-list anchor — `startsWith('/api/public/')` correctly rejects `'/api/publicX-evil'` (no regex anchor concern)
- S-09 open redirect on next/redirect — CRM login ignores param (no risk because unused); portal `safeNextPath()` (portal/login/page.tsx:20-27) rejects non-`/portal/` paths and `//`-protocol-relative
- S-10 CSRF — defense-in-depth: `proxy.ts originAllowed()` (lines 104-122) rejects state-changing `/api/v1/**` where Origin/Referer don't match in prod; better-auth has its own origin check for `/api/auth/**`; dev bypass intentional
- S-11 cookie flags — CRM: `httpOnly`, `secure` (prod), `sameSite: 'strict'` (`src/lib/auth/index.ts:107-110`); Portal: `httpOnly`, `secure` (prod), `sameSite: 'lax'` (`src/app/api/portal/auth/sign-in/route.ts:43-45`)
- S-12 CSP — per-request nonce-based CSP via `proxy.ts:buildCspWithNonce()` for page routes in prod (`'nonce-<n>' 'strict-dynamic'`); fallback CSP in `next.config.ts:55-66`; `frame-ancestors: 'none'` + `X-Frame-Options: DENY`; HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy all present
- S-13 CORS — no `Access-Control-Allow-Origin: *` anywhere (correct for same-origin CRM)
- R-019/020 portal `client_portal_enabled` gate — `src/app/(portal)/layout.tsx:22` calls `isPortalDisabledGlobally()`; per-page `getPortalSession()` additionally guards
- R-022 reset-password tokens — Portal: single-use `consumeToken` setting `usedAt`, 30min TTL, SHA-256 hashed in DB. Better-auth CRM: 1h TTL, `revokeSessionsOnPasswordReset: true`
- R-023 portal half — `portal/activate/page.tsx` uses `PasswordSetForm` with `useSyncExternalStore + readTokenFromUrl()` reading `window.location.hash` client-side; SSR-safe via `null` server snapshot
- R-025 public berths cache headers `s-maxage=300, stale-while-revalidate=60` confirmed in both list + single endpoints
- R-026/027 public health: anonymous `{status,timestamp}` only never 503; `X-Intake-Secret` `timingSafeEqual` (lines 57-64); authenticated runs DB+Redis dep checks in parallel, 503 on either failure
- S-17 session fixation — better-auth creates fresh session row on every sign-in; portal sign-in always issues new JWT via `createPortalToken`
- S-18 token expiry/refresh — CRM 24h absolute, 6h sliding refresh window (`src/lib/auth/index.ts:99-103`); Portal JWT 24h checked against `passwordChangedAt` watermark per request
- S-19 audit log tamper-resistance — `audit_logs` has no `updated_at`; no `UPDATE` calls in app code (only INSERT/SELECT and time-based retention DELETE bounded by `AUDIT_LOGS_RETENTION_DAYS`)

View File

@@ -0,0 +1,92 @@
# Audit Log Audit (AU-01-14) — agent #4
**Headline:** Core write path solid; major mutations all audit; mask helper covers expected PII; FTS indexed; AU-11 fix complete. Two HIGH issues: encrypted credential ciphertext bypasses masking (key is `"value"`) and `toggleAccount` mutation is silent.
**Counts:** 0 critical · 2 high · 4 medium · 4 low
---
## 🟠 HIGH AU-01a: `toggleAccount` writes no audit row
- **File:** `src/lib/services/email-accounts.service.ts:86-116`
- **What:** Sets `isActive` on email account with no `createAuditLog` call. `connectAccount` (line 70) and `disconnectAccount` (line 139) do, but enable/disable in between is silent.
- **Why it matters:** Silently disabling an email account suppresses bounce-detection or reroutes replies — compliance gap on a security-relevant config change.
- **Suggested fix:** Add `void createAuditLog({ action: 'update', entityType: 'email_account', entityId: accountId, newValue: { isActive: data.isActive }, ... })` inside `toggleAccount`.
## 🟠 HIGH AU-02: Encrypted credential ciphertext stored in audit log without masking
- **File:** `src/lib/services/settings.service.ts:66-76` + `src/lib/services/sales-email-config.service.ts:281-299`
- **What:** `updateSalesEmailConfig` calls `upsertSetting('sales_smtp_pass_encrypted', <ciphertext>, portId, meta)`. `upsertSetting` records `newValue: { value: '<ciphertext>' }`. `maskSensitiveFields` checks JSON keys against `SENSITIVE_KEY_FRAGMENTS`; the wrapping key `"value"` isn't in the list. Ciphertext lands verbatim in `audit_logs.new_value`.
- **Why it matters:** Audit log is readable by all admins with `admin.view_audit_log`. DB read access exfils ciphertext; if `EMAIL_CREDENTIAL_KEY` is ever compromised, the historical audit log becomes a credential store. Industry standard: store only `credentialUpdated: true` for credential changes.
- **Suggested fix:** In `upsertSetting`, detect when key ends with `_encrypted` (or accept `redactValue?: boolean` flag) and record `newValue: { value: '[redacted]' }`.
## 🟡 MEDIUM AU-03: FTS `search_text` covers only 4 fields; placeholder text misleads
- **File:** `src/lib/db/migrations/0014_black_banshee.sql:47-55` + `src/components/admin/audit/audit-log-list.tsx:360`
- **What:** `search_text` GENERATED ALWAYS = `action || entity_type || entity_id || user_id`. Search input placeholder reads "entity id, action, vendor…" — implies you can search inside `metadata`/`new_value`. Searching "vendor" returns zero rows silently.
- **Suggested fix:** Change placeholder to "action name, entity id, user id…" OR add `metadata` to GENERATED expression with `jsonb_to_tsvector` (larger index).
## 🟡 MEDIUM AU-08: Admin audit log shows field names but no old→new diff
- **File:** `src/components/admin/audit/audit-log-list.tsx:290-305` + `src/components/admin/audit/audit-log-card.tsx:84-91`
- **What:** "Changes" column renders `Object.keys(newValue).slice(0,3).join(', ')` — no old→new diff, no row-expand. Dashboard `activity-feed.tsx` has working `buildDiffLine()` with 3 diff shapes, unused here.
- **Why it matters:** Compliance audits can't confirm before/after state from UI alone; admins must dig into raw JSON.
- **Suggested fix:** Add row-expand or detail sheet using `buildDiffLine` from activity-feed.tsx.
## 🟠 AU-10: Cascade-archived interests produce no individual audit rows
- **File:** `src/lib/services/clients.service.ts:578-618`
- **What:** `archiveClient` batch-archives open interests, writes ONE `entityType: 'client'` row with `newValue: { cascadedInterestIds: [...] }`. No per-interest rows. `search_text` doesn't include `new_value`, so searching for an interest ID returns nothing.
- **Why it matters:** Auditor querying for a specific archived interest sees no archive event; must know to look at parent client row.
- **Suggested fix:** Loop over `archivedInterestIds` and emit per-interest `createAuditLog({ action: 'archive', entityType: 'interest', entityId, metadata: { cascadeSource: 'client_archive', clientId } })` (fire-and-forget).
## 🟡 MEDIUM AU-12: No audit log CSV export endpoint
- **File:** (absent — no `src/app/api/v1/admin/audit/export/route.ts`)
- **What:** No download button, no API. Expenses domain has reference impl at `src/app/api/v1/expenses/export/csv/route.ts`.
- **Why it matters:** GDPR / marina licensing audits often require exports.
- **Suggested fix:** `GET /api/v1/admin/audit/export/csv` reusing `searchAuditLogs` + filter params.
## 🟡 MEDIUM AU-13: Outcome change uses `action: 'update'`, not distinct verb
- **File:** `src/lib/services/interests.service.ts:1047-1058`
- **What:** `setInterestOutcome`/`clearInterestOutcome` log `action: 'update'` with `metadata.type: 'outcome_set'/'outcome_cleared'`. No `outcome_change` in `AuditAction` or filter dropdown. `metadata.type` not in `search_text` — FTS can't isolate.
- **Suggested fix:** Add `'outcome_change'` to `AuditAction` union; use in both functions; add to dropdown; add to `DEFAULT_SEVERITY_BY_ACTION` as `'warning'`.
## 🟢 LOW AU-14: Tier map sparse; new actions default to 'info'
- **File:** `src/lib/audit.ts:220-222`
- **What:** Only 2 entries (`permission_denied: 'warning'`, `hard_delete: 'critical'`). `password_change`, `portal_activate`, `revoke_invite`, `branding.logo.uploaded`, `rule_evaluated` all default to `'info'`. Severity≥warning filter misses security-relevant events.
- **Suggested fix:** Add `password_change/portal_activate/revoke_invite: 'warning'`. `reconcile_manual` is in `metadata.type` — add `severity: 'warning'` at the call site in `berths.service.ts`.
## 🟢 LOW AU-14b: Action filter dropdown missing 12 verbs
- **File:** `src/components/admin/audit/audit-log-list.tsx:393-415`
- **What:** Dropdown has 20 actions; missing `branding.logo.*`, `rule_evaluated`, `revoke/resend_invite`, `request/send_gdpr_export`, `password_change`, `portal_invite/activate/password_reset_request/password_reset`. Free-text partially compensates.
- **Suggested fix:** Add missing action verbs.
## 🟢 LOW AU-14c: Entity-type filter missing several domains
- **File:** `src/components/admin/audit/audit-log-list.tsx:88-102`
- **What:** Missing `document_folder`, `file`, `company`, `yacht`, `email_account`, `audit_log`, `backup_job`. Free-text on `entity_type` (in tsvector) works; dropdown is convenience.
- **Suggested fix:** Add missing entity types.
## 🟢 LOW AU-14d: Dead code — `listAuditLogs` (ILIKE) in `audit.service.ts`
- **File:** `src/lib/services/audit.service.ts`
- **What:** `listAuditLogs` exported but zero import sites. Admin route uses `searchAuditLogs` exclusively. ILIKE search is dead.
- **Why it matters:** Future dev might wire it up bypassing GIN index → seq scans at scale.
- **Suggested fix:** Delete `audit.service.ts` or mark `@deprecated`.
---
## ✅ Passing
- AU-01 (10 sampled mutating endpoints all audit: clients/interests/companies/berths/documents/folders/tags/roles/settings/files create + update + archive)
- AU-02 password/token fragment masking covers `password`, `passwordHash`, `token`, `secret`, `api_key`, `apikey`, `auth`, `cookie`, `credentials` recursively up to depth 4. `email-accounts.service.ts` correctly logs only `metadata: { emailAddress, provider }`; `credentialsEnc` stripped before any JSON serialization.
- AU-04 action filter wired (exact `eq()` filter)
- AU-05 entity-type filter wired (same path)
- AU-06 user filter wired (UUID exact match)
- AU-07 date-range filter (ISO strings → Date → gte/lte; UI validates inversion)
- AU-09 reconcile_manual tag in metadata at `berths.service.ts:473`
- AU-11 permission_denied feed filter at `src/components/dashboard/activity-feed.tsx:185-189` (`i.action !== 'permission_denied'`); admin page correctly displays them with `'bg-red-800'` badge

View File

@@ -0,0 +1,52 @@
# Documents/Files Audit (D-01-22) — agent #5
**Headline:** Structurally solid across all 22 checks. One medium real-time event mismatch + 2 low documentation divergences.
**Counts:** 0 critical · 0 high · 1 medium · 2 low · 19 passing
---
## 🟡 MEDIUM D-01/02/03: Real-time invalidation event name mismatch after upload
- **File:** `src/components/documents/documents-hub.tsx:141`
- **What:** Hub subscribes to `'file:created': [['files']]`, but emitter (`files.ts:128`) and socket-events type def (`events.ts:264`) use `'file:uploaded'`.
- **Why it matters:** After remote upload (other session, webhook auto-deposit), hub Files sections don't auto-refresh. Local `FolderDropZone` upload bypasses this via direct `queryClient.invalidateQueries`, but remote uploads invisible until reload.
- **Suggested fix:** Change line 141 to `'file:uploaded': [['files']]` to match `client-files-tab.tsx:32`, `company-files-tab.tsx:32`, `interest-documents-tab.tsx:62`.
## 🟢 LOW D-13: HubRootView has 2 sections, not 3
- **File:** `src/components/documents/hub-root-view.tsx:50-100`
- **What:** Spec says 3 cards; component renders 2 ("Signing in progress" + "Recent files"). Doc-only.
- **Suggested fix:** Update CLAUDE.md to "2 sections."
## 🟢 LOW D-16: `interest.yachtId` branch in chain doc spec doesn't exist in code
- **File:** `src/lib/services/documents.service.ts:1225-1251`
- **What:** Spec is `doc.clientId ?? .companyId ?? .yachtId ?? interest.clientId ?? interest.yachtId`. Code stops at `interest.clientId` because `interests.clientId` is NOT NULL — so the yachtId fallback is unreachable. Comment line 1239 explains.
- **Suggested fix:** Update CLAUDE.md to drop the unreachable trailing branch, or annotate with `// unreachable: interests.clientId is NOT NULL`.
---
## ✅ Passing checks
- D-01 A16 fix verified — `formStr()` returns `undefined` (not `null`) for absent FormData fields; root upload omits `folderId` correctly
- D-02 entity-folder drag-drop carries `folderId`+`entityType`+`entityId`+typed FK
- D-03 file picker dialog passes `folderId` (null for root) correctly
- D-04 PDF inline preview via `PdfViewer` lazy-loaded
- D-05 image inline preview + lightbox via `<img>` for jpeg/png/gif/webp
- D-06 Word/Excel: `FileGrid` gates "Preview" with `PREVIEWABLE_MIMES.has(...)` so only "Download" shows; `FilePreviewDialog` never opened
- D-07 download endpoint wraps with `withPermission('files', 'view', ...)`; `getFileById` enforces port via `file.portId !== portId`
- D-08 `deleteFolderSoftRescue` (`src/lib/services/document-folders.service.ts:294-337`) wrapped in `db.transaction()`, re-parents folders + documents + files explicitly (no CASCADE)
- D-09 `syncEntityFolderName` called in updateClient (clients.service.ts:554), updateCompany (companies.service.ts:187), updateYacht (yachts.service.ts:167)
- D-10 `moveFolder` cycle prevention: rejects self at line 213, `pg_advisory_xact_lock` per port (line 233), walks ancestor chain with `seen` set, checks `cursor === folderId` at each step
- D-11 `assertNotSystemManaged` called in renameFolder (line 172), moveFolder (line 217), deleteFolderSoftRescue (line 299)
- D-12 `listFilesAggregatedByEntity` walks Client↔Companies (via companyMemberships INNER JOIN companies on portId)↔Yachts; cap 20 + total
- D-14 EntityFolderView uses `useAggregatedWorkflows` (filters to INFLIGHT_STATUSES `['draft','sent','partially_signed']`); files with `signedFromDocumentId` show "View signing details"
- D-15 `GET /api/v1/documents/[id]/signing-details` returns `{ data: { workflow, signers, events } }`; `getDocumentById` enforces portId
- D-16 idempotency: outer gate `doc.status === 'completed' && doc.signedFileId` returns; inner `SELECT ... FOR UPDATE` re-check inside transaction
- D-17 Defense-in-depth port at every join: `companies` INNER JOIN with `portId` (line 451), `clients` INNER JOIN with `portId` (line 497), `yachts/files` WHERE portId everywhere, LEFT JOIN `documents` with `or(eq(documents.portId, portId), isNull(documents.id))` (line 588-590). companyMemberships has no portId column but is port-scoped via INNER JOIN to companies/clients
- D-18 `?folder=<uuid>` URL state — three-state (absent → undefined hub root, `=root` → null, `=<uuid>` → uuid); `decodeFolderParam`/`encodeFolderParam` symmetric; deep folder works
- D-19 `ensureEntityFolder` race-safety: fast-path re-SELECT before insert; two distinct catch branches for `uniq_document_folders_entity` (re-SELECT winner) and `uniq_document_folders_sibling_name` (increment suffix)
- D-20 magic-byte: `bufferMatchesMime` in files.ts:58 covers 8 MIME types in-server; presign-PUT only used by berth-pdf/brochure (both stream first 5 bytes + `isPdfMagic()`)
- D-21 filename HTML-escape (`document-sends.service.ts:415-422`)
- D-22 `streamAttachmentOrLink` size-threshold + 24h presigned URL fallback; `fallbackToLinkReason: 'size_above_threshold'` audited

View File

@@ -0,0 +1,30 @@
# Security Audit (S-01-08, S-21-30) — agent #6
**Headline:** 1 medium finding (S-23 plaintext S3 access key ID), 19 clean.
## 🟡 MEDIUM S-23: S3 access key ID stored plaintext in `system_settings`
- **File:** `src/lib/storage/index.ts:136`, `src/components/admin/storage-admin-panel.tsx:80`
- **What:** S3 secret key (`storage_s3_secret_key_encrypted`) is AES-encrypted, but the access key ID (`storage_s3_access_key`) is stored/read as plaintext in `system_settings`.
- **Why it matters:** Asymmetric encryption — DB exfil exposes the IAM key ID, narrowing the attack surface for credential stuffing or confirming which IAM principal to target. The access key ID is also surfaced in admin settings API responses.
- **Suggested fix:** Apply same `encrypt()` / `*IsSet` pattern as the secret key. Migration to re-key existing rows. Update `resolveConfig` to call `decryptIfPresent`.
## ✅ Passing checks
- S-01 XSS via client.fullName (React text node)
- S-02 XSS via tag.name (React child, sanitized style object)
- S-03 XSS via note.content (plain text, no markdown rendering — `whitespace-pre-wrap` is CSS only)
- S-04 XSS via email body markdown (`src/lib/utils/markdown-email.ts` escape-then-allowlist + DOMPurify second layer in `send-document-dialog.tsx`)
- S-05 SQL injection via search query (Drizzle parameterized; `sql.raw` only on hardcoded constants in `admin/storage/route.ts:30` and `storage/migrate.ts:149`)
- S-06 Path traversal in folder name (DB-only, never used as filesystem path)
- S-07 Path traversal in file name / storage key (`validateStorageKey` in `src/lib/storage/filesystem.ts:49-69` rejects `..`/absolute/empty/non-allowlist chars; `resolveKey` does `path.resolve` prefix check)
- S-08 SSRF via webhook target URL (two-layer: `isLocalOrPrivateHost` in `src/lib/validators/webhooks.ts` blocks RFC1918+loopback+link-local+CGNAT+cloud metadata; `resolveAndCheckHost` in `src/lib/queue/workers/webhooks.ts` re-resolves DNS at dispatch — DNS rebinding-resistant)
- S-21 SMTP credential AES-256-GCM with random IV (`src/lib/utils/encryption.ts`)
- S-22 IMAP credential same path as SMTP
- S-24 Privilege escalation blocked: `updateUser` in `src/lib/services/users.service.ts:294-318` does caller-superset check; permission-overrides at `src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:203-210` enforce per-leaf + block self-target at line 160; role definition mutations require `requireSuperAdmin` not just `manage_users`
- S-25 Direct ID enumeration immune (`crypto.randomUUID` everywhere)
- S-26 Audit log read-back of own permission denials — clean (admin-only `view_audit_log`)
- S-27 Magic-byte verification verified
- S-28 Filename HTML-escape in download links (`src/lib/services/document-sends.service.ts:415-420`)
- S-29 Bounce-monitor email subject parsing — clean (no IMAP bounce worker exists yet; `email-threads.service.ts` uses parameterized `ilike` for subject matching)
- S-30 `EMAIL_REDIRECT_TO` enforced at boot via Zod `superRefine` in `src/lib/env.ts:110-117` — production with the env set causes `process.exit(1)`. Webhook worker also short-circuits to `dead_letter` when set.

View File

@@ -0,0 +1,112 @@
# Email + Integrations Audit (EM-01-19, IN-01-29) — agent #7
**Headline:** Broadly well-implemented. Primary issue: missing SMTP timeouts on sales transporter (HIGH — risks worker starvation). Plus 8 medium gaps in portal-email portId scoping, digest catalog key, receipt scanner config, presign TTL.
**Counts:** 0 critical · 1 high · 8 medium · 0 low · 30 passing
---
## 🟠 HIGH EM-XX: Sales transporter missing SMTP timeouts
- **File:** `src/lib/services/sales-email-config.service.ts:331-337`
- **What:** `createSalesTransporter` builds nodemailer transport with no timeout options. Compare `createTransporter` in `src/lib/email/index.ts:26-37` which uses `SMTP_TIMEOUTS = { connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 30_000 }`.
- **Why it matters:** Hung SMTP relay can stall send-out indefinitely. Email queue concurrency=5, maxAttempts=5. Without socket timeouts, one stuck TCP connection holds a worker for nodemailer's 2-min default × 5 retries = 10min/job × 5 slots = whole pool blocked for 10min by a single flaky send.
- **Suggested fix:** Apply `SMTP_TIMEOUTS` constant to `nodemailer.createTransport` in `createSalesTransporter`.
## 🟡 MEDIUM EM-05a: Per-port branding not threaded into portal activation/reset emails
- **File:** `src/lib/services/portal-auth.service.ts:163-164`
- **What:** `issueActivationToken` and `issuePasswordReset` call `sendEmail(email, subject, html, undefined, text)` without the 6th `portId` argument. Without `portId`, `createTransporter()` uses global env SMTP. Branding is threaded into HTML via `getBrandingShell(portId)` but the SMTP transport falls back to global.
- **Why it matters:** Multi-port deploys: portal auth emails for port B go through global env SMTP, defeating per-port SMTP override.
- **Suggested fix:** Pass `portId` as 6th arg to `sendEmail` in both `issueActivationToken` and the reset send.
## 🟡 MEDIUM EM-07: CC/BCC not supported in main `sendEmail`
- **File:** `src/lib/email/index.ts:54-68`
- **What:** `SendEmailOptions` lacks `cc`/`bcc`. Sales send-out path also lacks them.
- **Suggested fix:** Add optional `cc`/`bcc` to `SendEmailOptions`. Low urgency.
## 🟡 MEDIUM EM-11: Bounce-to-interest linking not implemented
- **File:** `src/lib/services/sales-email-config.service.ts:13` (header comment)
- **What:** `getSalesImapConfig` exposes IMAP creds but no BullMQ worker reads IMAP. Failed deliveries don't update `document_sends.failedAt`.
- **Suggested fix:** Wire BullMQ recurring job using imapflow to scan inbox for bounce NDRs, match against `document_sends.messageId`. Phase 7 §14.9 deferred.
## 🟡 MEDIUM EM-16: Notification digest uses wrong catalog key for subject resolution
- **File:** `src/lib/services/notification-digest.service.ts:161-169`
- **What:** Calls `resolveSubject` with `key: 'crm_invite' as any` because `'notification_digest'` is not in `TEMPLATE_KEYS` in `src/lib/email/template-catalog.ts`.
- **Why it matters:** Admin-set CRM invite subject override bleeds into digest emails.
- **Suggested fix:** Add `'notification_digest'` to `TEMPLATE_KEYS`; update digest service to use it.
## 🟡 MEDIUM IN-11: Presigned URL TTL fixed at 900s for portal downloads
- **File:** `src/lib/storage/index.ts:240-254` (`presignDownloadUrl`); `src/lib/services/portal.service.ts:350` (`getDocumentDownloadUrl`)
- **What:** `presignDownloadUrl` defaults `expirySeconds=900` (15min). Sales send-out correctly overrides to 24h. `getDocumentDownloadUrl` calls without expiry → 15min default.
- **Why it matters:** Portal users opening their doc list and clicking after >15min get 403.
- **Suggested fix:** Pass `expirySeconds: 4 * 3600` for portal download links, or sign on-demand from API.
## 🟡 MEDIUM IN-21: OpenAI receipt-scanner module-level instantiation, no credential health check
- **File:** `src/lib/services/receipt-scanner.ts:4`
- **What:** `const openai = new OpenAI();` at module level reads `OPENAI_API_KEY` at import. SDK throws on first call when unset; catch returns zero-confidence empty result. No admin-visible health check.
- **Suggested fix:** Guard `OPENAI_API_KEY` upfront with clear error. Add a health-check endpoint similar to `checkDocumensoHealth`.
## 🟡 MEDIUM IN-23: Receipt OCR ignores per-port config; hardcoded `gpt-4o`
- **File:** `src/lib/services/receipt-scanner.ts:19`
- **What:** `model: 'gpt-4o'` hardcoded; per-port `getResolvedOcrConfig` not consulted; `aiEnabled` flag does nothing. Module-level singleton OpenAI client.
- **Suggested fix:** Accept `portId`, call `getResolvedOcrConfig(portId)`, check `aiEnabled`, use `config.apiKey` and `config.model`. Branch on provider for OpenAI vs Anthropic.
## 🟡 MEDIUM IN-24: Stale "pdfme" references in comments/seed
- **File:** `src/lib/db/seed-data.ts:807`, `src/lib/services/document-templates.ts:573`
- **What:** Comments still reference pdfme even though the rendering path was removed; `tiptap-validation.ts:8` confirms pdfme retired. `document-templates.ts:648-652` throws ValidationError for non-EOI templates.
- **Suggested fix:** Update comments to reference pdf-lib AcroForm fill; remove "pdfme" from seed-data description.
## 🟡 MEDIUM IN-29: Umami `testConnection` throws instead of returning typed result
- **File:** `src/lib/services/umami.service.ts:80-101, 292`
- **What:** `loadUmamiConfig` returns null gracefully; all public APIs return null when unconfigured. But `testConnection` throws `CodedError('UMAMI_NOT_CONFIGURED')` instead of returning `{ ok: false, error }` like `checkDocumensoHealth`.
- **Suggested fix:** Return `{ ok: false, error: string }` to match Documenso convention.
---
## ✅ Passing checks
- EM-01 per-port SMTP override (`getPortEmailConfig` in `port-config.ts:136`)
- EM-02/03 default send-froms cascade (explicit `from``cfg.fromAddress` → env.SMTP_FROM → `noreply@${SMTP_HOST}`)
- EM-04 EMAIL_REDIRECT_TO subject prefix `[redirected from <orig>]`; documenso-client also applies `applyRecipientRedirect`/`applyPayloadRedirect`; env.ts:110 prod boot guard
- EM-05 branded shell (`renderShell` in `src/lib/email/shell.ts:37`)
- EM-06 reply-to override applied
- EM-08 send rate limit 50/user/hour Redis sliding-window keyed `${portId}:${userId}`
- EM-09 `streamAttachmentOrLink` threshold + filename HTML-escape pre-SMTP
- EM-10 IMAP probe script + `getSalesImapConfig` AES-256-GCM decrypted
- EM-12 `document_sends` audit row in success + failure branches
- EM-13 portal activation token: 32-byte token, hash stored in `portalAuthTokens`, `#token=...` fragment to stay out of logs
- EM-14/15 reset/invite emails wired
- EM-17 EOI sent via Documenso (not as nodemailer attachment)
- EM-18/19 `renderEmailBody` escape-first + `isSafeHref` (https/mailto only) + `MERGE_VALUE_ESCAPE_MAP` neutralizes markdown chars
- IN-01 v1 template-generate path (`generateDocumentFromTemplate`)
- IN-02 v2 envelope/create multipart (FormData with `payload` JSON + `files` Blob)
- IN-03 v2 distribute returns `recipients[].signingUrl` in one round-trip
- IN-04 redistribute version-aware (v2 caveat: `recipientIds` may not target single recipient — API behavior risk, not code bug)
- IN-05 downloadSignedPdf version-aware
- IN-06 voidDocument version-aware (idempotent on 404)
- IN-07 placeFields v2 bulk `field/create-many` percent coords + `fieldMeta`; v1 one POST per field with pixel coords
- IN-08 `normalizeDocument` `id ?? documentId` for both docs and recipients (handles legacy `r.Recipient` capital-R)
- IN-09 NocoDB `pg_advisory_xact_lock` + skip rows where `updated_at > last_imported_at`
- IN-10 S3Backend with SSE AES256, all calls wrapped in `withTimeout(30_000)`, never imports MinIO directly
- IN-12 filesystem MULTI_NODE_DEPLOYMENT guard (boot-time throw)
- IN-13 BullMQ exponential backoff: email/docs 5×1s, webhooks 8×30s
- IN-14 Redis noeviction in both compose files
- IN-15 `src/worker.ts` imports all 10 workers + SIGTERM/SIGINT graceful shutdown
- IN-16 public berths cache `s-maxage=300, stale-while-revalidate=60`
- IN-17 status filter Sold > Under Offer (status OR has active is_specific_interest with isNull(end_date)+outcome) > Available
- IN-18 mooring regex `^[A-Z]+\d+$` checked pre-DB; returns 400 for malformed
- IN-19/20 dual-mode health endpoint with `timingSafeEqual`
- IN-22 berth-pdf-parser tier-2 is `unpdf` (not Tesseract — prior comment correction); 30s timeout
- IN-25 `fillEoiFormFields` flatten + metadata; missing fields warn rather than throw
- IN-26 VALID_MERGE_TOKENS allow-list including `{{eoi.berthRange}}`
- IN-27 `formatBerthRange` handles all cases (single/contig/non-contig/cross-pontoon/dedup)
- IN-28 portal magic-link rate-limited 10/h/IP via `enforcePublicRateLimit(req, 'portalToken')`

View File

@@ -0,0 +1,55 @@
# Performance + Behavioral Audit (P-05/09/13/14, B-01-22) — agent #8
**Headline:** 1 critical (B-01 INNER JOIN drops hard-deleted berth links), 1 high (B-16 AppShell remount destroys form state), 1 medium (P-09a leading-wildcard ILIKE), 17 clean.
**Counts:** 1 critical · 1 high · 1 medium · 1 low · 17 passing
---
## 🔴 CRITICAL B-01: Hard-deleted berth causes silent data loss across interest surfaces
- **File:** `src/lib/services/interest-berths.service.ts:55` (`getPrimaryBerth`), `:87` (`getPrimaryBerthsForInterests`), `:140` (`listBerthsForInterest`)
- **What:** All three helpers use `INNER JOIN berths ON berths.id = interestBerths.berthId`. When a berth is hard-deleted, the INNER JOIN silently drops the link.
- **Why it matters:** Interest detail page shows `berthId: null`, `berthMooringNumber: null`. Kanban card shows no berth chip. EOI generation produces empty field. `archiveInterest` path that calls `getPrimaryBerth` before evaluating berth rule returns null and **skips the rule entirely**.
- **Suggested fix:** Change all three `INNER JOIN` to `LEFT JOIN berths`. Callers already handle `null` mooringNumber. Add service-layer guard preventing hard-delete of berths with `interest_berths` rows (require unlink or soft-archive first).
## 🟠 HIGH B-16: AppShell remounts children on breakpoint crossing, destroying form state
- **File:** `src/components/layout/app-shell.tsx:58-70`
- **What:** When `isMobile` flips on resize, the shell switches between `<MobileLayout>{children}</MobileLayout>` and the desktop `<div>...{children}...</div>`. React unmounts and remounts `children`, destroying any in-progress `useState` form drafts including `InlineEditableField`.
- **Why it matters:** A user editing a client name on desktop who resizes past the mobile breakpoint loses unsaved draft text. Multi-step modal forms (reconcile wizard) open during resize get unmounted.
- **Suggested fix:** Wrap shared content with stable `key`, or use CSS-only responsive layout so the children subtree never remounts. Alternatively `key={isMobile ? 'mobile' : 'desktop'}` only on the shell wrappers with `children` stable via Portal.
## 🟡 MEDIUM P-09a: Leading-wildcard ILIKE in `buildListQuery` prevents index use
- **File:** `src/lib/db/query-builder.ts`
- **What:** List search uses `ILIKE '%term%'` with leading wildcard, defeating B-tree and trigram-prefix indexes.
- **Why it matters:** Sequential scan on high-cardinality text columns; degrades at scale.
- **Suggested fix:** Migrate to `pg_trgm` GIN indexes on the searched columns, or move to FTS via existing `search_text` GIN where one exists.
## 🟢 LOW P-14: List endpoint `limit` allows up to 1000 rows
- **File:** `src/lib/api/list-query.ts`
- **What:** Generic list cap = 1000. Audit log is bounded to 200 with cursor pagination (better pattern).
- **Why it matters:** A 1000-row response with relations can blow the 256 KB budget.
- **Suggested fix:** Lower default cap to ~100; require explicit cursor pagination beyond.
---
## ✅ Passing checks
- P-05 No N+1 — all secondary fetches batched via `inArray`
- P-13 Audit FTS uses `to_tsvector('simple')` + GIN index + `plainto_tsquery('simple')` consistently (`src/lib/services/audit-search.service.ts`, migration `0014_black_banshee.sql`)
- B-02 Sara Laurent contract-without-yachtId renders correctly (overview tab guards yacht section; stage-gate only fires on `changeInterestStage`)
- B-03 `activeInterestsWhere` (`src/lib/services/active-interest.ts`) used in listInterestsForBoard, getInterestStageCounts, listBerths reconcile, recommender CTE
- B-04 / B-05 `formatBerthRange` correct: single (`A1`), contiguous (`A1-A3`), non-contiguous (`A1, A3`), cross-pontoon (`A1-A2, B5-B7`), dedup, non-canonical pass-through
- B-07 Tier B fires only when `activeInterestCount===0 && lostCount>0`; `lost_count` aggregates `LIKE 'lost%' OR cancelled`; heat scoring gated by `tier === 'B'`; fall-through policy enforces cooldown/never_auto_recommend
- B-08 `withPermission` (`src/lib/api/helpers.ts:328-340`) writes `permission_denied` audit row before 403 (fire-and-forget `void`)
- B-09 Same-stage no-op `if (existing.pipelineStage === data.pipelineStage) return STAGE_NOOP;` early-returns before DB/audit/socket (`src/lib/services/interests.service.ts:847-849`)
- B-10 Documenso webhook handles empty body / malformed JSON via try/catch returning `{ ok: false }` 200 + warning log (`src/app/api/webhooks/documenso/route.ts:176-182, 202`)
- B-11 `status_override_mode` transitions (null/manual/automated) all have audit coverage; reconcile clears to null, rules engine writes 'automated', admin UI writes 'manual'
- B-13 Catch-up wizard `pipelineStage === 'contract'` sends `outcome: 'won'` (`src/components/berths/catch-up-wizard.tsx:120`); reconcile route validates `z.enum(['won']).optional()`
- B-17 Bulk-add berths wizard step state persists in `BulkAddBerthsWizard`'s `useState`; no remount between steps
- B-18 NotesList handles 6 entity types (clients/interests/yachts/companies/residential_clients/residential_interests); `companyNotes.updatedAt` substituted via `createdAt` per CLAUDE.md
- B-19 `InlineEditableField` present on client/yacht/company/interest/residential-client/residential-interest/berth tabs (11 files)
- B-22 `markExternallySigned` (`src/lib/services/external-signing.service.ts:68-72`) updates `{ docStatus: 'signed', updatedAt: now }`. Note: catalog said "documentId=null, signedAt=now" but interests table has no such columns — the service is correct relative to schema.

View File

@@ -0,0 +1,159 @@
# UX/Forms/Tables Audit (U-001-100, code-side) — agent #9
**Headline:** Generally consistent (Sheet, AlertDialog, EmptyState, requestId surfacing all good across most surfaces). 4 HIGH gaps: native `alert()` for bulk-action failures, icon-only buttons missing aria-label, unicode glyphs in portal, Vaul Drawer in mobile search overlay. Plus 14 MEDIUM gaps in form discipline + a11y + mobile nav.
**Counts:** 0 critical · 4 high · 14 medium · 0 low
---
## 🟠 HIGH
### U-059: Unicode glyphs as status icons in portal documents page
- **File:** `src/app/(portal)/portal/documents/page.tsx:85-89`
- **What:** Signer status rendered as raw Unicode (`'✓'` signed, `'✗'` declined, `'○'` pending) inside colour-coded `<span>` with no `aria-label`.
- **Why it matters:** A11y — screen readers read literal Unicode names. Per project memory: decorative unicode glyphs are explicitly flagged. `inline-stage-picker.tsx:443` comment confirms the pattern ("was ⚑ unicode glyph — replaced with a Lucide").
- **Suggested fix:** Replace with `<CheckCircle2>` / `<XCircle>` / `<Circle>` Lucide icons + `aria-label`.
### U-066: Vaul Drawer used for mobile search overlay (violates Sheet doctrine)
- **File:** `src/components/search/mobile-search-overlay.tsx:6`
- **What:** `import { Drawer as VaulDrawer } from 'vaul'` — search overlay is a full-screen overlay, not a bottom sheet, but uses Vaul Drawer. CLAUDE.md says Vaul is reserved for mobile-bottom-sheet only (currently `MoreSheet` only).
- **Suggested fix:** Convert to `<Sheet side="bottom">` or `<Dialog>` fullscreen. Visualviewport handling (lines 50-89) becomes redundant once Radix dialog primitive backs it.
### U-076: Native `alert()` for bulk-action failure feedback in 3 lists
- **Files:** `src/components/interests/interest-list.tsx:146`, `src/components/companies/company-list.tsx:73`, `src/components/yachts/yacht-list.tsx:66`
- **What:** Partial-failure feedback via `alert(...)`. `client-list.tsx:145` uses `toast.warning(...)` correctly.
- **Why it matters:** Native alert blocks main thread, can't be styled, fires in tests without suppression.
- **Suggested fix:** Replace with `toast.warning(...)` matching `client-list.tsx`.
### U-079: Icon-only buttons missing aria-label (5 sites)
- **Files:**
- `src/components/notifications/notification-bell.tsx:65` (Bell icon button)
- `src/components/files/file-grid.tsx:121` (MoreHorizontal "…" on file cards)
- `src/components/admin/forms/form-template-list.tsx:102` (Trash button)
- `src/components/email/email-accounts-list.tsx:159` (Trash button)
- `src/components/companies/company-members-tab.tsx:228` (MoreHorizontal)
- **Pattern reference (correct):** `src/components/shared/folder-actions-menu.tsx:96` uses `<span className="sr-only">More folder actions</span>`.
- **Suggested fix:** Add `aria-label` to each, following the folder-actions-menu sr-only pattern.
---
## 🟡 MEDIUM
### U-009: Audit log inline div instead of EmptyState component
- **File:** `src/components/admin/audit/audit-log-list.tsx:524`
- **What:** `<div><p className="text-muted-foreground">No audit log entries found.</p></div>` rather than `<EmptyState title="..." />`.
- **Suggested fix:** Replace with `<EmptyState title="No audit log entries found." />`.
### U-010: Two duplicate EmptyState components with incompatible APIs
- **Files:** `src/components/ui/empty-state.tsx` vs `src/components/shared/empty-state.tsx`
- **What:** `ui/` accepts `{icon: ReactNode, body, actions}`; `shared/` accepts `{icon: ElementType, description, action: {label, onClick}}`. 3 files use `ui/` (admin/reconcile-queue, documents/documents-hub, reservations/reservation-detail), 24 use `shared/`.
- **Suggested fix:** Pick `shared/` as canonical (8× usage); migrate the 3 `ui/` callers and delete `ui/empty-state`.
### U-021: Required-field marker inconsistent
- **Files:** `src/components/clients/client-form.tsx:273`, `src/components/interests/interest-form.tsx:281`
- **What:** Some fields use inline `*`, others have no marker; no `aria-required` on inputs; no consistent pattern.
- **Suggested fix:** Single pattern: `<Label>Field <span aria-hidden>*</span></Label>` + `aria-required="true"` on input.
### U-022: Help-text discoverability inconsistent
- **File:** `src/components/shared/filter-bar.tsx`, `src/components/clients/client-form.tsx`
- **What:** No tooltip pattern; some fields have always-visible muted-foreground hints, some have nothing.
- **Suggested fix:** Document a rule (always-visible for constraints/format hints; tooltips only for icons).
### U-024: Cancel/dismiss without unsaved-changes warning on ClientForm/YachtForm
- **Files:** `src/components/clients/client-form.tsx`, `src/components/yachts/yacht-form.tsx`
- **What:** `InterestForm.requestClose()` (line 123) checks `isDirty` and shows discard AlertDialog; `CompanyForm` also has it. ClientForm and YachtForm don't — sheet closes immediately.
- **Suggested fix:** Add `isDirty` guard + discard AlertDialog matching InterestForm pattern.
### U-031: FileUploadZone size limit not surfaced as client-side check
- **File:** `src/components/files/file-upload-zone.tsx:170`
- **What:** Accept attribute lists extensions; "up to 50MB" copy at line 163; no client-side size check before upload. Server-side check fails silently with "Upload failed" at line 103.
- **Suggested fix:** Wire client-side size check before upload; show clear "File too large" message.
### U-044: No jump-to-page input in pagination
- **File:** `src/components/shared/data-table.tsx:420`
- **Suggested fix:** Add small `<input type="number">` between Previous/Next.
### U-048: No column resize/reorder on DataTable
- **File:** `src/components/shared/data-table.tsx`
- **What:** Visibility supported via `ColumnPicker`; widths fixed; no drag-reorder.
- **Suggested fix:** Opt-in `enableColumnResizing` per table via TanStack Table v8 `onColumnSizingChange`.
### U-069: Invoice delete uses custom overlay, not AlertDialog
- **File:** `src/app/(dashboard)/[portSlug]/invoices/page.tsx:167`
- **What:** Hand-rolled `<div className="fixed inset-0 bg-background/80 backdrop-blur-xs z-50 ...">` rather than `<AlertDialog>` / `<ConfirmationDialog>`. Lacks focus trap, Escape, role="alertdialog".
- **Suggested fix:** Replace with `<ConfirmationDialog>` matching pattern elsewhere.
### U-074: Success toast missing on ClientForm + InterestForm create/edit
- **Files:** `src/components/clients/client-form.tsx:215`, `src/components/interests/interest-form.tsx:235`
- **What:** `onSuccess` invalidates queries + closes sheet, no `toast.success()`. `ComposeDialog.onSuccess:81` does fire one.
- **Suggested fix:** `toast.success(isEdit ? 'Client updated' : 'Client created')`.
### U-080: Logo preview `<img alt="">` should describe state
- **File:** `src/components/admin/shared/settings-form-card.tsx:420`
- **Suggested fix:** Use `alt="Port logo preview"` or dynamic from field label.
### U-081: Heading hierarchy inconsistent within tab components
- **Files:** `email-accounts-list.tsx:114`, `interest-contract-tab.tsx:130/251/291/364` (h2 → h3 → h2 jumps)
- **Suggested fix:** Audit each tab; standardize h2 = primary section, h3 = sub-section; never h2 after h3 at same nesting depth.
### U-086: DialogContent missing aria-describedby on minimal-content dialogs
- **File:** `src/components/email/compose-dialog.tsx:95` and ~40 other dialogs
- **What:** Only `file-preview-dialog.tsx:82` explicitly suppresses the Radix warning.
- **Suggested fix:** Add `<DialogDescription className="sr-only">...</DialogDescription>` or `aria-describedby={undefined}` to suppress.
### U-091: Mobile topbar title blank on list pages
- **Files:** `client-list.tsx`, `yacht-list.tsx`, `interest-list.tsx`, `berth-list.tsx`
- **What:** `useMobileChrome` only called from detail pages. List pages leave topbar in fallback (no title, stale from previous detail page).
- **Suggested fix:** Add `useMobileChrome({ title, showBackButton: false })` per list with cleanup pattern.
### U-093: Invoices missing from mobile navigation
- **File:** `src/components/layout/mobile/more-sheet.tsx:54`
- **What:** Not in `MORE_GROUPS`, not in bottom tabs. Mobile users can only reach via direct URL.
- **Suggested fix:** Add `{ label: 'Invoices', icon: FileText, segment: 'invoices' }` to Operations group.
---
## ✅ Sample passing checks
- U-001-008 list empty states + skeletons clean across clients/yachts/interests/berths/companies/reservations/invoices/email-threads
- U-012 FileUploadZone drag-hover with `border-primary bg-primary/5`
- U-023 field-level errors via react-hook-form `formState.errors` consistent
- U-026 BulkAddBerthsWizard + CatchUpWizard persist state across step nav
- U-027 phone E.164 via `formatAsYouType` emits `{ e164, country }`
- U-029 native `<input type="date">` provides browser calendar + keyboard
- U-033 Combobox keyboard nav inherited from Radix `<Command>` primitives
- U-040 Sort indicators via `getSortIcon` (`ArrowUpDown`/`ArrowUp`/`ArrowDown`)
- U-041/042 Filter chip dismiss + Clear-all in FilterBar
- U-043 page size selector 25/50/100/250/All
- U-049 virtual list via `@tanstack/react-virtual` (`virtual virtualHeightPx={640}` in audit log)
- U-054 STAGE_BADGE in `src/lib/constants.ts:100` — 7 distinct stages with distinct Tailwind colour families
- U-055 outcome badge: won=emerald, lost\_\*=rose, cancelled=slate
- U-057 status-pill covers all required document statuses
- U-060/061 button hierarchy + destructive red consistent
- U-065 Sheet used for forms+previews on both desktop and mobile (23 components)
- U-067 AlertDialog used for destructive confirmations (`useConfirmation`, `ArchiveConfirmDialog`, `ConfirmationDialog`, `BulkHardDeleteDialog`)
- U-070-072 click-outside, Esc, focus-trap, focus-restore all inherited from Radix
- U-073 toast position consistent (sonner top-right)
- U-075 `toastError()` (`src/lib/api/toast-error.ts:43`) surfaces requestId + Copy ID action — used in 89 files
- U-094 iOS safe-area-inset comprehensive (`pb-safe-bottom`, `pt-safe-top`, FAB `calc(env(safe-area-inset-bottom)+86px)`)
- U-097 visualViewport handling on mobile-search-overlay
- U-092 More sheet covers Documents/Interests/Yachts/Companies/Residential/Alerts/Reminders/Expenses/Reservations/Reports/Analytics/Settings/Admin

View File

@@ -0,0 +1,49 @@
# #71 Automated email refactor — DEFERRED
Searched the repo + git history (commits back to the initial `67d7e6e Initial
commit: Port Nimara CRM`) for legacy CRM email templates that could be
lifted verbatim or used as a tonal reference for the rewrite. **None found.**
The codebase was built from scratch; there's no archive directory, no
import dump, and no commits ever contained "old-system" template HTML.
## What this task needs
A full refactor of the four signing-lifecycle emails to a luxury-port
brand voice, with per-port branding flow:
1. **Invitation** (`signingInvitationEmail`) — currently functional but
utilitarian copy. Subject format Matt called for:
`"{firstName}, your EOI for {portName} is ready to be signed"`.
2. **Reminder** (`signingReminderEmail`) — same recipient, follow-up nudge.
3. **Completion** (`signingCompletedEmail`) — sent with the signed PDF attached.
4. **Cancelled** (`signingCancelledEmail`) — added 2026-05-15 alongside the
cancel-with-notify modal.
Each template should have **per-port** branding parameters:
- Port name + signature block
- Primary brand color (already plumbed via `BrandingShell`)
- Optional header/footer HTML overrides (`branding_email_header_html` /
`_footer_html` settings)
## Source-of-truth flow before unblocking
Matt to paste / share the legacy templates from the prior CRM (likely
NocoDB-era or a separate email tool — not committed to this repo). Once
shared, lift the copy verbatim where possible; otherwise match
**structure + tone + voice** carefully.
Current files to refactor:
- `src/lib/email/templates/document-signing.tsx` (4 templates)
- `src/lib/email/templates/portal-auth.tsx` (activation + reset)
- `src/lib/email/templates/inquiry-client-confirmation.tsx`
- `src/lib/email/templates/inquiry-sales-notification.tsx`
## Status
DEFERRED until the legacy copy is supplied or Matt approves a from-scratch
draft. The structural plumbing (per-port branding, sendEmail with
attachments, EMAIL_REDIRECT_TO safety, cancel-with-notify wiring) all
landed in earlier tasks — only the copy rewrite remains.

View File

@@ -0,0 +1,158 @@
/**
* Backfill `document_signers` rows for EOI documents that were generated
* before the per-recipient signer-row insert landed (pre-2026-05-15).
*
* Symptom on the affected docs: the EOI tab's "Signing progress" panel
* reads "No signers loaded" forever because the webhook handler updates
* existing rows (by token / email) and never inserts new ones.
*
* This script walks every documents row that has a documensoId, status
* in ('sent', 'partially_signed', 'completed'), and zero signer rows.
* For each, it pulls the envelope from Documenso and recreates the
* signer rows from the recipients array. Idempotent — safe to re-run.
*
* Usage:
* pnpm tsx scripts/backfill-eoi-signers.ts # dry-run, lists candidates
* pnpm tsx scripts/backfill-eoi-signers.ts --apply # actually inserts
*/
import 'dotenv/config';
import { and, inArray, isNotNull, sql } from 'drizzle-orm';
import { db, closeDb } from '@/lib/db';
import { documents, documentSigners } from '@/lib/db/schema/documents';
import { getDocument as getDocumensoDoc } from '@/lib/services/documenso-client';
import { logger } from '@/lib/logger';
interface BackfillStats {
scanned: number;
withZeroSigners: number;
inserted: number;
failed: number;
skipped: number;
}
async function main() {
const apply = process.argv.includes('--apply');
// 1. Find candidate documents: in-flight or completed EOIs with a
// documensoId and no signer rows.
const candidates = await db
.select({
id: documents.id,
portId: documents.portId,
documensoId: documents.documensoId,
status: documents.status,
documentType: documents.documentType,
title: documents.title,
signerCount: sql<number>`(
SELECT COUNT(*)::int FROM ${documentSigners}
WHERE ${documentSigners.documentId} = ${documents.id}
)`,
})
.from(documents)
.where(
and(
inArray(documents.status, ['sent', 'partially_signed', 'completed']),
isNotNull(documents.documensoId),
),
);
const stats: BackfillStats = {
scanned: candidates.length,
withZeroSigners: 0,
inserted: 0,
failed: 0,
skipped: 0,
};
const needsBackfill = candidates.filter((c) => c.signerCount === 0);
stats.withZeroSigners = needsBackfill.length;
console.log(
`Scanned ${stats.scanned} document${stats.scanned === 1 ? '' : 's'}; ${stats.withZeroSigners} need backfill.`,
);
if (!apply) {
console.log('\nDRY RUN (pass --apply to insert):');
for (const doc of needsBackfill) {
console.log(` - ${doc.id} (${doc.title}) — port=${doc.portId}, status=${doc.status}`);
}
await closeDb();
return;
}
// 2. For each candidate, fetch the envelope from Documenso and insert
// the signer rows. Failures are logged + counted; processing
// continues so one broken doc doesn't halt the run.
for (const doc of needsBackfill) {
if (!doc.documensoId) {
stats.skipped++;
continue;
}
try {
const envelope = await getDocumensoDoc(doc.documensoId, doc.portId);
if (envelope.recipients.length === 0) {
logger.warn({ documentId: doc.id }, 'Backfill: envelope has no recipients — skipping');
stats.skipped++;
continue;
}
// Use the same role-mapping logic as the create-time flow:
// - signingOrder=1 + role SIGNER → 'client' (positional)
// - SIGNER otherwise → 'signer'
// - APPROVER → 'approver'
// - CC / VIEWER → pass-through
const rows = envelope.recipients.map((r) => {
const cleanName = (r.name || r.email)
.replace(/\s*\(was:[^)]*\)/i, '')
.replace(/\s*\(placeholder\)/i, '')
.trim();
const upRole = r.role.toUpperCase();
const role =
upRole === 'SIGNER' && r.signingOrder === 1
? 'client'
: upRole === 'APPROVER'
? 'approver'
: upRole === 'CC'
? 'cc'
: upRole === 'VIEWER'
? 'viewer'
: 'signer';
return {
documentId: doc.id,
signerName: cleanName || r.email,
signerEmail: r.email,
signerRole: role,
signingOrder: r.signingOrder,
status: (r.status === 'SIGNED' ? 'signed' : 'pending') as 'signed' | 'pending',
signingUrl: r.signingUrl ?? null,
embeddedUrl: r.embeddedUrl ?? null,
signingToken: r.token ?? null,
// No invitedAt — the backfill can't reconstruct the original
// dispatch timestamp. Reps see the card as "Not yet invited"
// for any pending signer; clicking Send invitation re-stamps.
invitedAt: null,
};
});
await db.insert(documentSigners).values(rows);
stats.inserted += rows.length;
console.log(`${doc.id} (${doc.title}) — inserted ${rows.length} signer row(s)`);
} catch (err) {
stats.failed++;
logger.error(
{ err: err instanceof Error ? err.message : err, documentId: doc.id },
'Backfill failed for document',
);
console.log(`${doc.id}${err instanceof Error ? err.message : 'unknown error'}`);
}
}
console.log(`\nDone. inserted=${stats.inserted} failed=${stats.failed} skipped=${stats.skipped}`);
await closeDb();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,138 @@
/**
* One-time migration: encrypt any plaintext credential rows in
* `system_settings` that should now be AES-256-GCM encrypted per the
* settings registry. Safe to re-run (idempotent — only touches plaintext
* rows, skips rows that are already encrypted envelopes).
*
* Currently handles:
* - `documenso_api_key_override` → in-place encrypt
* - `storage_s3_access_key` (legacy) → encrypt + move to
* `storage_s3_access_key_encrypted`
* - `documenso_webhook_secret` (if string) → in-place encrypt
*
* Run: `pnpm tsx scripts/encrypt-plaintext-credentials.ts`
*/
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema';
import { encrypt } from '@/lib/utils/encryption';
const KEYS_TO_ENCRYPT_IN_PLACE = ['documenso_api_key_override', 'documenso_webhook_secret'];
function isEncryptedEnvelope(value: unknown): boolean {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { iv?: unknown }).iv === 'string' &&
typeof (value as { tag?: unknown }).tag === 'string' &&
typeof (value as { data?: unknown }).data === 'string'
);
}
async function encryptInPlace(key: string): Promise<{ touched: number; skipped: number }> {
const rows = await db
.select({ key: systemSettings.key, portId: systemSettings.portId, value: systemSettings.value })
.from(systemSettings)
.where(eq(systemSettings.key, key));
let touched = 0;
let skipped = 0;
for (const row of rows) {
if (isEncryptedEnvelope(row.value)) {
skipped++;
continue;
}
if (typeof row.value !== 'string' || row.value === '') {
skipped++;
continue;
}
const envelope = JSON.parse(encrypt(row.value)) as {
iv: string;
tag: string;
data: string;
};
if (row.portId) {
await db
.update(systemSettings)
.set({ value: envelope, updatedAt: new Date() })
.where(and(eq(systemSettings.key, key), eq(systemSettings.portId, row.portId)));
} else {
await db
.update(systemSettings)
.set({ value: envelope, updatedAt: new Date() })
.where(and(eq(systemSettings.key, key), isNull(systemSettings.portId)));
}
touched++;
}
return { touched, skipped };
}
async function moveS3AccessKeyToEncrypted(): Promise<{
moved: number;
alreadyMigrated: number;
}> {
// Move global rows only — s3 storage settings are global by design.
const legacyRows = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(and(eq(systemSettings.key, 'storage_s3_access_key'), isNull(systemSettings.portId)));
if (legacyRows.length === 0) {
return { moved: 0, alreadyMigrated: 0 };
}
// Check if the encrypted form already exists.
const existingEncrypted = await db
.select({ key: systemSettings.key })
.from(systemSettings)
.where(
and(eq(systemSettings.key, 'storage_s3_access_key_encrypted'), isNull(systemSettings.portId)),
);
if (existingEncrypted.length > 0) {
// Encrypted form wins; leave the legacy row in place so reads still
// tolerate it (the storage layer reads both and prefers encrypted).
return { moved: 0, alreadyMigrated: legacyRows.length };
}
const plaintext = legacyRows[0]!.value;
if (typeof plaintext !== 'string' || plaintext === '') {
return { moved: 0, alreadyMigrated: 0 };
}
const envelope = JSON.parse(encrypt(plaintext)) as { iv: string; tag: string; data: string };
await db.insert(systemSettings).values({
key: 'storage_s3_access_key_encrypted',
portId: null,
value: envelope,
});
// Drop the legacy plaintext row so it doesn't show up in admin
// settings dumps anymore. The storage layer's backward-compat path
// continues to handle older rows on other deployments.
await db
.delete(systemSettings)
.where(and(eq(systemSettings.key, 'storage_s3_access_key'), isNull(systemSettings.portId)));
return { moved: 1, alreadyMigrated: 0 };
}
async function main(): Promise<void> {
console.log('Encrypting plaintext credentials...');
for (const key of KEYS_TO_ENCRYPT_IN_PLACE) {
const { touched, skipped } = await encryptInPlace(key);
console.log(` ${key}: ${touched} encrypted, ${skipped} skipped`);
}
const s3 = await moveS3AccessKeyToEncrypted();
console.log(
` storage_s3_access_key → _encrypted: ${s3.moved} moved, ${s3.alreadyMigrated} already migrated`,
);
console.log('Done.');
process.exit(0);
}
main().catch((err: unknown) => {
console.error('Migration failed:', err);
process.exit(1);
});

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -25,8 +25,25 @@ const loginSchema = z.object({
type LoginFormData = z.infer<typeof loginSchema>;
/**
* H-02: Validate a redirect target before pushing the user to it. The
* middleware appends `?redirect=<path>` when a session check fails on a
* protected route; an unsanitized router.push of that value would let a
* crafted URL bounce the user to an external host or protocol-relative
* `//evil.com` after a successful sign-in. Only same-origin, single-leading-
* slash paths pass.
*/
function safeRedirectTarget(raw: string | null): string {
if (!raw) return '/dashboard';
// Allow only paths starting with a single `/` (rules out `//evil.com`
// protocol-relative URLs and `https://…` absolute ones).
if (!raw.startsWith('/') || raw.startsWith('//')) return '/dashboard';
return raw;
}
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
// Fresh-DB bootstrap detection: if no super-admin exists yet, /setup
@@ -76,7 +93,8 @@ export default function LoginPage() {
return;
}
router.push('/dashboard');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(safeRedirectTarget(searchParams.get('redirect')) as any);
} catch {
toast.error('Something went wrong. Please try again.');
} finally {

View File

@@ -1,8 +1,8 @@
'use client';
import { Suspense, useState } from 'react';
import { Suspense, useState, useSyncExternalStore } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -27,10 +27,35 @@ const passwordSchema = z
type SetPasswordFormData = z.infer<typeof passwordSchema>;
/**
* H-03: tokens travel in the URL fragment (`#token=…`) so they never land
* in HTTP access logs or HTTP-Referer headers. Pre-fragment links still
* carry `?token=…` and stay functional until every outstanding invite
* expires — drop the `?token=` fallback after that grace period.
*/
function readTokenFromUrl(): string {
if (typeof window === 'undefined') return '';
const hash = window.location.hash.replace(/^#/, '');
if (hash) {
const params = new URLSearchParams(hash);
const fromFragment = params.get('token');
if (fromFragment) return fromFragment;
}
const search = new URLSearchParams(window.location.search);
return search.get('token') ?? '';
}
const subscribeNoop = () => () => undefined;
function SetPasswordInner() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
// useSyncExternalStore so the fragment-only token is read post-hydration
// (server snapshot returns null; client returns the actual value).
const token = useSyncExternalStore<string | null>(
subscribeNoop,
() => readTokenFromUrl(),
() => null,
);
const [isLoading, setIsLoading] = useState(false);
const {
@@ -73,6 +98,17 @@ function SetPasswordInner() {
}
}
// Pre-hydration: token is null. Show a loading placeholder so the user
// doesn't see a flash of "Link is missing" while the fragment is being
// read on the client.
if (token === null) {
return (
<BrandedAuthShell>
<div className="text-center text-sm text-gray-500">Loading</div>
</BrandedAuthShell>
);
}
if (!token) {
return (
<BrandedAuthShell>

View File

@@ -1,57 +1,11 @@
import Link from 'next/link';
import { Bot, FileText, Brain, ExternalLink } from 'lucide-react';
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
const MASTER_FIELDS: SettingFieldDef[] = [
{
key: 'ai_enabled',
label: 'AI features enabled',
description:
'Master switch. When OFF, every AI surface (receipt OCR fallback, berth-PDF AI parse, future embedding-driven recommendations) is bypassed. Provider keys stay configured but unused.',
type: 'boolean',
defaultValue: true,
},
{
key: 'ai_monthly_token_cap',
label: 'Monthly token cap (this port)',
description:
'Soft cap on total AI tokens consumed per calendar month across every feature. When exceeded, AI features fall back to non-AI paths and surface a banner. Set 0 for no cap.',
type: 'number',
defaultValue: 0,
},
];
const PROVIDER_FIELDS: SettingFieldDef[] = [
{
key: 'openai_api_key',
label: 'OpenAI API key',
description:
'Used by Receipt OCR fallback and (future) berth-PDF AI parse. Stored AES-encrypted at rest; the field shows blank after save.',
type: 'password',
placeholder: 'sk-…',
defaultValue: '',
},
{
key: 'openai_default_model',
label: 'Default OpenAI model',
description: 'Used when a feature does not specify an explicit model.',
type: 'select',
defaultValue: 'gpt-4o-mini',
options: [
{ value: 'gpt-4o-mini', label: 'gpt-4o-mini — cheap, fast, vision-capable' },
{ value: 'gpt-4o', label: 'gpt-4o — full-strength multimodal' },
{ value: 'gpt-4-turbo', label: 'gpt-4-turbo — legacy text reasoning' },
],
},
];
interface FeatureLink {
href: string;
icon: typeof Bot;
@@ -85,16 +39,16 @@ export default function AiAdminPage() {
eyebrow="ADMIN"
/>
<SettingsFormCard
<RegistryDrivenForm
title="Master controls"
description="Hard kill switch + budget guardrails covering every AI surface in this port."
fields={MASTER_FIELDS}
sections={['ai.master']}
/>
<SettingsFormCard
<RegistryDrivenForm
title="Provider credentials"
description="Shared API keys used by AI-enabled features. Per-feature pages can override the model on a feature-by-feature basis."
fields={PROVIDER_FIELDS}
description="Shared API keys used by AI-enabled features. AES-encrypted at rest. Per-feature pages can override the model on a feature-by-feature basis."
sections={['ai.providers']}
/>
<Card>

View File

@@ -4,148 +4,15 @@ import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
import { EmbeddedSigningCard } from '@/components/admin/documenso/embedded-signing-card';
import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-button';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
const API_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_api_url_override',
label: 'API URL override',
description:
'Optional. Falls back to DOCUMENSO_API_URL env when blank. Bare host only — never include /api/v1; the client appends versioned paths based on the API version below.',
type: 'string',
placeholder: 'https://documenso.example.com',
defaultValue: '',
},
{
key: 'documenso_api_key_override',
label: 'API key override',
description: 'Optional. Falls back to DOCUMENSO_API_KEY env when blank. Stored in plain text.',
type: 'password',
defaultValue: '',
},
{
key: 'documenso_api_version_override',
label: 'API version',
description:
'Which Documenso REST API this port targets. v1 = Documenso 1.13.x stable. v2 = Documenso 2.x with the envelope model and richer per-field metadata. Test the connection after switching. See the v2 benefits card above for what changes when you flip this — and note that template-based EOI generation still uses the v1 formValues shape regardless of this setting (v2 template/use migration is on the roadmap).',
type: 'select',
options: [
{ value: 'v1', label: 'v1 — Documenso 1.13.x (default, stable)' },
{ value: 'v2', label: 'v2 — Documenso 2.x (envelope, recommended for new ports)' },
],
defaultValue: 'v1',
},
];
const SIGNER_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_developer_name',
label: 'Developer signer — name',
description:
'The party who signs after the client (typically the marina developer or owner). Used as the static "developer" recipient in templated documents (EOI). Was hardcoded as "David Mizrahi" in the legacy single-tenant system.',
type: 'string',
placeholder: 'David Mizrahi',
defaultValue: '',
},
{
key: 'documenso_developer_email',
label: 'Developer signer — email',
description: 'Email used to send the developer signing request via Documenso.',
type: 'string',
placeholder: 'dm@portnimara.com',
defaultValue: '',
},
{
key: 'documenso_developer_label',
label: 'Developer signer — display label',
description:
'How the developer slot is referenced in email subjects + signer-progress UI copy. Defaults to "Developer" when blank.',
type: 'string',
placeholder: 'Developer',
defaultValue: '',
},
{
key: 'documenso_developer_user_id',
label: 'Developer signer — linked CRM user (optional)',
description:
"Project Director RBAC binding. When set, the webhook handler fires an in-CRM notification for this user when it's their turn to sign — alongside the branded email. Leave blank if the developer slot doesn't map to a CRM user (e.g. external developer). Use the user's UUID from /admin/users.",
type: 'string',
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
{
key: 'documenso_approver_name',
label: 'Approver — name',
description:
'The final approver who signs after the developer (typically a sales/legal lead). Was hardcoded as "Abbie May" in the legacy system.',
type: 'string',
placeholder: 'Abbie May',
defaultValue: '',
},
{
key: 'documenso_approver_email',
label: 'Approver — email',
description: 'Email used to route the final approval signing request.',
type: 'string',
placeholder: 'sales@portnimara.com',
defaultValue: '',
},
{
key: 'documenso_approver_label',
label: 'Approver — display label',
description:
'How the approver slot is referenced in email subjects + signer-progress UI copy. Defaults to "Approver" when blank.',
type: 'string',
placeholder: 'Approver',
defaultValue: '',
},
{
key: 'documenso_approver_user_id',
label: 'Approver — linked CRM user (optional)',
description:
"Same as developer's linked user — when set, fires an in-CRM notification when it's the approver's turn. Use the user's UUID from /admin/users.",
type: 'string',
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
];
const EOI_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_eoi_template_id',
label: 'EOI Documenso template ID',
description: 'Numeric template ID used by the Documenso EOI pathway.',
type: 'string',
placeholder: '12345',
defaultValue: '',
},
{
key: 'eoi_default_pathway',
label: 'Default EOI pathway',
description:
'Which pathway is used when an EOI is generated without an explicit choice. Documenso = signed via Documenso, In-app = filled locally with pdf-lib.',
type: 'select',
options: [
{ value: 'documenso-template', label: 'Documenso template' },
{ value: 'inapp', label: 'In-app (pdf-lib)' },
],
defaultValue: 'documenso-template',
},
{
key: 'eoi_send_mode',
label: 'Initial signing-invitation email behaviour',
description:
'Auto = the system sends our branded "please sign" email immediately when an EOI/contract/reservation is generated. Manual = the document is generated and the signing URL appears in the UI; a rep clicks "Send invitation" to dispatch. Auto is the lower-friction option for high-volume teams; manual lets reps review before sending. Applies to all document types, not just EOI.',
type: 'select',
options: [
{ value: 'manual', label: 'Manual (rep clicks Send after generation)' },
{ value: 'auto', label: 'Auto (send branded email on generate)' },
],
defaultValue: 'manual',
},
];
// API_FIELDS removed — replaced by <RegistryDrivenForm sections={['documenso.api']} />
// which adds the new webhook-secret field + AES encrypts the API key at rest.
const CONTRACT_RESERVATION_FIELDS: SettingFieldDef[] = [
{
@@ -168,30 +35,22 @@ const CONTRACT_RESERVATION_FIELDS: SettingFieldDef[] = [
},
];
const EMBED_FIELDS: SettingFieldDef[] = [
{
key: 'embedded_signing_host',
label: 'Embedded signing host',
description:
"Origin of the public site that hosts the embedded Documenso signing pages. Outbound emails wrap raw Documenso signing URLs into {host}/sign/<type>/<token> so clients sign on your branded page rather than Documenso's domain. Leave blank to fall back to the app URL. Marketing-website pattern: https://portnimara.com",
type: 'string',
placeholder: 'https://portnimara.com',
defaultValue: '',
},
];
// Embedded signing field config + Test + Setup help all live inside
// `<EmbeddedSigningCard />` (imported above). Kept out of the field list
// here so the admin page reads as a flat sequence of cards.
const V2_FEATURE_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_signing_order',
label: 'Signing order',
description:
'PARALLEL = recipients can sign in any order (faster, current default). SEQUENTIAL = Documenso refuses to email recipient N+1 until recipient N has signed, enforcing client → developer → approver order on EOIs. Only applies when API version above is v2 v1 instances ignore this and always behave as PARALLEL.',
'Whether all signers receive the invitation at once (PARALLEL — anyone can sign first) or only the next pending signer gets the email once the previous one finishes (SEQUENTIAL). Applied at envelope-create time on both v1 and v2: v1 honours meta.signingOrder on /templates/{id}/generate-document; v2 honours it via /envelope/update right after /template/use.',
type: 'select',
options: [
{ value: '', label: 'PARALLEL (default)' },
{ value: 'SEQUENTIAL', label: 'SEQUENTIAL — enforce signing order (v2 only)' },
{ value: 'PARALLEL', label: 'PARALLEL — all signers invited at once' },
{ value: 'SEQUENTIAL', label: 'SEQUENTIAL — one at a time in order' },
],
defaultValue: '',
defaultValue: 'PARALLEL',
},
{
key: 'documenso_redirect_url',
@@ -369,10 +228,10 @@ export default function DocumensoSettingsPage() {
</CardContent>
</Card>
<SettingsFormCard
<RegistryDrivenForm
title="Documenso API"
description="Per-port API credentials. Leave blank to use the global env defaults."
fields={API_FIELDS}
description="Per-port API credentials. AES-encrypted at rest. Leave blank to inherit from the env fallback (badged below each field)."
sections={['documenso.api']}
extra={<DocumensoTestButton />}
/>
@@ -382,16 +241,17 @@ export default function DocumensoSettingsPage() {
fields={V2_FEATURE_FIELDS}
/>
<SettingsFormCard
<RegistryDrivenForm
sections={['documenso.signers']}
title="Signers (developer + approver)"
description="Identity of the static signers in your Documenso templates. The client is always pulled from the interest's linked client record; these values fill the developer (signing order 2) and approver (signing order 3) slots."
fields={SIGNER_FIELDS}
description="Identity bound to the developer (signing order 2) and approver (signing order 3) slots in your Documenso templates. Leave name + email blank to fall through to whatever you set on the Documenso template itself; set them here to override the template's stored values at send time. Recipient IDs are populated automatically by 'Sync from Documenso' below. Linking a CRM user is optional — when set, the platform fires an in-CRM notification for that user when it's their turn to sign."
/>
<SettingsFormCard
<RegistryDrivenForm
sections={['documenso.templates']}
title="EOI generation"
description="Default pathway, template, and email behaviour when an interest's EOI is generated."
fields={EOI_FIELDS}
description="Default pathway, template, and email behaviour when an interest's EOI is generated. Recipient + field discovery happens via 'Sync from Documenso' below — that also populates the template ID for you."
extra={<TemplateSyncButton />}
/>
<SettingsFormCard
@@ -400,11 +260,7 @@ export default function DocumensoSettingsPage() {
fields={CONTRACT_RESERVATION_FIELDS}
/>
<SettingsFormCard
title="Embedded signing"
description="Where the public-facing branded signing pages live. The CRM rewrites Documenso signing URLs to point here when sending invitation and reminder emails."
fields={EMBED_FIELDS}
/>
<EmbeddedSigningCard />
</div>
);
}

View File

@@ -1,68 +1,8 @@
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
const FIELDS: SettingFieldDef[] = [
{
key: 'email_from_name',
label: 'From name',
description: 'Display name shown in the From: header on outgoing email.',
type: 'string',
placeholder: 'Port Nimara',
defaultValue: '',
},
{
key: 'email_from_address',
label: 'From address',
description: 'Sender email address. Falls back to SMTP_FROM env when blank.',
type: 'string',
placeholder: 'noreply@example.com',
defaultValue: '',
},
{
key: 'email_reply_to',
label: 'Reply-to address',
description: 'Optional Reply-To: header for replies (e.g. sales@example.com).',
type: 'string',
placeholder: 'sales@example.com',
defaultValue: '',
},
{
key: 'smtp_host_override',
label: 'SMTP host override',
description: 'Optional. Falls back to SMTP_HOST env when blank.',
type: 'string',
placeholder: 'mail.example.com',
defaultValue: '',
},
{
key: 'smtp_port_override',
label: 'SMTP port override',
description: 'Optional. Falls back to SMTP_PORT env when blank.',
type: 'number',
placeholder: '587',
defaultValue: null,
},
{
key: 'smtp_user_override',
label: 'SMTP username override',
description: 'Optional. Falls back to SMTP_USER env when blank.',
type: 'string',
defaultValue: '',
},
{
key: 'smtp_pass_override',
label: 'SMTP password override',
description: 'Optional. Stored in plain text - only set when overriding env credentials.',
type: 'password',
defaultValue: '',
},
];
export default function EmailSettingsPage() {
return (
<div className="space-y-6">
@@ -70,15 +10,18 @@ export default function EmailSettingsPage() {
title="Email Settings"
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
/>
<SettingsFormCard
{/* Registry-driven so each field shows the "Using env fallback /
port / global / default" badge inline — admins can tell at a
glance which fields are coming from .env vs. UI overrides. */}
<RegistryDrivenForm
sections={['email.from']}
title="From address"
description="Identity headers used by system-generated emails."
fields={FIELDS.slice(0, 3)}
/>
<SettingsFormCard
<RegistryDrivenForm
sections={['email.smtp']}
title="SMTP transport overrides"
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults."
fields={FIELDS.slice(3)}
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
/>
<SalesEmailConfigCard />
<EmailRoutingCard />

View File

@@ -1,14 +1,23 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { ShieldX } from 'lucide-react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema/users';
import { Button } from '@/components/ui/button';
/**
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may
* access any page under /[portSlug]/admin.
*
* H-15: previously this layout silently redirected non-admins to
* `/dashboard`, which left them staring at the dashboard with no
* explanation of why their bookmark / shared admin link "didn't work".
* Render an explicit 403 page instead so the URL stays on the failed
* route and the user can see why their request was denied.
*/
export default async function AdminLayout({
children,
@@ -29,7 +38,23 @@ export default async function AdminLayout({
});
if (!profile?.isSuperAdmin) {
redirect(`/${portSlug}/dashboard`);
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-4 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
<ShieldX className="h-7 w-7 text-destructive" aria-hidden />
</div>
<div className="space-y-1">
<h1 className="text-xl font-semibold">Access denied</h1>
<p className="max-w-md text-sm text-muted-foreground">
This area is for super-administrators only. If you believe you should have access, ask
an administrator to grant the super-admin role on your account.
</p>
</div>
<Button asChild>
<Link href={`/${portSlug}/dashboard`}>Back to dashboard</Link>
</Button>
</div>
);
}
return <>{children}</>;

View File

@@ -0,0 +1,264 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type Mode = 'auto' | 'suggest' | 'off';
const TRIGGERS: Array<{
key: string;
label: string;
description: string;
defaultMode: Mode;
}> = [
{
key: 'eoi_sent',
label: 'EOI sent',
description: 'Rep generates an EOI for signing — moves the deal to "EOI" stage.',
defaultMode: 'auto',
},
{
key: 'eoi_signed',
label: 'EOI signed (all parties)',
description:
'All signatories complete the EOI — moves the deal to "Reservation" stage. Conventional CRM behaviour.',
defaultMode: 'auto',
},
{
key: 'reservation_signed',
label: 'Reservation agreement signed',
description:
'Reservation paperwork signed by all parties — keeps the deal at "Reservation" with sub-status signed.',
defaultMode: 'auto',
},
{
key: 'deposit_received',
label: 'Deposit received in full',
description:
'Deposit total reaches the expected amount — moves the deal to "Deposit Paid" stage.',
defaultMode: 'auto',
},
{
key: 'contract_signed',
label: 'Sales contract signed',
description: 'Final contract signed by all parties — moves the deal to "Contract" stage.',
defaultMode: 'auto',
},
];
const PRESETS = {
aggressive: 'auto',
conservative: 'suggest',
} as const;
type PresetName = keyof typeof PRESETS;
export default function PipelineRulesPage() {
const queryClient = useQueryClient();
const [rules, setRules] = useState<Record<string, Mode>>(() =>
Object.fromEntries(TRIGGERS.map((t) => [t.key, t.defaultMode])),
);
const { data, isLoading } = useQuery<{
data: { values: Record<string, { value?: Record<string, Mode> | null }> };
}>({
queryKey: ['admin', 'settings', 'pipeline.auto_advance'],
queryFn: () =>
apiFetch<{
data: { values: Record<string, { value?: Record<string, Mode> | null }> };
}>('/api/v1/admin/settings/resolved?sections=pipeline.auto_advance'),
});
// Hydrate the local form once the server-side state arrives. We treat
// missing keys as the registered default — the page's persisted JSON
// doesn't have to enumerate every trigger, just the overrides.
useEffect(() => {
const persisted = data?.data?.values?.stage_advance_rules?.value;
if (!persisted || typeof persisted !== 'object') return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setRules((prev) => {
const next = { ...prev };
for (const t of TRIGGERS) {
const v = persisted[t.key];
if (v === 'auto' || v === 'suggest' || v === 'off') next[t.key] = v;
}
return next;
});
}, [data]);
const saveMutation = useMutation({
mutationFn: () =>
apiFetch('/api/v1/admin/settings/stage_advance_rules', {
method: 'PUT',
body: { value: rules },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
toast.success('Pipeline rules saved.');
},
onError: (err) => toastError(err),
});
const applyPreset = (preset: PresetName) => {
const target = PRESETS[preset];
setRules(Object.fromEntries(TRIGGERS.map((t) => [t.key, target])));
};
const setMode = (key: string, mode: Mode) => {
setRules((prev) => ({ ...prev, [key]: mode }));
};
const allMatch = (mode: Mode) => TRIGGERS.every((t) => rules[t.key] === mode);
const currentPreset: PresetName | 'custom' = allMatch('auto')
? 'aggressive'
: allMatch('suggest')
? 'conservative'
: 'custom';
return (
<div className="space-y-6">
<PageHeader
title="Pipeline auto-advance rules"
description="Control which lifecycle events (signing, payments) automatically advance the deal stage on the kanban. Choose a preset or fine-tune per trigger."
/>
<Card>
<CardHeader>
<CardTitle className="text-base">Preset</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3">
<PresetButton
name="aggressive"
label="Aggressive (default)"
description="Every trigger auto-advances the stage. Matches conventional CRM behaviour and saves rep clicks."
active={currentPreset === 'aggressive'}
onClick={() => applyPreset('aggressive')}
/>
<PresetButton
name="conservative"
label="Conservative"
description="Every trigger sends a notification suggesting the move. Reps click Approve to advance."
active={currentPreset === 'conservative'}
onClick={() => applyPreset('conservative')}
/>
<div
className={`rounded-lg border p-3 ${
currentPreset === 'custom'
? 'border-primary bg-primary/5'
: 'border-muted bg-muted/20'
}`}
>
<p className="text-sm font-semibold">Custom</p>
<p className="text-xs text-muted-foreground">
Mix and match the per-trigger toggles below override the preset.
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Per-trigger settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" aria-hidden /> Loading
</div>
) : (
TRIGGERS.map((t) => (
<div
key={t.key}
className="flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex-1">
<p className="text-sm font-medium">{t.label}</p>
<p className="text-xs text-muted-foreground">{t.description}</p>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`mode-${t.key}`} className="sr-only">
Mode
</Label>
<Select
value={rules[t.key] ?? t.defaultMode}
onValueChange={(v) => setMode(t.key, v as Mode)}
>
<SelectTrigger id={`mode-${t.key}`} className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto-advance</SelectItem>
<SelectItem value="suggest">Suggest only</SelectItem>
<SelectItem value="off">Off</SelectItem>
</SelectContent>
</Select>
</div>
</div>
))
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="gap-1.5 [&_svg]:size-3.5"
>
{saveMutation.isPending ? <Loader2 className="animate-spin" aria-hidden /> : <Save />}
Save rules
</Button>
</div>
</div>
);
}
function PresetButton({
name,
label,
description,
active,
onClick,
}: {
name: PresetName;
label: string;
description: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-lg border p-3 text-left transition-colors ${
active
? 'border-primary bg-primary/5 ring-2 ring-primary/40'
: 'border-muted hover:border-foreground/30 hover:bg-muted/30'
}`}
aria-pressed={active}
>
<p className="text-sm font-semibold">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
<p className="mt-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{name === 'aggressive' ? 'auto for all triggers' : 'suggest for all triggers'}
</p>
</button>
);
}

View File

@@ -6,6 +6,16 @@ import { Plus, Trash2 } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
@@ -163,33 +173,39 @@ export default function InvoicesPage() {
/>
)}
{/* Delete confirmation */}
{deleteTarget && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-xs z-50 flex items-center justify-center">
<div className="bg-background border rounded-lg shadow-lg p-6 max-w-sm w-full space-y-4">
<h3 className="font-semibold">Delete Invoice?</h3>
<p className="text-sm text-muted-foreground">
{/* M-U09: was a hand-rolled overlay; standardized on AlertDialog so
the focus-trap, Escape-to-close, and a11y semantics match every
other destructive-action surface in the app. */}
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete invoice?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete invoice{' '}
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. This
<span className="font-mono font-medium">{deleteTarget?.invoiceNumber}</span>. This
action cannot be undone.
</p>
<div className="flex items-center gap-2 justify-end">
<Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteMutation.mutate(deleteTarget.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="mr-1.5 h-4 w-4" />
Delete
</Button>
</div>
</div>
</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={deleteMutation.isPending}
onClick={() => {
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive"
>
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden />
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import { SocketProvider } from '@/providers/socket-provider';
import { PortProvider } from '@/providers/port-provider';
import { PermissionsProvider } from '@/providers/permissions-provider';
import { AppShell } from '@/components/layout/app-shell';
import { DevModeBanner } from '@/components/shared/dev-mode-banner';
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
import { classifyFormFactor } from '@/lib/form-factor';
@@ -48,6 +49,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
<SocketProvider>
<RealtimeToasts />
<WebVitalsReporter />
{/* Sticky banner across the app whenever EMAIL_REDIRECT_TO is
set so reps + admins always know outbound mail is being
rerouted. Production hides itself (env.ts forbids the
flag in prod) so the banner is dev/staging-only. */}
<DevModeBanner />
{/* #26: AppShell mounts ONE responsive tree (desktop OR
* mobile) per render — never both — so pages don't pay the
* double-state, double-fetch, double-Tabs-provider tax. */}

View File

@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
import { FileText } from 'lucide-react';
import { FileText, CheckCircle2, XCircle, Circle } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
@@ -71,29 +71,38 @@ export default async function PortalDocumentsPage() {
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">
Signers
</p>
{doc.signers.map((signer, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm">
<span
className={
signer.status === 'signed'
? 'text-green-600'
: signer.status === 'declined'
? 'text-red-500'
: 'text-gray-500'
}
>
{signer.status === 'signed'
? '✓'
: signer.status === 'declined'
? '✗'
: '○'}
</span>
<span className="text-gray-700">{signer.signerName}</span>
<span className="text-gray-400 capitalize">
({signer.signerRole.replace(/_/g, ' ')})
</span>
</div>
))}
{doc.signers.map((signer, idx) => {
const StatusIcon =
signer.status === 'signed'
? CheckCircle2
: signer.status === 'declined'
? XCircle
: Circle;
const statusLabel =
signer.status === 'signed'
? 'Signed'
: signer.status === 'declined'
? 'Declined'
: 'Pending';
const statusColor =
signer.status === 'signed'
? 'text-green-600'
: signer.status === 'declined'
? 'text-red-500'
: 'text-gray-500';
return (
<div key={idx} className="flex items-center gap-2 text-sm">
<StatusIcon
className={`h-4 w-4 ${statusColor}`}
aria-label={statusLabel}
/>
<span className="text-gray-700">{signer.signerName}</span>
<span className="text-gray-400 capitalize">
({signer.signerRole.replace(/_/g, ' ')})
</span>
</div>
);
})}
</div>
)}

View File

@@ -45,9 +45,18 @@ export async function POST(req: NextRequest) {
const ip = clientIp(req);
const rl = await checkRateLimit(ip, rateLimiters.auth);
if (!rl.allowed) {
// H-04: RFC 6585 §4 requires Retry-After on 429 so automated clients
// can back off correctly. rateLimitHeaders only emits the X-RateLimit-*
// triplet; checkRateLimit's helper enforcePublicRateLimit adds this
// header, but this route uses checkRateLimit directly so the header
// has to be added explicitly.
const retryAfter = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
return NextResponse.json(
{ error: { message: 'Too many attempts. Try again later.' } },
{ status: 429, headers: rateLimitHeaders(rl) },
{
status: 429,
headers: { ...rateLimitHeaders(rl), 'Retry-After': String(retryAfter) },
},
);
}

View File

@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { findTemplateIdByEnvelopeId } from '@/lib/services/documenso-client';
import { syncDocumensoTemplate } from '@/lib/services/documenso-template-sync.service';
/**
* POST /api/v1/admin/documenso/sync-template/:templateId
*
* Calls Documenso's GET /template/{id} via the configured per-port creds,
* pre-fills the matching documenso_*_recipient_id settings, and caches the
* field name→ID map at documenso_eoi_field_map for v2 prefillFields usage.
*
* Accepts either a numeric template ID (`123`) or a Documenso 2.x envelope
* ID (`envelope_xxxxxxxx`) — the latter is what the Documenso UI URL shows,
* so paste-from-URL works out of the box on v2 instances. Envelope IDs get
* resolved to their numeric template id via `findTemplateIdByEnvelopeId`
* before the sync runs.
*
* Admin-only via `admin.manage_settings`. Audit-logged through the per-field
* writeSetting calls inside the service.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const raw = params.templateId ?? '';
let templateId: number;
if (/^envelope_/.test(raw)) {
const resolved = await findTemplateIdByEnvelopeId(raw, ctx.portId);
if (!resolved) {
throw new NotFoundError(`Template "${raw}" — no matching envelopeId found`);
}
templateId = resolved;
} else {
templateId = Number(raw);
if (!Number.isInteger(templateId) || templateId <= 0) {
throw new ValidationError(
'templateId must be a positive integer or a Documenso envelopeId (envelope_…)',
);
}
}
const result = await syncDocumensoTemplate(templateId, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync.service';
/**
* GET /api/v1/admin/documenso/sync-template/report
*
* Returns the cached sync result from the most recent successful Sync run,
* so the admin panel's status box survives a page reload without re-hitting
* Documenso. Returns `{ data: null }` when no sync has run for this port.
*
* Admin-only via `admin.manage_settings` — same gate as the sync write
* endpoint, since the report contains template recipient identities and
* AcroForm field names that aren't OK to leak outside the admin surface.
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const report = await getEoiTemplateSyncReport(ctx.portId);
return NextResponse.json({ data: report });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { sendEmail } from '@/lib/email';
import { logger } from '@/lib/logger';
const bodySchema = z.object({
to: z.string().email().optional(),
});
/**
* Fire a test email through the per-port sales SMTP credentials. Used by
* the admin "Test SMTP" button on the Sales email config card to verify
* connectivity / auth without waiting for the next real send to fail.
*
* Sends a small text/HTML message to either the body-supplied `to` or
* (default) the admin's own email so they get the verification in their
* inbox. Returns { ok: true } on success or { ok: false, error } on
* failure — the admin UI rates accordingly.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const body = await parseBody(req, bodySchema);
const recipient = body.to ?? ctx.user.email;
if (!recipient) {
return NextResponse.json(
{ data: { ok: false, error: 'No recipient resolved — sign-in email is empty' } },
{ status: 200 },
);
}
try {
const subject = `Port Nimara CRM — SMTP test (${new Date().toLocaleTimeString()})`;
const html = `<p>Hello,</p><p>This is a test message sent from your CRM's <strong>Sales SMTP</strong> configuration. If you received this, your SMTP credentials work.</p><p style="color:#666;font-size:12px;">Timestamp: ${new Date().toISOString()}</p>`;
const text = `This is a test message sent from your CRM's Sales SMTP configuration. If you received this, your SMTP credentials work.\n\nTimestamp: ${new Date().toISOString()}`;
await sendEmail(recipient, subject, html, undefined, text, ctx.portId);
return NextResponse.json({ data: { ok: true, to: recipient } });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn({ portId: ctx.portId, err: message }, 'Sales SMTP test send failed');
return NextResponse.json({ data: { ok: false, error: message } });
}
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { readSetting, SETTING_KEYS } from '@/lib/services/port-config';
import { fetchWithTimeout, FetchTimeoutError } from '@/lib/fetch-with-timeout';
import { logger } from '@/lib/logger';
/**
* POST /api/v1/admin/embedded-signing/test
*
* Verifies that the configured `embedded_signing_host` (the marketing
* site that hosts the branded embedded-signing wrapper) is reachable
* and returns a 2xx for the test path. Used by the admin "Test
* connection" button on the Documenso settings page so an admin can
* tell whether their marketing-site cutover is ready BEFORE signers
* get sent there from outbound emails.
*
* Two checks:
* 1. Bare host returns 2xx — the site is up.
* 2. `/sign/health` (or `/`) returns 2xx within 5s — soft probe; not
* every marketing site exposes /sign/health, so we degrade to a
* root probe when the dedicated path 404s.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const host = await readSetting<string>(SETTING_KEYS.embeddedSigningHost, ctx.portId);
if (!host) {
return NextResponse.json({
data: {
ok: false,
error: 'No embedded_signing_host configured. Set the URL in Documenso settings first.',
},
});
}
const checked: Array<{ path: string; status?: number; ok: boolean; error?: string }> = [];
const probe = async (path: string) => {
try {
const res = await fetchWithTimeout(`${host.replace(/\/$/, '')}${path}`, {
method: 'GET',
redirect: 'manual',
});
checked.push({
path,
status: res.status,
ok: res.ok || (res.status >= 300 && res.status < 400),
});
return res.status;
} catch (err) {
const msg =
err instanceof FetchTimeoutError
? `timed out after ${err.timeoutMs}ms`
: err instanceof Error
? err.message
: String(err);
checked.push({ path, ok: false, error: msg });
return null;
}
};
// Try root first — it's the most universal signal of "the site is
// up." Then probe /sign/success which the post-signing redirect
// typically points to, so admins can also catch a stale path.
await probe('/');
await probe('/sign/success');
const anyOk = checked.some((c) => c.ok);
if (!anyOk) {
logger.warn({ portId: ctx.portId, host, checked }, 'Embedded signing host probe failed');
}
return NextResponse.json({
data: {
ok: anyOk,
host,
checks: checked,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { copyFromEnv } from '@/lib/settings/resolver';
/**
* POST /api/v1/admin/settings/:key/copy-from-env
*
* One-click migration helper used by the admin form's "Copy from env"
* button. Reads the env var named in the registry entry's `envFallback`
* field and writes it as the current scope's row. Returns `{ copied: false }`
* if the env var is unset / empty.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const result = await copyFromEnv(params.key!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { registryFor } from '@/lib/settings/registry';
import { getSetting } from '@/lib/settings/resolver';
/**
* POST /api/v1/admin/settings/:key/reveal
*
* Returns the decrypted cleartext for an encrypted / sensitive setting.
* Used by the eye-toggle on encrypted fields in the registry-driven admin
* form so the operator can verify what they saved earlier.
*
* Gated on `admin.manage_settings` (the same permission required to write
* the value — so this never widens an existing trust boundary). Every
* reveal is audit-logged with the request id so a super-admin can trace
* who looked at what and when.
*
* Refuses to reveal values resolved from `env` or `default` — those would
* leak server-process secrets via the API.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const key = params.key!;
const entry = registryFor(key);
if (!entry) throw new NotFoundError(`Unknown setting: ${key}`);
if (!entry.encrypted && !entry.sensitive) {
// Non-sensitive values are already returned in the resolved-list
// endpoint, so a dedicated reveal isn't needed (and could be
// misused to bypass observability).
return NextResponse.json({ data: { revealed: false, value: null } }, { status: 200 });
}
// Resolve through the standard chain so the user sees exactly what
// the runtime would. The resolver decrypts on the way out.
const value = await getSetting<string>(key, ctx.portId);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'view',
entityType: 'setting',
entityId: key,
metadata: { settingKey: key, op: 'reveal' },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: { revealed: true, value: value ?? null } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { writeSetting, deleteSetting } from '@/lib/settings/resolver';
const putSchema = z.object({
value: z.unknown(),
});
/**
* PUT /api/v1/admin/settings/:key
*
* Writes a registry-known setting. The resolver validates against the
* entry's Zod schema, encrypts at rest if registered as such, and writes
* an audit log with secrets masked.
*
* Body: { value: <whatever the entry's type accepts> }
*
* Empty / null `value` on a non-sensitive field DELETEs the row (reverts
* to global → env → default). On a sensitive/encrypted field, empty is a
* no-op so an unchanged save through the ••• placeholder doesn't wipe
* the stored ciphertext. Use the DELETE endpoint to explicitly revert.
*/
export const PUT = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
try {
const { value } = await parseBody(req, putSchema);
await writeSetting(params.key!, value, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* DELETE /api/v1/admin/settings/:key
*
* Removes the row, reverting the resolver to global → env → default.
* 404 if no row exists at the appropriate scope.
*/
export const DELETE = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
await deleteSetting(params.key!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { entriesForSections } from '@/lib/settings/registry';
import { resolveForAdminAPI } from '@/lib/settings/resolver';
/**
* GET /api/v1/admin/settings/resolved?sections=documenso.api,documenso.signers
*
* Returns the resolved value + source (port/global/env/default) for every
* registry entry in the requested sections. Drives the registry-driven
* admin form: the `source` field gates the "Using env fallback" badge.
*
* Sensitive fields surface `isSet` only — never the decrypted value.
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const url = new URL(req.url);
const sectionsParam = url.searchParams.get('sections');
if (!sectionsParam) {
return NextResponse.json({ data: { entries: [], values: {} } }, { status: 200 });
}
const sections = sectionsParam
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const entries = entriesForSections(sections);
const keys = entries.map((e) => e.key);
const resolved = await resolveForAdminAPI(keys, ctx.portId);
// Return the entry metadata so the client can render labels/types
// without bundling the registry into the client JS. Strip the
// `validator` + `transform` function references — they're not
// JSON-serializable.
const entriesForClient = entries.map((e) => ({
key: e.key,
section: e.section,
label: e.label,
description: e.description,
type: e.type,
options: e.options,
encrypted: !!e.encrypted,
sensitive: !!(e.sensitive || e.encrypted),
scope: e.scope,
envFallback: e.envFallback,
placeholder: e.placeholder,
defaultValue: e.defaultValue,
}));
const values: Record<string, unknown> = {};
for (const [k, r] of resolved.entries()) {
values[k] = r;
}
return NextResponse.json({ data: { entries: entriesForClient, values } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { user, userPortRoles, userProfiles } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
/**
* GET /api/v1/admin/users/picker
*
* Lightweight list of users in the active port, used by admin form
* user-select dropdowns (e.g. linking a CRM user to a Documenso recipient
* slot). Returns only the fields needed to render an option: id, email,
* name. Excludes deactivated users.
*
* Gated on `admin.manage_settings` — anyone editing per-port admin
* settings can already see all the configured Documenso recipient
* email/name values, so revealing the user roster to them doesn't
* widen the trust boundary. Tighter than the full `admin/users` GET
* (which is `admin.manage_users`-gated).
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const rows = await db
.select({
id: user.id,
email: user.email,
name: user.name,
isActive: userProfiles.isActive,
})
.from(user)
.innerJoin(userPortRoles, eq(userPortRoles.userId, user.id))
.leftJoin(userProfiles, eq(userProfiles.userId, user.id))
.where(and(eq(userPortRoles.portId, ctx.portId)));
// Dedupe by id (a user with multiple role rows in this port would
// otherwise repeat) and drop deactivated profiles.
const seen = new Set<string>();
const data = rows
.filter((r) => r.isActive !== false)
.filter((r) => {
if (seen.has(r.id)) return false;
seen.add(r.id);
return true;
})
.map(({ id, email, name }) => ({ id, email, name }));
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -69,6 +69,7 @@ export async function getMatchCandidatesHandler(
id: clients.id,
fullName: clients.fullName,
nationalityIso: clients.nationalityIso,
archivedAt: clients.archivedAt,
})
.from(clients)
.where(and(eq(clients.portId, ctx.portId)));
@@ -142,6 +143,13 @@ export async function getMatchCandidatesHandler(
interestsByClient.set(r.clientId, (interestsByClient.get(r.clientId) ?? 0) + 1);
}
// Build a lookup from the original pool for archived flag — the dedup
// candidate type intentionally doesn't carry it, but the suggestion card
// needs to differentiate "use this live client" from "restore this
// archived client". Without this the UX swallows soft-deleted dupes.
const archivedById = new Map<string, Date | null>();
for (const c of liveClients) archivedById.set(c.id, c.archivedAt ?? null);
const data = useful.map((m) => ({
clientId: m.candidate.id,
fullName: m.candidate.fullName,
@@ -151,6 +159,7 @@ export async function getMatchCandidatesHandler(
interestCount: interestsByClient.get(m.candidate.id) ?? 0,
emails: m.candidate.emails,
phonesE164: m.candidate.phonesE164,
archivedAt: archivedById.get(m.candidate.id)?.toISOString() ?? null,
}));
return NextResponse.json({ data });

View File

@@ -26,6 +26,7 @@ export const POST = withAuth(
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
{ dimensionUnit: body.dimensionUnit },
);
return NextResponse.json({ data: result }, { status: 201 });
} catch (error) {

View File

@@ -1,18 +1,44 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { cancelDocument } from '@/lib/services/documents.service';
const cancelBodySchema = z
.object({
reason: z.string().max(2000).optional().nullable(),
notifyRecipients: z.array(z.string().uuid()).max(20).optional(),
})
.strict()
.optional();
export const POST = withAuth(
withPermission('documents', 'edit', async (_req, ctx, params) => {
withPermission('documents', 'edit', async (req, ctx, params) => {
try {
const doc = await cancelDocument(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
// Body is optional — legacy callers POST with `{}`. parseBody returns
// null when the request has no body; default to empty options.
let body: z.infer<typeof cancelBodySchema> = undefined;
try {
body = await parseBody(req, cancelBodySchema);
} catch {
body = undefined;
}
const doc = await cancelDocument(
params.id!,
ctx.portId,
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
{
reason: body?.reason ?? null,
notifyRecipients: body?.notifyRecipients ?? [],
},
);
return NextResponse.json({ data: doc });
} catch (error) {
return errorResponse(error);

View File

@@ -54,15 +54,99 @@ export const POST = withAuth(
.where(eq(documentSigners.documentId, documentId))
.orderBy(asc(documentSigners.signingOrder));
const target = body.recipientId
let target = body.recipientId
? signers.find((s) => s.id === body.recipientId)
: signers.find((s) => s.status === 'pending');
if (!target) {
throw new ValidationError('No pending signer found to invite');
}
// Self-heal flow when target.signingUrl is null. Two scenarios:
// 1. Envelope was created before the auto-distribute fix shipped
// — never distributed, so we must call /envelope/distribute
// to mint URLs.
// 2. Envelope WAS auto-distributed at generate time, but the
// response we got didn't carry signingUrls into our DB row
// (transient Documenso bug, or response shape mismatch).
// In that case the envelope is already PENDING and a second
// /distribute call returns 4xx ("already distributed").
//
// Defensive flow: try `getEnvelope` FIRST (cheap, always works).
// If recipients carry signingUrls, persist + skip distribute.
// If not, fall through to distribute, but catch 4xx so we don't
// surface a confusing "Documenso upstream error" to the rep —
// instead we re-fetch via GET one more time and accept whatever
// URLs the envelope has.
if (!target.signingUrl && doc.documensoId) {
const { distributeEnvelopeV2, getDocument } =
await import('@/lib/services/documenso-client');
const persistUrlsForDocument = async (
recipients: Array<{
signingOrder: number;
signingUrl?: string;
embeddedUrl?: string;
token?: string;
}>,
) => {
for (const r of recipients) {
if (!r.signingUrl) continue;
await db
.update(documentSigners)
.set({
signingUrl: r.signingUrl,
embeddedUrl: r.embeddedUrl ?? null,
signingToken: r.token ?? null,
})
.where(
and(
eq(documentSigners.documentId, documentId),
eq(documentSigners.signingOrder, r.signingOrder),
),
);
}
};
// Step 1: cheap GET.
let recovered = false;
try {
const fetched = await getDocument(doc.documensoId, ctx.portId);
if (fetched.recipients.some((r) => r.signingUrl)) {
await persistUrlsForDocument(fetched.recipients);
recovered = true;
}
} catch {
// ignore — fall through to distribute attempt
}
// Step 2: distribute, only if GET didn't recover URLs.
if (!recovered) {
try {
const distributed = await distributeEnvelopeV2(doc.documensoId, ctx.portId);
await persistUrlsForDocument(distributed.recipients);
} catch {
// Probably "already distributed" — last-ditch GET.
try {
const fetched = await getDocument(doc.documensoId, ctx.portId);
await persistUrlsForDocument(fetched.recipients);
} catch {
// give up; the validator below surfaces a clean error
}
}
}
// Re-read target so its signingUrl is now populated.
const refreshed = await db
.select()
.from(documentSigners)
.where(eq(documentSigners.id, target.id))
.limit(1);
target = refreshed[0] ?? target;
}
if (!target.signingUrl) {
throw new ValidationError(
'Signer has no Documenso URL yet — generate or send the document first',
'Signer has no Documenso URL yet — try regenerating the EOI; v2 envelopes require distribution before the signing link exists.',
);
}

View File

@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync.service';
/**
* GET `/api/v1/documents/signing-defaults`
@@ -21,6 +22,21 @@ export const GET = withAuth(
withPermission('documents', 'send_for_signing', async (_req, ctx) => {
try {
const cfg = await getPortDocumensoConfig(ctx.portId);
// Signing order resolution chain (highest → lowest priority):
// 1. Cached `documento_eoi_template_sync_report.templateMeta.signingOrder`
// — populated by the admin "Sync from Documenso" button and
// represents the live template's bound order. On v2 this is the
// authoritative value because `/template/use` doesn't accept a
// per-call override.
// 2. Per-port `documenso_signing_order` setting from
// getPortDocumensoConfig (used by v1 + as a UI fallback when the
// admin hasn't run a sync yet).
// 3. Documenso's own default (`PARALLEL` = concurrent signing).
const syncReport = await getEoiTemplateSyncReport(ctx.portId).catch(() => null);
const signingOrder: 'PARALLEL' | 'SEQUENTIAL' =
syncReport?.templateMeta?.signingOrder ?? cfg.signingOrder ?? 'PARALLEL';
return NextResponse.json({
data: {
developer: {
@@ -34,6 +50,16 @@ export const GET = withAuth(
label: cfg.approverLabel ?? 'Approver',
},
sendMode: cfg.sendMode,
signingOrder,
// Surface where the value came from so the UI tooltip can be
// honest about the source. Helps reps debug "I changed it in
// Documenso but the CRM still says X" — they need to re-run
// Sync to pull the change.
signingOrderSource: syncReport?.templateMeta?.signingOrder
? 'template'
: cfg.signingOrder
? 'port-setting'
: 'default',
},
});
} catch (error) {

View File

@@ -10,7 +10,12 @@ export const PATCH = withAuth(
withPermission('email', 'configure_account', async (req, ctx, params) => {
try {
const body = await parseBody(req, toggleAccountSchema);
const account = await toggleAccount(params.accountId!, ctx.userId, body);
const account = await toggleAccount(params.accountId!, ctx.userId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: account });
} catch (error) {
return errorResponse(error);

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { env } from '@/lib/env';
/**
* GET /api/v1/internal/dev-flags
*
* Read-only feed of dev-mode safety flags that the UI surfaces as
* always-visible badges. Authenticated (any signed-in user) — these
* flags affect every outbound email so reps need to see them too,
* not just admins.
*
* Today returns just `emailRedirectTo`. Add more flags here (e.g.
* MOCK_DOCUMENSO, FAKE_PAYMENTS, READ_ONLY_DB) as they appear.
*/
export const GET = withAuth(async () => {
return NextResponse.json({
data: {
emailRedirectTo: env.EMAIL_REDIRECT_TO ?? null,
isDev: env.NODE_ENV !== 'production',
},
});
});

View File

@@ -14,6 +14,7 @@ import {
FileSignature,
FileText,
FileUp,
GitBranch,
Inbox,
ListChecks,
Mail,
@@ -117,6 +118,14 @@ const GROUPS: AdminGroup[] = [
'API credentials, EOI template, and default in-app vs external signing pathway.',
icon: FileSignature,
},
{
href: 'pipeline-rules',
label: 'Pipeline auto-advance',
description:
'Per-trigger control: which lifecycle events (EOI signed, deposit received, contract signed) auto-advance the deal stage.',
icon: GitBranch,
keywords: ['pipeline', 'auto-advance', 'stage rules', 'aggressive', 'conservative'],
},
{
href: 'reminders',
label: 'Reminders',

View File

@@ -1,6 +1,7 @@
'use client';
import { Activity, Clock, Eye, Pencil, Plus, Trash2, User } from 'lucide-react';
import { useState } from 'react';
import { Activity, ChevronDown, Clock, Eye, Pencil, Plus, Trash2, User } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
@@ -72,8 +73,14 @@ interface AuditLogCardProps {
}
export function AuditLogCard({ entry }: AuditLogCardProps) {
const [expanded, setExpanded] = useState(false);
const accentClass = ACTION_ACCENT[entry.action] ?? 'bg-slate-300';
const badgeColor = ACTION_BADGE_COLORS[entry.action] ?? 'bg-gray-500';
const hasDetail =
Boolean(entry.oldValue) ||
Boolean(entry.newValue) ||
Boolean(entry.metadata) ||
Boolean(entry.userAgent);
const entityTitle = `${entry.entityType.charAt(0).toUpperCase()}${entry.entityType.slice(1)}${
entry.entityId ? ` ${entry.entityId.slice(0, 8)}` : ''
@@ -153,7 +160,78 @@ export function AuditLogCard({ entry }: AuditLogCardProps) {
) : null}
</>
) : null}
{hasDetail ? (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="ml-auto inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
aria-expanded={expanded}
>
<ChevronDown
className={cn(
'h-3 w-3 transition-transform',
expanded ? 'rotate-180' : 'rotate-0',
)}
aria-hidden
/>
{expanded ? 'Hide details' : 'Show details'}
</button>
) : null}
</div>
{expanded && hasDetail ? (
<div className="mt-3 space-y-2 rounded-md border bg-muted/30 p-3 text-xs">
{entry.oldValue ? (
<details>
<summary className="cursor-pointer font-semibold text-muted-foreground">
Old value
</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded bg-background p-2 font-mono text-[11px]">
{JSON.stringify(entry.oldValue, null, 2)}
</pre>
</details>
) : null}
{entry.newValue ? (
<details open>
<summary className="cursor-pointer font-semibold text-muted-foreground">
New value
</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded bg-background p-2 font-mono text-[11px]">
{JSON.stringify(entry.newValue, null, 2)}
</pre>
</details>
) : null}
{entry.metadata ? (
<details>
<summary className="cursor-pointer font-semibold text-muted-foreground">
Metadata
</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded bg-background p-2 font-mono text-[11px]">
{JSON.stringify(entry.metadata, null, 2)}
</pre>
</details>
) : null}
{entry.userAgent || entry.ipAddress ? (
<dl className="grid grid-cols-[120px_1fr] gap-x-2 gap-y-0.5">
{entry.ipAddress ? (
<>
<dt className="font-semibold text-muted-foreground">IP address</dt>
<dd className="font-mono">{entry.ipAddress}</dd>
</>
) : null}
{entry.userAgent ? (
<>
<dt className="font-semibold text-muted-foreground">User agent</dt>
<dd className="truncate font-mono" title={entry.userAgent}>
{entry.userAgent}
</dd>
</>
) : null}
</dl>
) : null}
</div>
) : null}
</div>
</div>
</ListCard>

View File

@@ -3,11 +3,12 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { formatDistanceToNow } from 'date-fns';
import { Search, X } from 'lucide-react';
import { History, Search, X } from 'lucide-react';
import { toast } from 'sonner';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -19,6 +20,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { AuditLogCard } from './audit-log-card';
@@ -85,6 +93,9 @@ const SOURCE_LABEL: Record<string, string> = {
job: 'Job',
};
// L-AU03: entity types that mutations can target but the filter dropdown
// didn't expose. Reps querying the audit log for, e.g., an email-account
// toggle (H-05 fix) couldn't pick it from the dropdown.
const ENTITY_TYPES = [
'client',
'interest',
@@ -99,6 +110,13 @@ const ENTITY_TYPES = [
'setting',
'tag',
'webhook',
'yacht',
'company',
'reservation',
'email_account',
'portal_session',
'portal_user',
'file',
];
function useDebounced<T>(value: T, ms = 300): T {
@@ -129,6 +147,10 @@ export function AuditLogList() {
const [userId, setUserId] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
/** Currently-open audit detail row. Drives the side Sheet that
* exposes the full oldValue / newValue / metadata / IP / UA payload
* so reps can inspect a row without leaving the search list. */
const [detailEntry, setDetailEntry] = useState<AuditEntry | null>(null);
const debouncedSearch = useDebounced(search);
const debouncedUserId = useDebounced(userId);
@@ -335,6 +357,27 @@ export function AuditLogList() {
),
size: 130,
},
{
id: 'details',
header: '',
cell: ({ row }) => {
const e = row.original;
const hasDetail =
Boolean(e.oldValue) || Boolean(e.newValue) || Boolean(e.metadata) || Boolean(e.userAgent);
if (!hasDetail) return null;
return (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setDetailEntry(e)}
>
Details
</Button>
);
},
size: 80,
},
];
return (
@@ -359,7 +402,7 @@ export function AuditLogList() {
<Input
id="audit-search"
className="pl-9 h-9"
placeholder="entity id, action, vendor…"
placeholder="entity id, entity type, action, user id…"
value={search}
onChange={(e) => setSearch(e.target.value)}
data-testid="audit-search"
@@ -412,6 +455,22 @@ export function AuditLogList() {
<SelectItem value="webhook_retried">Webhook retried</SelectItem>
<SelectItem value="job_failed">Job failed</SelectItem>
<SelectItem value="cron_run">Cron run</SelectItem>
{/* L-AU02: actions that fire in the code but were missing from
the dropdown — reps couldn't filter on them. */}
<SelectItem value="password_change">Password change</SelectItem>
<SelectItem value="portal_invite">Portal invite</SelectItem>
<SelectItem value="portal_activate">Portal activate</SelectItem>
<SelectItem value="portal_password_reset_request">Portal reset req</SelectItem>
<SelectItem value="portal_password_reset">Portal reset</SelectItem>
<SelectItem value="revoke_invite">Revoke invite</SelectItem>
<SelectItem value="resend_invite">Resend invite</SelectItem>
<SelectItem value="request_gdpr_export">GDPR req</SelectItem>
<SelectItem value="send_gdpr_export">GDPR sent</SelectItem>
<SelectItem value="rule_evaluated">Rule evaluated</SelectItem>
<SelectItem value="outcome_set">Outcome set</SelectItem>
<SelectItem value="outcome_cleared">Outcome cleared</SelectItem>
<SelectItem value="branding.logo.uploaded">Logo uploaded</SelectItem>
<SelectItem value="branding.logo.archived">Logo archived</SelectItem>
</SelectContent>
</Select>
</div>
@@ -522,9 +581,15 @@ export function AuditLogList() {
virtualHeightPx={640}
virtualRowHeightPx={56}
emptyState={
<div className="text-center py-8">
<p className="text-muted-foreground">No audit log entries found.</p>
</div>
<EmptyState
icon={History}
title="No audit log entries"
description={
hasActiveFilter
? 'No entries match the current filters. Try clearing them.'
: 'Activity will appear here once users start making changes.'
}
/>
}
/>
</div>
@@ -543,6 +608,73 @@ export function AuditLogList() {
</Button>
</div>
) : null}
<Sheet open={!!detailEntry} onOpenChange={(o) => !o && setDetailEntry(null)}>
<SheetContent side="right" className="overflow-y-auto sm:max-w-xl">
{detailEntry ? (
<>
<SheetHeader>
<SheetTitle>
{detailEntry.action.replace(/_/g, ' ')} {detailEntry.entityType}
</SheetTitle>
<SheetDescription>
{new Date(detailEntry.createdAt).toLocaleString()}
{detailEntry.actor ? ` · ${detailEntry.actor.name}` : ''}
</SheetDescription>
</SheetHeader>
<div className="space-y-4 pt-4 text-sm">
{detailEntry.oldValue ? (
<details>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Old value
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.oldValue, null, 2)}
</pre>
</details>
) : null}
{detailEntry.newValue ? (
<details open>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
New value
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.newValue, null, 2)}
</pre>
</details>
) : null}
{detailEntry.metadata ? (
<details>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Metadata
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.metadata, null, 2)}
</pre>
</details>
) : null}
{detailEntry.ipAddress || detailEntry.userAgent ? (
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1 text-xs">
{detailEntry.ipAddress ? (
<>
<dt className="font-semibold text-muted-foreground">IP address</dt>
<dd className="font-mono">{detailEntry.ipAddress}</dd>
</>
) : null}
{detailEntry.userAgent ? (
<>
<dt className="font-semibold text-muted-foreground">User agent</dt>
<dd className="font-mono break-all">{detailEntry.userAgent}</dd>
</>
) : null}
</dl>
) : null}
</div>
</>
) : null}
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -0,0 +1,260 @@
'use client';
import { useState } from 'react';
import { CheckCircle2, HelpCircle, Loader2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface TestResult {
ok: boolean;
host?: string;
checks?: Array<{ path: string; status?: number; ok: boolean; error?: string }>;
error?: string;
at: Date;
}
const EMBED_FIELDS: SettingFieldDef[] = [
{
key: 'embedded_signing_host',
label: 'Embedded signing host',
description:
"Origin of the public site that hosts the embedded Documenso signing pages. Outbound emails wrap raw Documenso signing URLs into {host}/sign/<type>/<token> so clients sign on your branded page rather than Documenso's domain. Leave blank to fall back to the app URL. Marketing-website pattern: https://portnimara.com",
type: 'string',
placeholder: 'https://portnimara.com',
defaultValue: '',
},
];
/**
* Admin card for the embedded-signing host setting. Provides:
* - The setting field itself (via SettingsFormCard)
* - A Test connection button that probes the host's `/` and
* `/sign/success` paths to verify the marketing-site cutover is
* ready BEFORE signers get sent there from outbound emails.
* - A Help button that opens a Sheet with the setup instructions —
* what routes the marketing site needs, what URL parameters to
* handle, and the Documenso webhook config that pairs with it.
*/
export function EmbeddedSigningCard() {
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<TestResult | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
const handleTest = async () => {
setTesting(true);
setResult(null);
try {
const res = (await apiFetch('/api/v1/admin/embedded-signing/test', {
method: 'POST',
body: {},
})) as {
data: {
ok: boolean;
host?: string;
error?: string;
checks?: Array<{ path: string; status?: number; ok: boolean; error?: string }>;
};
};
setResult({ ...res.data, at: new Date() });
if (res.data.ok) toast.success('Embedded signing host reachable.');
else toast.error('Embedded signing host probe failed — see card.');
} catch (err) {
toastError(err);
setResult({
ok: false,
error: err instanceof Error ? err.message : String(err),
at: new Date(),
});
} finally {
setTesting(false);
}
};
return (
<>
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div>
<CardTitle>Embedded signing</CardTitle>
<CardDescription>
Where the public-facing branded signing pages live. The CRM rewrites Documenso
signing URLs to point here when sending invitation and reminder emails.
</CardDescription>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setHelpOpen(true)}
className="gap-1.5 [&_svg]:size-3.5"
>
<HelpCircle />
Setup help
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Renders inside our outer Card with its own micro-header.
Title kept terse (empty string would look broken) so the
user still has a visual anchor for the field. */}
<SettingsFormCard title="Host URL" description="" fields={EMBED_FIELDS} />
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={handleTest}
disabled={testing}
className="gap-1.5 [&_svg]:size-3.5"
>
{testing ? <Loader2 className="animate-spin" aria-hidden /> : null}
Test connection
</Button>
<p className="text-xs text-muted-foreground">
Probes <code>/</code> and <code>/sign/success</code> on the configured host.
</p>
</div>
{result ? (
<div
className={`rounded-md border p-3 text-sm ${
result.ok
? 'border-emerald-200 bg-emerald-50 text-emerald-900'
: 'border-rose-200 bg-rose-50 text-rose-900'
}`}
>
<div className="flex items-start gap-2">
{result.ok ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
) : (
<XCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
)}
<div className="flex-1">
<p className="font-medium">{result.ok ? 'Connection ok' : 'Connection failed'}</p>
{result.host ? (
<p className="text-xs">
Host: <code>{result.host}</code>
</p>
) : null}
{result.error ? <p className="text-xs">{result.error}</p> : null}
{result.checks ? (
<ul className="mt-1 space-y-0.5 text-xs">
{result.checks.map((c) => (
<li key={c.path}>
<code>{c.path}</code> {' '}
{c.ok ? (
<span className="text-emerald-800">{c.status ?? 'ok'}</span>
) : (
<span className="text-rose-800">
{c.status ? `${c.status} fail` : (c.error ?? 'fail')}
</span>
)}
</li>
))}
</ul>
) : null}
<p className="mt-1 text-[11px] opacity-70">{result.at.toLocaleTimeString()}</p>
</div>
</div>
</div>
) : null}
</CardContent>
</Card>
<Sheet open={helpOpen} onOpenChange={setHelpOpen}>
<SheetContent side="right" className="overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle>Set up embedded signing</SheetTitle>
<SheetDescription>
How the marketing site has to be wired up so the branded signing flow works.
</SheetDescription>
</SheetHeader>
<div className="space-y-4 pt-4 text-sm leading-6">
<section>
<h3 className="mb-1 font-semibold">1. Choose the host</h3>
<p className="text-muted-foreground">
Pick a public host (e.g. <code>https://portnimara.com</code>) and enter it in the
Embedded signing host field above. The CRM rewrites raw Documenso signing URLs into{' '}
<code>{'{host}/sign/<role>/<token>'}</code> for every outbound invitation + reminder
email.
</p>
</section>
<section>
<h3 className="mb-1 font-semibold">2. Implement the signing route</h3>
<p className="text-muted-foreground">
The marketing site needs to handle <code>/sign/[role]/[token]</code> by forwarding
to the underlying Documenso signing URL (or embedding it in an iframe). Role is one
of <code>client</code> / <code>developer</code> / <code>approver</code> useful for
tracking which slot the signer is in.
</p>
<p className="mt-1 text-muted-foreground">Minimum Next.js example:</p>
<pre className="mt-1 overflow-x-auto rounded bg-muted p-2 font-mono text-[11px]">
{`// app/sign/[role]/[token]/page.tsx
export default function SignPage({ params }) {
const documenseUrl = \`\${env.DOCUMENSO_URL}/sign/\${params.token}\`;
return <iframe src={documenseUrl} className="w-full h-screen" />;
}`}
</pre>
</section>
<section>
<h3 className="mb-1 font-semibold">3. Implement the success route</h3>
<p className="text-muted-foreground">
After signing, Documenso redirects to the URL configured in{' '}
<strong>Post-sign redirect URL</strong>. Default points at{' '}
<code>{'{host}/sign/success'}</code>. Render a confirmation page there (the
signer&apos;s already done; this is just the friendly &ldquo;Thanks!&rdquo; UX).
</p>
</section>
<section>
<h3 className="mb-1 font-semibold">4. Test the connection</h3>
<p className="text-muted-foreground">
Use the Test connection button to verify <code>/</code> and{' '}
<code>/sign/success</code> return 2xx. If either fails, the marketing site
isn&apos;t ready fix the route before flipping live or signers will land on a 404
page from outbound emails.
</p>
</section>
<section>
<h3 className="mb-1 font-semibold">5. Pair the Documenso webhook</h3>
<p className="text-muted-foreground">
Make sure the Documenso webhook points at{' '}
<code>{'{appUrl}/api/webhooks/documenso'}</code> with the matching webhook secret
stored under Documenso API Webhook secret. Without this the EOI status never
updates after signing.
</p>
</section>
<section>
<h3 className="mb-1 font-semibold">6. Cutover</h3>
<p className="text-muted-foreground">
Flip the Embedded signing host field to your live URL and save. Existing in-flight
EOIs keep their pre-cutover signing URLs (the rewrite happens at email-dispatch
time, not at envelope creation), so old signers can still complete on the old host
until they sign or the EOI is cancelled.
</p>
</section>
</div>
</SheetContent>
</Sheet>
</>
);
}

View File

@@ -0,0 +1,437 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Download, Loader2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface SyncRecipient {
role: string;
signingOrder: number;
id: number;
name?: string;
email?: string;
mappedSettingKey: string | null;
}
interface AcroFormReport {
envelopeItemId: string;
fields: Array<{ name: string; type: string }>;
matchedFieldNames: string[];
missingFieldNames: string[];
extraFieldNames: string[];
error?: string;
}
interface SyncResult {
syncedAt: string;
templateId: number;
title: string;
recipients: SyncRecipient[];
fieldCount: number;
matchedFields: Array<{ label: string; fieldId: number }>;
unmatchedTemplateFields: Array<{ label: string; fieldId: number }>;
missingFromTemplate: string[];
templateMeta?: {
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
distributionMethod: 'EMAIL' | 'NONE' | null;
redirectUrl: string | null;
};
acroForm: AcroFormReport[];
}
function formatRelative(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms) || ms < 0) return new Date(iso).toLocaleString();
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
if (day < 30) return `${day}d ago`;
return new Date(iso).toLocaleDateString();
}
/**
* "Sync from Documenso" admin button — calls GET /template/{id} on the
* configured Documenso instance (via the per-port creds in admin settings),
* pre-fills the recipient slot IDs into the matching documenso_*_recipient_id
* settings, and caches the template's field name→ID map at
* `documenso_eoi_field_map` for v2 prefillFields usage at send time.
*
* Saves the operator from typing 4 numeric IDs by hand and (in v2 mode)
* eliminates the "renaming a field on Documenso silently breaks the EOI"
* class of bug.
*/
export function TemplateSyncButton() {
const queryClient = useQueryClient();
const [templateIdInput, setTemplateIdInput] = useState('');
const [lastResult, setLastResult] = useState<SyncResult | null>(null);
// Seed the result panel from the cached report so the status survives
// page reloads. A subsequent Sync click overwrites both this cache and
// the local state.
const cached = useQuery<{ data: SyncResult | null }>({
queryKey: ['documenso', 'sync-template', 'report'],
queryFn: () =>
apiFetch<{ data: SyncResult | null }>('/api/v1/admin/documenso/sync-template/report'),
staleTime: 60_000,
});
useEffect(() => {
if (!lastResult && cached.data?.data) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLastResult(cached.data.data);
setTemplateIdInput(String(cached.data.data.templateId));
}
}, [cached.data, lastResult]);
const sync = useMutation({
mutationFn: async (templateIdOrEnvelopeId: string) => {
const r = await apiFetch<{ data: SyncResult }>(
`/api/v1/admin/documenso/sync-template/${encodeURIComponent(templateIdOrEnvelopeId)}`,
{ method: 'POST' },
);
return r.data;
},
onSuccess: (result) => {
setLastResult(result);
toast.success(
`Synced "${result.title}" — ${result.recipients.length} recipients, ${result.fieldCount} fields cached`,
);
void queryClient.invalidateQueries({ queryKey: ['settings', 'resolved'] });
void queryClient.invalidateQueries({
queryKey: ['documenso', 'sync-template', 'report'],
});
},
onError: (err) => toastError(err, 'Template sync failed'),
});
const submit = () => {
const raw = templateIdInput.trim();
if (!raw) {
toast.error('Enter a template ID (number) or envelope ID (envelope_…)');
return;
}
// Accept either a numeric template ID or a Documenso 2.x envelope ID.
// The server resolves envelope_xxx → numeric id via the list endpoint.
const isNumeric = /^\d+$/.test(raw);
const isEnvelopeId = /^envelope_[a-z0-9]+$/i.test(raw);
if (!isNumeric && !isEnvelopeId) {
toast.error('Enter a positive integer or a Documenso envelopeId (envelope_…)');
return;
}
sync.mutate(raw);
};
return (
<div className="rounded-md border bg-card p-4 space-y-4">
<div>
<div className="text-sm font-medium">Sync from Documenso</div>
<p className="mt-1 text-xs text-muted-foreground">
Paste either a numeric template ID (<code>123</code>) or the <code>envelope_</code>{' '}
string from your Documenso template URL (e.g. <code>envelope_nfafbkihzhoaihkb</code>). The
CRM fetches the template via <code>GET /template/&#123;id&#125;</code> on the currently
configured Documenso instance, writes the discovered recipient IDs into the slots above,
and caches the field nameID map for v2 <code>prefillFields</code> at send time.
</p>
</div>
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1.5">
<Label htmlFor="documenso-sync-template-id" className="text-xs">
Template ID or envelope ID
</Label>
<Input
id="documenso-sync-template-id"
type="text"
placeholder="123 or envelope_xxxxxxxx"
value={templateIdInput}
onChange={(e) => setTemplateIdInput(e.target.value)}
disabled={sync.isPending}
/>
</div>
<Button onClick={submit} disabled={sync.isPending}>
{sync.isPending ? (
<>
<Loader2 className="mr-2 size-3 animate-spin" /> Syncing
</>
) : (
<>
<Download className="mr-2 size-3" /> Sync
</>
)}
</Button>
</div>
{lastResult && (
<div className="rounded-md border border-emerald-200 bg-emerald-50/60 p-3 text-sm dark:border-emerald-900/40 dark:bg-emerald-950/30">
<div className="flex items-center justify-between gap-2 font-medium text-emerald-700 dark:text-emerald-400">
<div className="flex items-center gap-2">
<CheckCircle2 className="size-4" />{' '}
{lastResult.title || `Template #${lastResult.templateId}`}
</div>
<span className="text-[11px] font-normal text-muted-foreground">
Last synced {formatRelative(lastResult.syncedAt)}
</span>
</div>
<div className="mt-2 space-y-1 text-xs">
<div className="font-medium text-muted-foreground">
Recipients ({lastResult.recipients.length})
</div>
<ul className="space-y-0.5">
{lastResult.recipients.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="font-mono text-xs">#{r.id}</span>
<span className="text-muted-foreground">·</span>
<span>
{r.role} (order {r.signingOrder})
</span>
{r.name && (
<>
<span className="text-muted-foreground">·</span>
<span>{r.name}</span>
</>
)}
{r.mappedSettingKey ? (
<span className="ml-auto rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
{r.mappedSettingKey}
</span>
) : (
<span className="ml-auto rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-950 dark:text-amber-300">
no slot match
</span>
)}
</li>
))}
</ul>
{lastResult.templateMeta && (
<div className="pt-1.5 rounded-md bg-muted/60 px-2 py-1.5">
<div className="font-medium text-muted-foreground">Template-level settings</div>
<p className="text-[11px] text-muted-foreground">
Read from the template itself on Documenso. These values are bound to the
template, so every envelope generated from it inherits them {' '}
<code>/template/use</code> does <strong>not</strong> accept overrides for these.
Change them in Documenso&apos;s template editor.
</p>
<ul className="mt-1 space-y-0.5 text-[11px]">
<li>
<span className="text-muted-foreground">Signing order:</span>{' '}
<span className="font-mono">
{lastResult.templateMeta.signingOrder ?? 'unset'}
</span>
</li>
<li>
<span className="text-muted-foreground">Distribution method:</span>{' '}
<span className="font-mono">
{lastResult.templateMeta.distributionMethod ?? 'unset'}
</span>
{lastResult.templateMeta.distributionMethod === 'EMAIL' && (
<span className="ml-1 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-900 dark:bg-amber-950 dark:text-amber-200">
Documenso will email recipients directly the CRM&apos;s branded email
is in addition. Set to NONE on the template to let the CRM be the sole
sender.
</span>
)}
</li>
<li>
<span className="text-muted-foreground">Post-sign redirect:</span>{' '}
<span className="font-mono">
{lastResult.templateMeta.redirectUrl ?? '(none)'}
</span>
</li>
</ul>
</div>
)}
<div className="pt-1 font-medium text-muted-foreground">
Fields: {lastResult.fieldCount} cached for <code>prefillFields</code>
{lastResult.fieldCount === 0 && (
<span className="ml-1 font-normal text-muted-foreground">
that&apos;s fine if your template is a fillable PDF (AcroForm). The CRM will
fill it via <code>formValues</code>-by-name instead, same as on v1.{' '}
<code>prefillFields</code> is only needed if you placed field overlays directly in
the Documenso template editor.
</span>
)}
</div>
{lastResult.matchedFields.length > 0 && (
<div className="pt-1.5">
<div className="font-medium text-emerald-700 dark:text-emerald-400">
CRM will fill ({lastResult.matchedFields.length})
</div>
<div className="flex flex-wrap gap-1.5 pt-1">
{lastResult.matchedFields.map((f) => (
<span
key={f.fieldId}
className="rounded bg-emerald-100 px-1.5 py-0.5 font-mono text-[10px] text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200"
>
{f.label} #{f.fieldId}
</span>
))}
</div>
</div>
)}
{lastResult.unmatchedTemplateFields.length > 0 && (
<div className="pt-1.5">
<div className="font-medium text-amber-700 dark:text-amber-400">
Template fields the CRM doesn&apos;t recognize (
{lastResult.unmatchedTemplateFields.length})
</div>
<p className="text-[11px] text-muted-foreground">
These won&apos;t be filled. Rename them in the Documenso template editor to match
a CRM-expected label (Name, Email, Address, Yacht Name, Length, Width, Draft,
Berth Number, Lease_10, Purchase), or ignore if they&apos;re signature/date fields
the recipient fills in themselves.
</p>
<div className="flex flex-wrap gap-1.5 pt-1">
{lastResult.unmatchedTemplateFields.map((f) => (
<span
key={f.fieldId}
className="rounded bg-amber-100 px-1.5 py-0.5 font-mono text-[10px] text-amber-900 dark:bg-amber-950 dark:text-amber-200"
>
{f.label} #{f.fieldId}
</span>
))}
</div>
</div>
)}
{lastResult.acroForm.length > 0 && (
<div className="pt-2.5 border-t border-emerald-200/60 dark:border-emerald-900/40">
<div className="font-medium text-foreground">
PDF AcroForm fields (the <code>formValues</code> path)
</div>
<p className="pt-0.5 text-[11px] text-muted-foreground">
These are the fillable fields actually in the PDF binary on Documenso. The CRM
fills them by name at send time this is the same mechanism the prod v1 server
uses.
</p>
{lastResult.acroForm.map((report) => (
<div key={report.envelopeItemId} className="mt-1.5 space-y-1">
{report.error ? (
<div className="rounded bg-destructive/10 px-2 py-1 text-[11px] text-destructive">
Couldn&apos;t inspect this PDF: {report.error}
</div>
) : report.fields.length === 0 ? (
<div className="rounded bg-amber-100 px-2 py-1 text-[11px] text-amber-900 dark:bg-amber-950 dark:text-amber-200">
This PDF has no AcroForm fields. The CRM&apos;s <code>formValues</code>{' '}
path will fill nothing. Re-export your PDF with form fields enabled, or
place overlays inside Documenso&apos;s editor and use{' '}
<code>prefillFields</code> instead.
</div>
) : (
<>
{report.matchedFieldNames.length > 0 && (
<div>
<div className="font-medium text-emerald-700 dark:text-emerald-400">
CRM-fillable AcroForm fields ({report.matchedFieldNames.length})
</div>
<div className="flex flex-wrap gap-1.5 pt-0.5">
{report.matchedFieldNames.map((n) => (
<span
key={n}
className="rounded bg-emerald-100 px-1.5 py-0.5 font-mono text-[10px] text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200"
>
{n}
</span>
))}
</div>
</div>
)}
{report.missingFieldNames.length > 0 && (
<div>
<div className="font-medium text-amber-700 dark:text-amber-400">
CRM tokens missing from the PDF ({report.missingFieldNames.length})
</div>
<p className="text-[11px] text-muted-foreground">
These exact names need AcroForm text/checkbox fields in the PDF, or
they&apos;ll be dropped at send time.
</p>
<div className="flex flex-wrap gap-1.5 pt-0.5">
{report.missingFieldNames.map((n) => (
<span
key={n}
className="rounded bg-amber-100 px-1.5 py-0.5 font-mono text-[10px] text-amber-900 dark:bg-amber-950 dark:text-amber-200"
>
{n}
</span>
))}
</div>
</div>
)}
{report.extraFieldNames.length > 0 && (
<div>
<div className="font-medium text-muted-foreground">
PDF fields the CRM has no token for ({report.extraFieldNames.length})
</div>
<p className="text-[11px] text-muted-foreground">
Usually signature blocks or other fields the recipient fills in
directly. Safe to ignore.
</p>
<div className="flex flex-wrap gap-1.5 pt-0.5">
{report.extraFieldNames.map((n) => (
<span
key={n}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
>
{n}
</span>
))}
</div>
</div>
)}
</>
)}
</div>
))}
</div>
)}
{lastResult.fieldCount > 0 && lastResult.missingFromTemplate.length > 0 && (
<div className="pt-1.5">
<div className="font-medium text-muted-foreground">
CRM data points not in <code>prefillFields</code> (
{lastResult.missingFromTemplate.length})
</div>
<p className="text-[11px] text-muted-foreground">
These would also be available as <code>prefillFields</code> if you added matching
overlays inside Documenso&apos;s template editor.
</p>
<div className="flex flex-wrap gap-1.5 pt-1">
{lastResult.missingFromTemplate.map((label) => (
<span
key={label}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
>
{label}
</span>
))}
</div>
</div>
)}
</div>
</div>
)}
{sync.isError && !lastResult && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs">
<div className="flex items-center gap-2 font-medium text-destructive">
<XCircle className="size-3" /> Sync failed check the Documenso credentials above and
confirm the template exists on the configured instance.
</div>
</div>
)}
</div>
);
}

View File

@@ -66,7 +66,7 @@ export function EmailRoutingCard() {
mutationFn: (routing: Record<string, Sender>) =>
apiFetch<RoutingResponse>('/api/v1/admin/email/routing', {
method: 'PATCH',
body: JSON.stringify({ routing }),
body: { routing },
}),
onSuccess: (resp) => {
qc.setQueryData(['admin', 'email', 'routing'], resp);

View File

@@ -90,6 +90,7 @@ export function FormTemplateList() {
<Button
variant="ghost"
size="icon"
aria-label="Edit form template"
onClick={() => {
setEditing(t);
setFormOpen(true);
@@ -99,7 +100,12 @@ export function FormTemplateList() {
</Button>
<ConfirmationDialog
trigger={
<Button variant="ghost" size="icon" className="text-destructive">
<Button
variant="ghost"
size="icon"
className="text-destructive"
aria-label="Delete form template"
>
<Trash2 className="h-4 w-4" aria-hidden />
</Button>
}

View File

@@ -13,7 +13,7 @@
* wire means "clear".
*/
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -94,6 +94,8 @@ const EMPTY_FORM: FormState = {
export function SalesEmailConfigCard() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [lastTest, setLastTest] = useState<{ ok: boolean; message: string; at: Date } | null>(null);
const [smtpPassSet, setSmtpPassSet] = useState(false);
const [imapPassSet, setImapPassSet] = useState(false);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
@@ -136,6 +138,38 @@ export function SalesEmailConfigCard() {
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleTestSmtp() {
setTesting(true);
setLastTest(null);
try {
const res = (await apiFetch('/api/v1/admin/email/sales-config/test-smtp', {
method: 'POST',
body: {},
})) as { data: { ok: boolean; to?: string; error?: string } };
if (res.data.ok) {
setLastTest({
ok: true,
message: `Test email sent to ${res.data.to ?? 'your inbox'}. Check delivery.`,
at: new Date(),
});
toast.success('Test SMTP send queued.');
} else {
setLastTest({
ok: false,
message: res.data.error ?? 'Unknown error',
at: new Date(),
});
toast.error('SMTP test failed — see card for details.');
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setLastTest({ ok: false, message: msg, at: new Date() });
toastError(err);
} finally {
setTesting(false);
}
}
async function handleSave() {
setSaving(true);
try {
@@ -385,7 +419,37 @@ export function SalesEmailConfigCard() {
</CardContent>
</Card>
<div className="flex justify-end">
{lastTest ? (
<div
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${
lastTest.ok
? 'border-emerald-200 bg-emerald-50 text-emerald-900'
: 'border-rose-200 bg-rose-50 text-rose-900'
}`}
>
{lastTest.ok ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
) : (
<XCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
)}
<div className="flex-1">
<p className="font-medium">{lastTest.ok ? 'SMTP test sent' : 'SMTP test failed'}</p>
<p className="text-xs">{lastTest.message}</p>
<p className="mt-0.5 text-[11px] opacity-70">{lastTest.at.toLocaleTimeString()}</p>
</div>
</div>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
onClick={handleTestSmtp}
disabled={testing || saving}
title="Send a test message to your account via the configured SMTP credentials."
>
{testing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
Test SMTP
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />}
Save sales email settings

View File

@@ -0,0 +1,608 @@
'use client';
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Download, Eye, EyeOff, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type SettingType =
| 'string'
| 'password'
| 'number'
| 'boolean'
| 'select'
| 'url'
| 'email'
| 'textarea'
| 'user-select';
type SettingSource = 'port' | 'global' | 'env' | 'default';
interface RegistryClientEntry {
key: string;
section: string;
label: string;
description: string;
type: SettingType;
options?: Array<{ value: string; label: string }>;
encrypted: boolean;
sensitive: boolean;
scope: 'port' | 'global';
envFallback?: string;
placeholder?: string;
defaultValue?: string | number | boolean | null;
}
interface ResolvedValue {
key: string;
source: SettingSource;
isSet: boolean;
value?: unknown;
}
interface ResolvedResponse {
data: { entries: RegistryClientEntry[]; values: Record<string, ResolvedValue> };
}
interface Props {
/** Section names from the registry to render (e.g. ['documenso.api', 'documenso.signers']). */
sections: string[];
/** Card-level title; omit to render fields without a card wrapper. */
title?: string;
/** Card-level description. */
description?: string;
/** Optional slot below the form (e.g. test-connection button). */
extra?: ReactNode;
}
/**
* Generates an editable settings form from the central registry. Renders the
* "Using env fallback" badge on each field whose resolved source is `env`
* (or `default`), plus a "Copy from env" button when an env value exists to
* one-click migrate the value into the admin DB.
*
* Encrypted / sensitive fields show ••• placeholder text and never receive
* the actual cleartext from the server. Saving an empty value on these
* fields is a no-op (use the explicit DELETE button to revert).
*/
export function RegistryDrivenForm({ sections, title, description, extra }: Props) {
const queryKey = ['settings', 'resolved', ...sections];
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<ResolvedResponse>({
queryKey,
queryFn: () =>
apiFetch<ResolvedResponse>(
`/api/v1/admin/settings/resolved?sections=${sections.map(encodeURIComponent).join(',')}`,
),
});
// Lifted draft state — every field's current input value is held here so
// a card-level "Save N changes" button can write them all in one batch.
// Sensitive fields seed as empty (we never seed cleartext from the server);
// non-sensitive fields seed from the resolved value.
const [drafts, setDrafts] = useState<Record<string, unknown>>({});
// A field is "dirty" only after the operator types into it. Server-driven
// events (eye-toggle reveal, copy-from-env autofill) explicitly clear the
// dirty flag for that key so they don't trigger a phantom save.
const [dirtyKeys, setDirtyKeys] = useState<Set<string>>(() => new Set());
// Re-seed drafts whenever the resolved-values query refreshes (after a
// successful save, revert, or copy-from-env) so values reflect server
// state. Preserves any in-progress edits the user is making.
useEffect(() => {
if (!data) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setDrafts((prev) => {
const next = { ...prev };
for (const entry of data.data.entries) {
if (dirtyKeys.has(entry.key)) continue; // don't trample in-progress edits
if (entry.encrypted || entry.sensitive) {
next[entry.key] = '';
} else {
next[entry.key] = data.data.values[entry.key]?.value ?? '';
}
}
return next;
});
}, [data, dirtyKeys]);
const setDraft = useCallback((key: string, value: unknown, opts?: { dirty?: boolean }) => {
setDrafts((prev) => ({ ...prev, [key]: value }));
if (opts?.dirty !== undefined) {
setDirtyKeys((prev) => {
const next = new Set(prev);
if (opts.dirty) next.add(key);
else next.delete(key);
return next;
});
}
}, []);
// Card-level bulk save. Fires one PUT per dirty field in parallel so the
// common case ("admin tweaks five fields, hits Save") is one round-trip
// worth of latency rather than five. Partial failures are surfaced
// per-field via toast; the resolved-values query gets invalidated once
// even on partial success so the UI reflects what landed.
const saveAll = useMutation({
mutationFn: async () => {
if (!data)
return { succeeded: [] as string[], failed: [] as Array<{ key: string; error: unknown }> };
const dirty = Array.from(dirtyKeys);
const settled = await Promise.allSettled(
dirty.map(async (key) => {
await apiFetch(`/api/v1/admin/settings/${encodeURIComponent(key)}`, {
method: 'PUT',
body: { value: drafts[key] },
});
return key;
}),
);
const succeeded: string[] = [];
const failed: Array<{ key: string; error: unknown }> = [];
settled.forEach((r, i) => {
const key = dirty[i]!;
if (r.status === 'fulfilled') succeeded.push(key);
else failed.push({ key, error: r.reason });
});
return { succeeded, failed };
},
onSuccess: ({ succeeded, failed }) => {
// Clear dirty flags for the keys that landed; leave failed ones dirty
// so the operator can fix + retry.
if (succeeded.length > 0) {
setDirtyKeys((prev) => {
const next = new Set(prev);
for (const k of succeeded) next.delete(k);
return next;
});
toast.success(
succeeded.length === 1 ? `Saved 1 setting` : `Saved ${succeeded.length} settings`,
);
}
for (const f of failed) {
const label = data?.data.entries.find((e) => e.key === f.key)?.label ?? f.key;
toastError(f.error, `Failed to save ${label}`);
}
void queryClient.invalidateQueries({ queryKey });
},
onError: (err) => toastError(err, 'Failed to save settings'),
});
const dirtyCount = dirtyKeys.size;
const content = (
<div className="space-y-6">
{isLoading || !data ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : (
<>
{groupBySection(data.data.entries).map(([section, entries]) => (
<SectionGroup
key={section}
entries={entries}
values={data.data.values}
drafts={drafts}
setDraft={setDraft}
onResolvedRefresh={() => queryClient.invalidateQueries({ queryKey })}
/>
))}
<div className="flex items-center justify-between gap-3 border-t pt-4">
<div className="text-xs text-muted-foreground">
{dirtyCount === 0
? 'No unsaved changes.'
: dirtyCount === 1
? '1 unsaved change.'
: `${dirtyCount} unsaved changes.`}
</div>
<Button
onClick={() => saveAll.mutate()}
disabled={saveAll.isPending || dirtyCount === 0}
>
{saveAll.isPending ? (
<>
<Loader2 className="mr-1 size-3 animate-spin" /> Saving
</>
) : dirtyCount > 0 ? (
`Save ${dirtyCount} change${dirtyCount === 1 ? '' : 's'}`
) : (
'Save'
)}
</Button>
</div>
</>
)}
{extra ? <div className="pt-2">{extra}</div> : null}
</div>
);
if (!title) return content;
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</CardHeader>
<CardContent>{content}</CardContent>
</Card>
);
}
function groupBySection(entries: RegistryClientEntry[]): Array<[string, RegistryClientEntry[]]> {
const map = new Map<string, RegistryClientEntry[]>();
for (const e of entries) {
const existing = map.get(e.section);
if (existing) existing.push(e);
else map.set(e.section, [e]);
}
return Array.from(map.entries());
}
function SectionGroup({
entries,
values,
drafts,
setDraft,
onResolvedRefresh,
}: {
entries: RegistryClientEntry[];
values: Record<string, ResolvedValue>;
drafts: Record<string, unknown>;
setDraft: (key: string, value: unknown, opts?: { dirty?: boolean }) => void;
onResolvedRefresh: () => void;
}) {
return (
<div className="space-y-4">
{entries.map((entry) => (
<SettingField
key={entry.key}
entry={entry}
resolved={values[entry.key]}
draft={drafts[entry.key]}
setDraft={(value, opts) => setDraft(entry.key, value, opts)}
onResolvedRefresh={onResolvedRefresh}
/>
))}
</div>
);
}
function SettingField({
entry,
resolved,
draft,
setDraft,
onResolvedRefresh,
}: {
entry: RegistryClientEntry;
resolved: ResolvedValue | undefined;
draft: unknown;
setDraft: (value: unknown, opts?: { dirty?: boolean }) => void;
onResolvedRefresh: () => void;
}) {
const [showSecret, setShowSecret] = useState(false);
// Tracks whether `draft` currently holds a server-revealed value (vs.
// something the operator just typed). Lets the toggle button hide the
// revealed value cleanly without wiping a fresh edit.
const [revealedFromServer, setRevealedFromServer] = useState(false);
const reveal = useMutation({
mutationFn: async () => {
const r = await apiFetch<{ data: { revealed: boolean; value: string | null } }>(
`/api/v1/admin/settings/${encodeURIComponent(entry.key)}/reveal`,
{ method: 'POST' },
);
return r.data;
},
onSuccess: (r) => {
if (r.revealed && r.value != null) {
// Server reveal — populate draft but do NOT mark dirty (the value
// already matches what's stored).
setDraft(r.value, { dirty: false });
setRevealedFromServer(true);
setShowSecret(true);
} else {
toast.info(`${entry.label} isn't set — nothing to reveal.`);
}
},
onError: (err) => toastError(err, `Failed to reveal ${entry.label}`),
});
const revert = useMutation({
mutationFn: async () => {
await apiFetch(`/api/v1/admin/settings/${encodeURIComponent(entry.key)}`, {
method: 'DELETE',
});
},
onSuccess: () => {
toast.success(`${entry.label} reverted to default`);
setDraft('', { dirty: false });
onResolvedRefresh();
},
onError: (err) => toastError(err, `Failed to revert ${entry.label}`),
});
const copyFromEnv = useMutation({
mutationFn: async () => {
const r = await apiFetch<{ data: { copied: boolean; envValue?: string } }>(
`/api/v1/admin/settings/${encodeURIComponent(entry.key)}/copy-from-env`,
{ method: 'POST' },
);
return r.data;
},
onSuccess: (r) => {
if (r.copied) {
toast.success(`${entry.label} copied from env`);
if (r.envValue && !entry.sensitive) setDraft(r.envValue, { dirty: false });
} else {
toast.info(`No env value to copy for ${entry.label}`);
}
onResolvedRefresh();
},
onError: (err) => toastError(err, `Failed to copy ${entry.label} from env`),
});
const source = resolved?.source ?? 'default';
const showFallbackBadge = source === 'env' || source === 'default';
const canCopyFromEnv = !!entry.envFallback && source === 'env';
return (
<div className="space-y-1.5 border-l-2 border-l-muted pl-4">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={entry.key} className="text-sm font-medium">
{entry.label}
</Label>
<div className="flex items-center gap-1.5">
{source === 'port' && (
<Badge variant="secondary" className="text-xs">
<CheckCircle2 className="mr-1 size-3" />
Port override
</Badge>
)}
{source === 'global' && (
<Badge variant="secondary" className="text-xs">
Global
</Badge>
)}
{showFallbackBadge && resolved?.isSet && (
<Badge variant="outline" className="text-xs">
Using env fallback
</Badge>
)}
{showFallbackBadge && !resolved?.isSet && (
<Badge variant="outline" className="text-xs text-muted-foreground">
Not set
</Badge>
)}
</div>
</div>
{entry.description && <p className="text-xs text-muted-foreground">{entry.description}</p>}
<FieldInput
entry={entry}
value={draft}
onChange={(v) => {
// User typing → mark dirty so the card-level Save button picks it up.
setDraft(v, { dirty: true });
// A fresh keystroke supersedes any prior server-reveal.
if (revealedFromServer) setRevealedFromServer(false);
}}
showSecret={showSecret}
sensitive={entry.sensitive}
placeholder={entry.placeholder}
/>
<div className="flex flex-wrap items-center gap-2 pt-1">
{canCopyFromEnv && (
<Button
variant="outline"
size="sm"
onClick={() => copyFromEnv.mutate()}
disabled={copyFromEnv.isPending}
>
<Download className="mr-1 size-3" />
Copy from env
</Button>
)}
{(source === 'port' || source === 'global') && (
<Button
variant="ghost"
size="sm"
onClick={() => revert.mutate()}
disabled={revert.isPending}
>
Revert to fallback
</Button>
)}
{entry.sensitive && entry.type === 'password' && (
<Button
type="button"
variant="ghost"
size="sm"
disabled={reveal.isPending}
onClick={() => {
if (showSecret) {
// Hide. If this draft came from the server reveal, drop it so
// we don't keep cleartext in component state past the toggle.
if (revealedFromServer) {
setDraft('', { dirty: false });
setRevealedFromServer(false);
}
setShowSecret(false);
return;
}
// Show. If the operator hasn't typed anything yet and the
// setting is saved on the server, ask the API for cleartext.
const hasLocalDraft = typeof draft === 'string' && draft.length > 0;
if (
!hasLocalDraft &&
resolved?.isSet &&
(resolved.source === 'port' || resolved.source === 'global')
) {
reveal.mutate();
} else {
setShowSecret(true);
}
}}
>
{reveal.isPending ? (
<Loader2 className="size-3 animate-spin" />
) : showSecret ? (
<EyeOff className="size-3" />
) : (
<Eye className="size-3" />
)}
</Button>
)}
</div>
</div>
);
}
function FieldInput({
entry,
value,
onChange,
showSecret,
sensitive,
placeholder,
}: {
entry: RegistryClientEntry;
value: unknown;
onChange: (v: unknown) => void;
showSecret: boolean;
sensitive: boolean;
placeholder?: string;
}) {
if (entry.type === 'boolean') {
return (
<Switch id={entry.key} checked={!!value} onCheckedChange={(checked) => onChange(checked)} />
);
}
if (entry.type === 'user-select') {
return (
<UserSelectInput
id={entry.key}
value={typeof value === 'string' ? value : ''}
onChange={onChange}
placeholder={placeholder ?? 'No CRM user linked'}
/>
);
}
if (entry.type === 'select' && entry.options) {
// Radix Select rejects an empty-string `value` because that's its internal
// sentinel for "cleared". Pass `undefined` instead so the placeholder
// renders cleanly when the resolved value is null/blank.
const selectValue = value == null || value === '' ? undefined : String(value);
return (
<Select value={selectValue} onValueChange={(v) => onChange(v)}>
<SelectTrigger id={entry.key}>
<SelectValue placeholder={placeholder ?? 'Choose…'} />
</SelectTrigger>
<SelectContent>
{entry.options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (entry.type === 'textarea') {
return (
<Textarea
id={entry.key}
value={String(value ?? '')}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
rows={4}
/>
);
}
return (
<Input
id={entry.key}
type={
entry.type === 'password' && !showSecret
? 'password'
: entry.type === 'number'
? 'number'
: entry.type === 'email'
? 'email'
: entry.type === 'url'
? 'url'
: 'text'
}
value={sensitive && !showSecret && value === '' ? '' : String(value ?? '')}
placeholder={sensitive ? '••••••••' : placeholder}
onChange={(e) =>
onChange(entry.type === 'number' ? Number(e.target.value || 0) : e.target.value)
}
/>
);
}
interface PickerUser {
id: string;
email: string;
name: string;
}
/**
* Renders a Radix Select of every user in the current port. Stores the
* user's UUID. A "no link" sentinel value lets the operator clear the
* binding (Radix can't store empty string as a value, so we map empty ↔
* `__none__` over the wire).
*/
function UserSelectInput({
id,
value,
onChange,
placeholder,
}: {
id: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const { data, isLoading } = useQuery<{ data: PickerUser[] }>({
queryKey: ['admin', 'users', 'picker'],
queryFn: () => apiFetch<{ data: PickerUser[] }>('/api/v1/admin/users/picker'),
staleTime: 60_000,
});
const NONE = '__none__';
const selectValue = value ? value : NONE;
return (
<Select value={selectValue} onValueChange={(v) => onChange(v === NONE ? '' : v)}>
<SelectTrigger id={id}>
<SelectValue placeholder={isLoading ? 'Loading users…' : placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}> No CRM user linked </SelectItem>
{(data?.data ?? []).map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name || u.email} {u.name ? `· ${u.email}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@@ -417,7 +417,14 @@ function ImageUploadField({
<div className="h-20 w-20 shrink-0 rounded-md border bg-muted/30 flex items-center justify-center overflow-hidden">
{}
{value ? (
<img src={value} alt="" className="h-full w-full object-contain" />
<img
src={value}
// M-U11: describe the preview so screen readers don't say
// "image" with no context. Falls back to a generic label
// when no field.label is set.
alt={`${field.label || 'Settings'} preview`}
className="h-full w-full object-contain"
/>
) : (
<span className="text-[10px] text-muted-foreground">No image</span>
)}

View File

@@ -28,6 +28,7 @@ import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { WarningCallout } from '@/components/ui/warning-callout';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
@@ -77,13 +78,31 @@ const S3_FIELDS: SettingFieldDef[] = [
defaultValue: '',
},
{
// Reads from the legacy plaintext key for backward compat. New writes
// go through the registry-driven form below (which uses the
// `*_encrypted` key + AES at rest). After running the migration script
// (`pnpm tsx scripts/encrypt-plaintext-credentials.ts`) this field is
// empty and the encrypted form takes over.
key: 'storage_s3_access_key',
label: 'S3 access key',
description: 'IAM access key id (or provider equivalent).',
label: 'S3 access key (legacy plaintext — deprecated)',
description:
'Deprecated. Use the AES-encrypted access key field below instead. After running the migration script, this row is removed and only the encrypted form is used.',
type: 'string',
placeholder: 'AKIA…',
defaultValue: '',
},
{
// M-S01: encrypted at rest like the secret key. The legacy plaintext
// field above is reflected for backward compat but new writes go
// through this AES envelope.
key: 'storage_s3_access_key_encrypted',
label: 'S3 access key (encrypted)',
description:
'Stored AES-encrypted at rest; the field shows blank after save and is replaced only when you type a new value. Run `pnpm tsx scripts/encrypt-plaintext-credentials.ts` to migrate the legacy plaintext value into this field.',
type: 'password',
placeholder: '(unchanged)',
defaultValue: '',
},
{
key: 'storage_s3_secret_key_encrypted',
label: 'S3 secret key',
@@ -133,7 +152,7 @@ export function StorageAdminPanel() {
mutationFn: async (opts: { from: BackendName; to: BackendName }) =>
apiFetch<{ data: MigrationResult }>('/api/v1/admin/storage/migrate', {
method: 'POST',
body: JSON.stringify({ ...opts, dryRun: true }),
body: { ...opts, dryRun: true },
}),
onSuccess: (result) => {
setDryRun(result.data);
@@ -146,7 +165,7 @@ export function StorageAdminPanel() {
mutationFn: async (opts: { from: BackendName; to: BackendName; skipMigration: boolean }) =>
apiFetch<{ data: MigrationResult }>('/api/v1/admin/storage/migrate', {
method: 'POST',
body: JSON.stringify({ ...opts, dryRun: false }),
body: { ...opts, dryRun: false },
}),
onSuccess: (result) => {
setConfirmOpen(false);
@@ -203,6 +222,16 @@ export function StorageAdminPanel() {
description="Where the CRM stores per-berth PDFs, brochures, GDPR exports, profile photos, and other binary files."
/>
{/* AES-encrypted access key — write path. The legacy plaintext access
key field below is read-only deprecation; new writes should go
through this card. After running the encrypt-plaintext-credentials
migration script, the legacy field becomes empty. */}
<RegistryDrivenForm
title="S3 access key (encrypted)"
description="AES-encrypted at rest. Type your access key here — it replaces the deprecated plaintext field below and fixes audit finding S-23."
sections={['storage.s3']}
/>
{/* STEP 1: configure connection details for the OTHER backend so the
admin can prep + test BEFORE attempting any switch. */}
<SettingsFormCard

View File

@@ -1,9 +1,11 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation';
import { Anchor, Plus } from 'lucide-react';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
@@ -29,6 +31,12 @@ import { mooringLetterTone } from './mooring-letter-tone';
export function BerthList() {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
// M-U14: surface the page title in the mobile topbar.
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Berths', showBackButton: false });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
// F13: bulk-add wizard had no UI entry point. Gate the CTA on
// `berths.import` (the existing permission used for adding berths)
// so non-admins don't see a button that 403s on click.

View File

@@ -5,6 +5,7 @@ import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -190,11 +191,25 @@ export function ClientForm({
// is the fall-back if the rep wiped the value after focus.
throw Object.assign(new Error('At least one contact is required.'), { status: 400 });
}
// If none of the remaining contacts is flagged primary, promote
// the first one — guards against a rep removing the originally-
// primary row and leaving an orphan set.
if (!cleanedContacts.some((c) => c.isPrimary)) {
cleanedContacts[0]!.isPrimary = true;
// Primary is per-channel (DB has a partial unique index on
// (client_id, channel) WHERE is_primary). For every channel present
// in the cleaned set, ensure exactly one row is flagged primary —
// promote the first row of that channel if none was explicitly
// marked, and clear duplicates so the API doesn't 409.
const seenPrimaryByChannel = new Set<string>();
for (const c of cleanedContacts) {
if (c.isPrimary && !seenPrimaryByChannel.has(c.channel)) {
seenPrimaryByChannel.add(c.channel);
} else if (c.isPrimary) {
// duplicate primary within the channel — clear
c.isPrimary = false;
}
}
const seenChannels = new Set<string>(cleanedContacts.map((c) => c.channel));
for (const channel of seenChannels) {
if (seenPrimaryByChannel.has(channel)) continue;
const first = cleanedContacts.find((c) => c.channel === channel);
if (first) first.isPrimary = true;
}
const payload: CreateClientInput = { ...data, contacts: cleanedContacts };
@@ -214,6 +229,9 @@ export function ClientForm({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients'] });
// M-U10: confirm the write landed. Without this the rep closes
// the sheet not sure whether the create/edit actually saved.
toast.success(isEdit ? 'Client updated' : 'Client created');
onOpenChange(false);
},
});
@@ -389,9 +407,41 @@ export function ClientForm({
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<Checkbox
checked={watch(`contacts.${index}.isPrimary`)}
onCheckedChange={(v) => setValue(`contacts.${index}.isPrimary`, !!v)}
onCheckedChange={(v) => {
const checked = !!v;
const thisChannel = watch(`contacts.${index}.channel`);
if (checked) {
// Primary is per-channel — flipping this one on
// clears the flag on every other row sharing the
// same channel. (DB enforces uniqueness via a
// partial index, but doing it client-side avoids
// a surprising 409 mid-save.)
const all = getValues('contacts') ?? [];
const next = all.map((c, i) => ({
...c,
isPrimary:
i === index
? true
: c.channel === thisChannel
? false
: !!c.isPrimary,
}));
setValue('contacts', next, { shouldDirty: true });
} else {
setValue(`contacts.${index}.isPrimary`, false, { shouldDirty: true });
}
}}
/>
<span className="font-medium">Primary contact</span>
<span className="font-medium">
Primary{' '}
{watch(`contacts.${index}.channel`) === 'email'
? 'email'
: watch(`contacts.${index}.channel`) === 'phone'
? 'phone'
: watch(`contacts.${index}.channel`) === 'whatsapp'
? 'WhatsApp'
: 'contact'}
</span>
</label>
{fields.length > 1 && (
<Button

View File

@@ -1,11 +1,13 @@
'use client';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { Plus, Archive, Tag as TagIcon, TagsIcon, Trash2 } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
@@ -50,6 +52,14 @@ export function ClientList() {
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
// M-U14: surface the page title in the mobile topbar so reps don't
// see a blank chrome row above the list.
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Clients', showBackButton: false });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true));

View File

@@ -1,8 +1,10 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { AlertCircle, ArrowRight, Briefcase, X } from 'lucide-react';
import { AlertCircle, Archive, ArrowRight, Briefcase, X } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
@@ -17,6 +19,10 @@ interface MatchData {
interestCount: number;
emails: string[];
phonesE164: string[];
/** ISO timestamp when the client was archived. When set, the matched
* client is soft-deleted — the suggestion panel surfaces a Restore link
* to the existing restore wizard instead of "Use this client". */
archivedAt: string | null;
}
interface DedupSuggestionPanelProps {
@@ -50,6 +56,8 @@ export function DedupSuggestionPanel({
onUseExisting,
onDismiss,
}: DedupSuggestionPanelProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [dismissed, setDismissed] = useState(false);
// Debounce inputs by 300ms so we don't fire on every keystroke. Keep
@@ -95,36 +103,56 @@ export function DedupSuggestionPanel({
const top = matches[0]!;
const isHigh = top.confidence === 'high';
const isArchived = !!top.archivedAt;
return (
<div
className={cn(
'rounded-lg border p-3 mb-3 transition-colors',
isHigh
? 'border-amber-300 bg-amber-50/60 dark:bg-amber-950/30'
: 'border-border bg-muted/40',
isArchived
? 'border-slate-300 bg-slate-50/60 dark:border-slate-700 dark:bg-slate-900/40'
: isHigh
? 'border-amber-300 bg-amber-50/60 dark:bg-amber-950/30'
: 'border-border bg-muted/40',
)}
data-testid="dedup-suggestion"
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
<AlertCircle
className={cn(
'size-5',
isHigh ? 'text-amber-700 dark:text-amber-400' : 'text-muted-foreground',
)}
aria-hidden
/>
{isArchived ? (
<Archive className="size-5 text-slate-600 dark:text-slate-400" aria-hidden />
) : (
<AlertCircle
className={cn(
'size-5',
isHigh ? 'text-amber-700 dark:text-amber-400' : 'text-muted-foreground',
)}
aria-hidden
/>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold leading-tight">
{isHigh
? 'This looks like an existing client'
: 'Possible match - check before creating'}
{isArchived
? 'This contact info belongs to an archived client'
: isHigh
? 'This looks like an existing client'
: 'Possible match — check before creating'}
</p>
{isArchived && (
<p className="mt-0.5 text-xs text-muted-foreground">
Restore the existing record (keeping its history + interests), or create a fresh one
if this is a different person.
</p>
)}
<div className="mt-2 rounded-md border bg-background/80 p-2.5">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{top.fullName}</p>
{isArchived && (
<span className="shrink-0 rounded-full bg-slate-200 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-slate-800 dark:bg-slate-700 dark:text-slate-200">
archived
</span>
)}
<span
className={cn(
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
@@ -143,19 +171,36 @@ export function DedupSuggestionPanel({
<Briefcase className="size-3" aria-hidden />
{top.interestCount} {top.interestCount === 1 ? 'interest' : 'interests'}
</span>
{isArchived && top.archivedAt && (
<span className="inline-flex items-center gap-1">
archived {new Date(top.archivedAt).toLocaleDateString()}
</span>
)}
</div>
<p className="mt-1.5 text-[11px] text-muted-foreground">{top.reasons.join(' · ')}</p>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => onUseExisting(top)}
data-testid="dedup-use-existing"
>
Use this client
<ArrowRight className="ml-1 size-3.5" aria-hidden />
</Button>
{isArchived ? (
<Button asChild type="button" size="sm">
<Link
href={`/${portSlug}/clients/${top.clientId}/restore`}
data-testid="dedup-restore-archived"
>
Restore this client
<ArrowRight className="ml-1 size-3.5" aria-hidden />
</Link>
</Button>
) : (
<Button
type="button"
size="sm"
onClick={() => onUseExisting(top)}
data-testid="dedup-use-existing"
>
Use this client
<ArrowRight className="ml-1 size-3.5" aria-hidden />
</Button>
)}
<Button
type="button"
size="sm"
@@ -167,7 +212,7 @@ export function DedupSuggestionPanel({
data-testid="dedup-dismiss"
>
<X className="mr-1 size-3.5" aria-hidden />
Create new anyway
{isArchived ? 'Create new anyway (different person)' : 'Create new anyway'}
</Button>
{matches.length > 1 ? (
<span className="text-xs text-muted-foreground">

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useParams } from 'next/navigation';
import { Plus, Archive, Tag as TagIcon, TagsIcon } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
@@ -70,7 +71,7 @@ export function CompanyList() {
queryClient.invalidateQueries({ queryKey: ['companies'] });
const s = res.data.summary;
if (s.failed > 0) {
alert(`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed.`);
toast.warning(`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed.`);
}
},
});

View File

@@ -225,7 +225,12 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
<PermissionGate resource="memberships" action="manage">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Member actions"
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>

View File

@@ -214,7 +214,13 @@ function ActivityFeedInner() {
{item.label ? (
<>
<span className="font-medium">{item.label}</span>
<span className="ml-1.5 text-muted-foreground text-xs capitalize">
{/* M-NEW-2: explicit middle-dot separator. The
prior `ml-1.5` was getting collapsed under
`truncate` so the label + type rendered as
"Test Person 1interest" with no visible
space between them. */}
<span className="text-muted-foreground/60 mx-1.5">·</span>
<span className="text-muted-foreground text-xs capitalize">
{item.entityType}
</span>
</>

View File

@@ -1,11 +1,11 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import type { Route } from 'next';
import { useRouter } from 'next/navigation';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Bell, Download, Mail, Trash2, X } from 'lucide-react';
import { ArrowLeft, Bell, Download, Mail, Send, Trash2, UserPlus, X } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -15,6 +15,22 @@ import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cleanSignerName } from '@/components/documents/signing-progress';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
/** Capitalize the first letter; rest stays as-is. Used for normalising
* free-text enum values ('signer'/'approver'/'sent'/'pending') for
* display without resorting to full ALL-CAPS that other surfaces use. */
function capFirst(s: string | null | undefined): string {
if (!s) return '';
return s.charAt(0).toUpperCase() + s.slice(1);
}
interface DetailDoc {
id: string;
@@ -39,6 +55,9 @@ interface DetailSigner {
signerRole: string;
signingOrder: number;
status: string;
/** Null = never invited yet → "Send invitation" CTA.
* Set + status pending → "Send reminder" CTA. */
invitedAt: string | null;
signedAt: string | null;
signingUrl: string | null;
}
@@ -158,6 +177,22 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
}
};
// #67: state-aware action button. When a signer has no `invitedAt`
// they've never been mailed — fire the initial invitation (the same
// route the EOI tab uses; handles v2 distribute-or-self-heal).
const handleSendInvitation = async (signerId: string) => {
try {
await apiFetch(`/api/v1/documents/${documentId}/send-invitation`, {
method: 'POST',
body: { signerId },
});
toast.success('Invitation sent');
queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] });
} catch (err) {
toastError(err);
}
};
const handleCancel = async () => {
const ok = await confirm({
title: 'Cancel document',
@@ -213,7 +248,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
kpiLine={
<>
<StatusPill status={STATUS_PILL_MAP[doc.status] ?? 'pending'} withDot>
{doc.status.replace(/_/g, ' ')}
{capFirst(doc.status.replace(/_/g, ' '))}
</StatusPill>
<span>
{signers.filter((s) => s.status === 'signed').length}/{signers.length} signed
@@ -279,28 +314,42 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="font-medium text-foreground">{signer.signerName}</div>
<div className="font-medium text-foreground">
{/* #67 cleanup: strip `(was: …)` / `(placeholder)`
email-redirect leak suffixes that the EOI tab
already scrubs on its own SigningProgress card. */}
{cleanSignerName(signer.signerName) || signer.signerEmail}
</div>
<StatusPill status={SIGNER_PILL_MAP[signer.status] ?? 'pending'}>
{signer.status}
{capFirst(signer.status)}
</StatusPill>
</div>
<div className="text-xs text-muted-foreground">
{signer.signerEmail} · {signer.signerRole}
{signer.signerEmail} · {capFirst(signer.signerRole)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{signer.signedAt
? `Signed ${new Date(signer.signedAt).toLocaleDateString('en-GB')}`
: 'Pending'}
: signer.invitedAt
? `Invited ${new Date(signer.invitedAt).toLocaleDateString('en-GB')}`
: 'Not yet invited'}
</div>
{signer.status === 'pending' && doc.documensoId && isInFlight ? (
<div className="mt-2 flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleRemind(signer.id)}
>
<Bell className="mr-1.5 h-3 w-3" aria-hidden /> Remind
</Button>
{/* #67 state-aware CTA: invited yet? remind. else: send. */}
{signer.invitedAt ? (
<Button
size="sm"
variant="outline"
onClick={() => handleRemind(signer.id)}
>
<Bell className="mr-1.5 h-3 w-3" aria-hidden /> Send reminder
</Button>
) : (
<Button size="sm" onClick={() => handleSendInvitation(signer.id)}>
<Send className="mr-1.5 h-3 w-3" aria-hidden /> Send invitation
</Button>
)}
{signer.signingUrl ? (
<button
type="button"
@@ -339,44 +388,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
{/* Right column */}
<div className="flex flex-col gap-4">
<section className="rounded-md border bg-white p-4">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Watchers
</h2>
{watchers.length === 0 ? (
<p className="text-xs text-muted-foreground">No one is watching this document yet.</p>
) : (
<ul className="space-y-1">
{watchers.map((w) => (
<li key={w.userId} className="flex items-center justify-between text-sm">
<span className="truncate font-mono text-xs text-muted-foreground">
{w.userId.slice(0, 8)}
</span>
<button
type="button"
aria-label="Remove watcher"
onClick={async () => {
try {
await apiFetch(`/api/v1/documents/${documentId}/watchers/${w.userId}`, {
method: 'DELETE',
});
toast.success('Watcher removed');
queryClient.invalidateQueries({
queryKey: ['document-detail', documentId],
});
} catch (err) {
toastError(err);
}
}}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</button>
</li>
))}
</ul>
)}
</section>
<WatchersCard documentId={documentId} watchers={watchers} />
<section className="rounded-md border bg-white p-4">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
@@ -405,3 +417,130 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
</div>
);
}
/**
* #67 watcher Add UI. The watchers list previously displayed only
* user-id stubs (truncated UUID) with a delete button and no way to
* add new watchers. This card resolves user IDs to display names
* via the existing `/api/v1/admin/users/picker` endpoint (already
* used by the registry-driven settings form), surfaces a "+ Add"
* select, and keeps the delete affordance unchanged.
*/
interface PickerUser {
id: string;
email: string;
name: string | null;
}
function WatchersCard({ documentId, watchers }: { documentId: string; watchers: DetailWatcher[] }) {
const queryClient = useQueryClient();
const [selected, setSelected] = useState('');
const { data: usersData } = useQuery({
queryKey: ['admin', 'users-picker'],
queryFn: () => apiFetch<{ data: PickerUser[] }>('/api/v1/admin/users/picker'),
});
const users = usersData?.data ?? [];
const userById = useMemo(() => {
const map = new Map<string, PickerUser>();
for (const u of users) map.set(u.id, u);
return map;
}, [users]);
const watcherIds = new Set(watchers.map((w) => w.userId));
const candidates = users.filter((u) => !watcherIds.has(u.id));
async function addWatcher(userId: string) {
if (!userId) return;
try {
await apiFetch(`/api/v1/documents/${documentId}/watchers`, {
method: 'POST',
body: { userId },
});
toast.success('Watcher added');
setSelected('');
queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] });
} catch (err) {
toastError(err);
}
}
async function removeWatcher(userId: string) {
try {
await apiFetch(`/api/v1/documents/${documentId}/watchers/${userId}`, {
method: 'DELETE',
});
toast.success('Watcher removed');
queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] });
} catch (err) {
toastError(err);
}
}
return (
<section className="rounded-md border bg-white p-4">
<h2 className="mb-1 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Watchers
</h2>
<p className="mb-3 text-xs text-muted-foreground">
Watchers receive an in-app notification on every signing event (opened, signed, declined,
completed).
</p>
{watchers.length === 0 ? (
<p className="text-xs text-muted-foreground">No one is watching this document yet.</p>
) : (
<ul className="mb-3 space-y-1">
{watchers.map((w) => {
const u = userById.get(w.userId);
return (
<li key={w.userId} className="flex items-center justify-between text-sm">
<span className="truncate">
{u?.name ?? u?.email ?? `User ${w.userId.slice(0, 8)}`}
</span>
<button
type="button"
aria-label="Remove watcher"
onClick={() => removeWatcher(w.userId)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</button>
</li>
);
})}
</ul>
)}
<div className="flex items-center gap-2">
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger className="h-9 flex-1 text-xs">
<SelectValue placeholder="Add a watcher…" />
</SelectTrigger>
<SelectContent>
{candidates.length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">
All users in this port are already watching.
</div>
) : (
candidates.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name ?? u.email}
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
disabled={!selected}
onClick={() => addWatcher(selected)}
>
<UserPlus className="mr-1.5 h-3 w-3" aria-hidden /> Add
</Button>
</div>
</section>
);
}

View File

@@ -138,7 +138,10 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
'document:cancelled': [['documents']],
'document:rejected': [['documents']],
'document:signer:signed': [['documents']],
'file:created': [['files']],
// M-D01: server emits `file:uploaded` (see src/lib/services/files.ts);
// every other consumer listens on that name. `file:created` was a
// typo here, so the hub's file list never invalidated on upload.
'file:uploaded': [['files']],
'file:updated': [['files']],
'file:deleted': [['files']],
'folder:created': [['document-folders']],

View File

@@ -0,0 +1,179 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Loader2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface Signer {
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
status: string;
}
interface EoiCancelDialogProps {
documentId: string;
signers: Signer[];
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Cancel-with-notify modal. Two variants by signedCount:
* - 0 signed: simple confirm with optional reason. Cancel button.
* - 1+ signed: list each signer with a checkbox so the rep picks
* who to email. Pre-checks the signers who have signed (they're
* the most-affected) — rep can opt out.
*
* In both cases the reason textarea is optional and (when present)
* gets inlined into the cancellation email body + the audit log.
*
* On confirm: POST /api/v1/documents/[id]/cancel with
* { reason, notifyRecipients: [signerId, ...] }
* The server voids the envelope, marks status=cancelled, sends the
* branded cancellation email to each picked recipient.
*/
export function EoiCancelDialog({ documentId, signers, open, onOpenChange }: EoiCancelDialogProps) {
const queryClient = useQueryClient();
const [reason, setReason] = useState('');
const [notifyIds, setNotifyIds] = useState<Set<string>>(() => {
// Default: pre-check the signers who have signed — they're the
// recipients most likely to want to know. Pending signers can be
// notified too but the rep needs to opt them in.
return new Set(signers.filter((s) => s.status === 'signed').map((s) => s.id));
});
const signedCount = useMemo(() => signers.filter((s) => s.status === 'signed').length, [signers]);
const cancelMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/documents/${documentId}/cancel`, {
method: 'POST',
body: {
reason: reason.trim() || null,
notifyRecipients: Array.from(notifyIds),
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success(
notifyIds.size > 0
? `EOI cancelled. ${notifyIds.size} signer${notifyIds.size === 1 ? '' : 's'} notified.`
: 'EOI cancelled.',
);
onOpenChange(false);
// Reset internal state so a second open of the dialog starts clean.
setReason('');
setNotifyIds(new Set());
},
onError: (err) => toastError(err),
});
const toggle = (id: string) => {
setNotifyIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-4 text-amber-600" aria-hidden /> Cancel this EOI?
</DialogTitle>
<DialogDescription>
{signedCount === 0
? 'No signatures have been collected yet. The signing service will be told to void this envelope.'
: `${signedCount} signer${signedCount === 1 ? ' has' : 's have'} already signed. The envelope will be voided and pick the signers you want to notify by email below.`}
</DialogDescription>
</DialogHeader>
{signedCount > 0 && (
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Notify
</p>
<ul className="space-y-1.5">
{signers.map((s) => (
<li key={s.id} className="flex items-center gap-2 text-sm">
<Checkbox
id={`notify-${s.id}`}
checked={notifyIds.has(s.id)}
onCheckedChange={() => toggle(s.id)}
/>
<Label htmlFor={`notify-${s.id}`} className="flex-1 cursor-pointer font-normal">
<span className="font-medium">{s.signerName || s.signerEmail}</span>{' '}
<span className="text-xs text-muted-foreground">
· {s.signerRole}
{s.status === 'signed' ? ' · already signed' : ' · pending'}
</span>
</Label>
</li>
))}
</ul>
<p className="text-xs italic text-muted-foreground">
Leave all unchecked to cancel silently no emails will be sent.
</p>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="cancel-reason" className="text-xs font-semibold uppercase tracking-wide">
Reason (optional)
</Label>
<Textarea
id="cancel-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Yacht owner changed terms; will resend a fresh EOI."
className="min-h-[80px] resize-y"
maxLength={2000}
/>
<p className="text-xs text-muted-foreground">
Appears in the cancellation email (if you notify anyone) and the audit log.
</p>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Keep EOI
</Button>
<Button
variant="destructive"
onClick={() => cancelMutation.mutate()}
disabled={cancelMutation.isPending}
className="gap-1.5 [&_svg]:size-3.5"
>
{cancelMutation.isPending ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<XCircle />
)}
Cancel EOI
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -6,13 +6,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, ExternalLink, FileSignature, Pencil } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import {
Select,
@@ -47,7 +47,14 @@ interface EoiContextResponse {
nationality: string | null;
primaryEmail: string | null;
primaryPhone: string | null;
address: { street: string; city: string; country: string } | null;
address: {
street: string;
city: string;
subdivision: string;
postalCode: string;
country: string;
countryIso: string;
} | null;
};
yacht: {
id: string;
@@ -55,6 +62,16 @@ interface EoiContextResponse {
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
lengthM: string | null;
widthM: string | null;
draftM: string | null;
/** Which unit the rep originally entered the dimensions in — drives
* the toggle's default position. The trio of *Unit columns usually
* share a value in practice; we read `lengthUnit` as the
* representative. */
lengthUnit: 'ft' | 'm';
widthUnit: 'ft' | 'm';
draftUnit: 'ft' | 'm';
hullNumber: string | null;
flag: string | null;
} | null;
@@ -100,18 +117,46 @@ export function EoiGenerateDialog({
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
// Unit picker for the Length/Width/Draft preview row + the values that
// ship to Documenso. Defaults to whichever side the rep originally typed
// (drives off the yacht's `lengthUnit` column). Stored as state so the
// rep can flip ft↔m before generating without losing the underlying data.
const [dimensionUnit, setDimensionUnit] = useState<'ft' | 'm' | null>(null);
// Resolved EOI context — the actual values the document will be
// auto-filled with. Loaded only while the dialog is open so we don't
// pay for the join tree on every interest detail page render.
const { data: ctxRes, isLoading: ctxLoading } = useQuery<EoiContextResponse>({
const {
data: ctxRes,
isLoading: ctxLoading,
error: ctxError,
} = useQuery<EoiContextResponse>({
queryKey: ['interests', interestId, 'eoi-context'],
queryFn: () => apiFetch<EoiContextResponse>(`/api/v1/interests/${interestId}/eoi-context`),
enabled: open,
staleTime: 30_000,
retry: false,
});
const ctx = ctxRes?.data;
// Server-side EOI validators throw `Cannot generate EOI - missing
// required client details: client name, client email, client address`.
// Parse that list so the dialog can render an inline fix-it form
// (no need to bounce out to the client detail page).
const ctxErrorMessage = ctxError instanceof Error && ctxError.message ? ctxError.message : null;
const missingFields = useMemo(() => {
if (!ctxErrorMessage) return new Set<'name' | 'email' | 'address'>();
const m = ctxErrorMessage.match(/missing required client details:\s*([^.]+)/i);
if (!m) return new Set<'name' | 'email' | 'address'>();
const tokens = m[1]!.split(',').map((s) => s.trim().toLowerCase());
const out = new Set<'name' | 'email' | 'address'>();
for (const t of tokens) {
if (t.includes('name')) out.add('name');
if (t.includes('email')) out.add('email');
if (t.includes('address')) out.add('address');
}
return out;
}, [ctxErrorMessage]);
const { data: templatesRes } = useQuery<{ data: InAppTemplate[] }>({
queryKey: ['document-templates', { templateType: 'eoi', isActive: true }],
@@ -123,6 +168,86 @@ export function EoiGenerateDialog({
});
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
// Only show the template picker when there's a real choice — the
// Documenso path is always present, so we show the dropdown once at
// least one in-app pdf-lib template is configured. Otherwise it's a
// 1-item select which adds noise.
const showTemplatePicker = inAppTemplates.length > 0;
// ─── Inline fix-it form for missing client fields ──────────────────────────
// Drafted as one piece of local state so a partial save (e.g. address
// succeeds but email fails) leaves the rest of the inputs untouched.
const [fixDraft, setFixDraft] = useState<{
name: string;
email: string;
street: string;
city: string;
postalCode: string;
subdivisionIso: string;
countryIso: string | null;
}>({
name: '',
email: '',
street: '',
city: '',
postalCode: '',
subdivisionIso: '',
countryIso: null,
});
const [fixSaving, setFixSaving] = useState(false);
const persistMissingFields = async (): Promise<void> => {
if (!clientId) {
toastError(new Error('Client ID missing — refresh the page.'));
return;
}
setFixSaving(true);
try {
// Issue one PATCH/POST per missing field. Sequential rather than
// parallel so a downstream failure surfaces a coherent error rather
// than partial-and-confused state.
if (missingFields.has('name')) {
if (!fixDraft.name.trim()) throw new Error('Client name is required.');
await apiFetch(`/api/v1/clients/${clientId}`, {
method: 'PATCH',
body: { fullName: fixDraft.name.trim() },
});
}
if (missingFields.has('email')) {
if (!fixDraft.email.trim()) throw new Error('Client email is required.');
await apiFetch(`/api/v1/clients/${clientId}/contacts`, {
method: 'POST',
body: { channel: 'email', value: fixDraft.email.trim(), isPrimary: true },
});
}
if (missingFields.has('address')) {
if (!fixDraft.street.trim()) throw new Error('Street address is required.');
await apiFetch(`/api/v1/clients/${clientId}/addresses`, {
method: 'POST',
body: {
streetAddress: fixDraft.street.trim(),
city: fixDraft.city.trim() || null,
postalCode: fixDraft.postalCode.trim() || null,
subdivisionIso: fixDraft.subdivisionIso.trim() || null,
countryIso: fixDraft.countryIso,
isPrimary: true,
},
});
}
// Refetch the EOI context so the dialog flips into preview-ready mode.
// Also bounce caches that downstream surfaces watch (client detail,
// interest detail) so the rep sees the edits everywhere immediately.
await queryClient.invalidateQueries({
queryKey: ['interests', interestId, 'eoi-context'],
});
await queryClient.invalidateQueries({ queryKey: ['clients', clientId] });
await queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
} catch (err) {
toastError(err);
} finally {
setFixSaving(false);
}
};
async function patchClient(body: Record<string, unknown>) {
if (!ctx) return;
@@ -149,22 +274,6 @@ export function EoiGenerateDialog({
placeholder: 'Full legal name',
},
},
{
key: 'nationality',
label: 'Nationality',
value: ctx.client.nationality,
present: !!ctx.client.nationality,
edit: {
variant: 'country' as const,
onSave: async (next: string | null) => {
// Country combobox emits the ISO code; the read-only string is the
// localised country name (resolved server-side). Coerce here so we
// store the canonical ISO.
const iso = next ? (next as string).toUpperCase() : null;
await patchClient({ nationalityIso: iso });
},
},
},
{
key: 'email',
label: 'Email address',
@@ -173,9 +282,17 @@ export function EoiGenerateDialog({
},
{
key: 'address',
// Mirrors the rendered EOI Address field exactly so the rep sees
// what's going to appear on the document.
label: 'Address',
value: ctx.client.address
? [ctx.client.address.street, ctx.client.address.city, ctx.client.address.country]
? [
ctx.client.address.street,
ctx.client.address.city,
ctx.client.address.subdivision,
ctx.client.address.postalCode,
ctx.client.address.countryIso,
]
.filter(Boolean)
.join(', ')
: null,
@@ -184,6 +301,17 @@ export function EoiGenerateDialog({
]
: [];
// Default the dimension toggle to the unit the rep originally typed in
// (yacht.lengthUnit). We fall back to 'ft' for legacy rows where the
// unit column was never set.
const defaultDimensionUnit: 'ft' | 'm' = ctx?.yacht?.lengthUnit ?? 'ft';
const effectiveDimensionUnit: 'ft' | 'm' = dimensionUnit ?? defaultDimensionUnit;
const dimensionsForRender = ctx?.yacht
? effectiveDimensionUnit === 'ft'
? [ctx.yacht.lengthFt, ctx.yacht.widthFt, ctx.yacht.draftFt]
: [ctx.yacht.lengthM, ctx.yacht.widthM, ctx.yacht.draftM]
: [];
// Optional — Section 3 of the EOI. Generation proceeds without them.
const optional = ctx
? [
@@ -200,12 +328,8 @@ export function EoiGenerateDialog({
},
{
key: 'dimensions',
label: 'Dimensions (L × W × D, ft)',
value: ctx.yacht
? [ctx.yacht.lengthFt, ctx.yacht.widthFt, ctx.yacht.draftFt]
.map((v) => v ?? '—')
.join(' × ')
: null,
label: `Dimensions (L × W × D, ${effectiveDimensionUnit})`,
value: ctx.yacht ? dimensionsForRender.map((v) => v ?? '—').join(' × ') : null,
},
{
key: 'berth',
@@ -241,11 +365,25 @@ export function EoiGenerateDialog({
pathway: isDocumenso ? 'documenso-template' : 'inapp',
// Signers derived server-side from EOI context for both pathways.
signers: [],
// Dimension unit chosen in the drawer's toggle — drives which
// side (ft|m) of the yacht's stored dimensions flows into the
// EOI's Length/Width/Draft formValues. Defaults server-side to
// the yacht's own `lengthUnit` column when unspecified.
dimensionUnit: effectiveDimensionUnit,
},
});
queryClient.invalidateQueries({
predicate: (q) => q.queryKey[0] === 'documents',
});
// Bounce every cache that surfaces the interest's EOI state so the
// Overview tab flips immediately from "Generate EOI" prompt to
// "EOI sent / awaiting signatures", the EOI tab picks up the new
// signers row, and the timeline reflects the just-stamped milestone.
await Promise.all([
queryClient.invalidateQueries({
predicate: (q) => q.queryKey[0] === 'documents',
}),
queryClient.invalidateQueries({ queryKey: ['interests', interestId] }),
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] }),
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'timeline'] }),
]);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
@@ -255,38 +393,41 @@ export function EoiGenerateDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<FileSignature className="size-4" aria-hidden />
Generate Expression of Interest
</DialogTitle>
<DialogDescription>
Review the values that will be auto-filled into the EOI. Anything wrong? Edit it on the
client&apos;s record before generating.
</DialogDescription>
</DialogHeader>
</SheetTitle>
<SheetDescription>
Review the values that will be auto-filled. Edit anything inline changes save back to
the client / interest record automatically. The EOI is generated once everything looks
right.
</SheetDescription>
</SheetHeader>
<div className="space-y-4 py-1">
<div className="space-y-2">
<Label htmlFor="eoi-template">Template</Label>
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
<SelectTrigger id="eoi-template">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
Standard EOI sent for e-signature (recommended)
</SelectItem>
{inAppTemplates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
<div className="space-y-4 py-4">
{showTemplatePicker && (
<div className="space-y-2">
<Label htmlFor="eoi-template">Template</Label>
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
<SelectTrigger id="eoi-template">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
Standard EOI sent for e-signature (recommended)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{inAppTemplates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{ctxLoading ? (
<div className="space-y-2">
@@ -305,9 +446,6 @@ export function EoiGenerateDialog({
<PreviewRow
key={row.key}
label={row.label}
// Nationality stores the localised country name in the preview
// but commits the ISO. Pass the underlying ISO via a closure
// so the CountryCombobox can highlight it correctly.
value={row.value}
missing={!row.present}
edit={row.edit}
@@ -316,9 +454,41 @@ export function EoiGenerateDialog({
</dl>
</div>
<div className="space-y-1 border-t pt-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Optional (Section 3 left blank if absent)
</p>
<div className="flex items-center justify-between">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Optional (Section 3 left blank if absent)
</p>
{ctx.yacht ? (
<div className="inline-flex rounded-md border bg-muted/30 p-0.5 text-[11px]">
<button
type="button"
onClick={() => setDimensionUnit('ft')}
className={
'rounded px-2 py-0.5 transition-colors ' +
(effectiveDimensionUnit === 'ft'
? 'bg-background font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground')
}
aria-pressed={effectiveDimensionUnit === 'ft'}
>
ft
</button>
<button
type="button"
onClick={() => setDimensionUnit('m')}
className={
'rounded px-2 py-0.5 transition-colors ' +
(effectiveDimensionUnit === 'm'
? 'bg-background font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground')
}
aria-pressed={effectiveDimensionUnit === 'm'}
>
m
</button>
</div>
) : null}
</div>
<dl className="space-y-1.5">
{optional.map((row) => (
<PreviewRow key={row.key} label={row.label} value={row.value} edit={row.edit} />
@@ -328,9 +498,8 @@ export function EoiGenerateDialog({
{portSlug && clientId && (
<div className="border-t pt-2 space-y-1">
<p className="text-[11px] text-muted-foreground">
Editing name / nationality / yacht name above patches the underlying records
directly. For phone, address, or to manage linked berths, jump to the canonical
page:
Editing name / yacht name above patches the underlying records directly. For
phone, address, or to manage linked berths, jump to the canonical page:
</p>
<div className="flex flex-wrap gap-3">
<Link
@@ -357,10 +526,132 @@ export function EoiGenerateDialog({
</div>
)}
</div>
) : missingFields.size > 0 && clientId ? (
<div className="rounded-md border border-amber-200 bg-amber-50/60 p-3 space-y-3">
<div className="space-y-0.5">
<p className="text-xs font-medium text-amber-900">
Missing required client details
</p>
<p className="text-[11px] text-amber-800/80">
Fill the fields below they&apos;ll be saved to the client&apos;s record before
the EOI renders.
</p>
</div>
<div className="space-y-3">
{missingFields.has('name') && (
<div className="space-y-1">
<Label htmlFor="fix-name" className="text-xs">
Client full name
</Label>
<Input
id="fix-name"
value={fixDraft.name}
onChange={(e) => setFixDraft((d) => ({ ...d, name: e.target.value }))}
placeholder="Jane Smith"
/>
</div>
)}
{missingFields.has('email') && (
<div className="space-y-1">
<Label htmlFor="fix-email" className="text-xs">
Client email
</Label>
<Input
id="fix-email"
type="email"
value={fixDraft.email}
onChange={(e) => setFixDraft((d) => ({ ...d, email: e.target.value }))}
placeholder="jane@example.com"
/>
</div>
)}
{missingFields.has('address') && (
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="fix-street" className="text-xs">
Street address
</Label>
<Input
id="fix-street"
value={fixDraft.street}
onChange={(e) => setFixDraft((d) => ({ ...d, street: e.target.value }))}
placeholder="123 Marina Way"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="fix-city" className="text-xs">
City
</Label>
<Input
id="fix-city"
value={fixDraft.city}
onChange={(e) => setFixDraft((d) => ({ ...d, city: e.target.value }))}
placeholder="Athens"
/>
</div>
<div className="space-y-1">
<Label htmlFor="fix-postal" className="text-xs">
Postal code
</Label>
<Input
id="fix-postal"
value={fixDraft.postalCode}
onChange={(e) =>
setFixDraft((d) => ({ ...d, postalCode: e.target.value }))
}
placeholder="98000"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="fix-region" className="text-xs">
Region / State <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="fix-region"
value={fixDraft.subdivisionIso}
onChange={(e) =>
setFixDraft((d) => ({ ...d, subdivisionIso: e.target.value }))
}
placeholder="ISO-3166-2 e.g. US-CA"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Country</Label>
<CountryCombobox
value={fixDraft.countryIso}
onChange={(iso) =>
setFixDraft((d) => ({ ...d, countryIso: iso ?? null }))
}
/>
</div>
</div>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
size="sm"
onClick={() => void persistMissingFields()}
disabled={fixSaving}
>
{fixSaving ? 'Saving…' : 'Save & preview EOI'}
</Button>
</div>
</div>
) : (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Couldn&apos;t load the EOI preview data. Try closing and reopening the dialog.
</p>
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 space-y-1">
{ctxErrorMessage ? (
<p className="font-medium">{ctxErrorMessage}</p>
) : (
<p>
Couldn&apos;t load the EOI preview data. Try closing and reopening the dialog.
</p>
)}
</div>
)}
{!ctxLoading && ctx && !requiredMet && (
@@ -374,16 +665,16 @@ export function EoiGenerateDialog({
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<SheetFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isGenerating}>
Cancel
</Button>
<Button onClick={handleGenerate} disabled={!requiredMet || isGenerating || ctxLoading}>
{isGenerating ? 'Generating…' : 'Generate EOI'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -2,9 +2,13 @@
import { apiFetch } from '@/lib/api/client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Check, Clock, X, Mail, Eye, Bell, Send } from 'lucide-react';
import { toastError } from '@/lib/api/toast-error';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface Signer {
id: string;
@@ -14,7 +18,6 @@ interface Signer {
signingOrder: number;
status: string;
signedAt?: string | null;
/** Phase 1+2 lifecycle columns surfaced on the API row. */
invitedAt?: string | null;
openedAt?: string | null;
lastReminderSentAt?: string | null;
@@ -25,28 +28,78 @@ interface SigningProgressProps {
signers: Signer[];
}
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-muted border-muted-foreground/30 text-muted-foreground',
signed: 'bg-green-100 border-green-500 text-green-800',
declined: 'bg-red-100 border-red-500 text-red-800',
};
const STATUS_LABELS: Record<string, string> = {
pending: 'Pending',
signed: 'Signed',
declined: 'Declined',
};
const ROLE_LABELS: Record<string, string> = {
client: 'Client',
signer: 'Signer',
developer: 'Developer',
approver: 'Sales/Approver',
approver: 'Approver',
sales: 'Sales / Approver',
cc: 'CC',
viewer: 'Viewer',
other: 'Other',
};
type Tone = 'pending' | 'opened' | 'signed' | 'declined';
const STATUS_META: Record<string, { label: string; tone: Tone; icon: typeof Check }> = {
pending: { label: 'Pending', tone: 'pending', icon: Clock },
signed: { label: 'Signed', tone: 'signed', icon: Check },
declined: { label: 'Declined', tone: 'declined', icon: X },
};
// Card styling per status — colour-tinted background + left accent stripe.
// `opened` is a runtime-derived tone (pending status + openedAt set) so a
// signer who's actually looked at the doc reads visually distinct from one
// who hasn't yet — the rep can tell at a glance who's stalling vs who
// hasn't engaged at all.
const TONE_STYLES: Record<
Tone,
{
card: string;
accentBar: string;
circle: string;
statusChipBg: string;
statusChipText: string;
iconBubble: string;
}
> = {
pending: {
card: 'bg-card hover:shadow-sm',
accentBar: 'before:bg-amber-300/70',
circle: 'bg-muted text-foreground/70 border-border',
statusChipBg: 'bg-amber-50 border-amber-200',
statusChipText: 'text-amber-800',
iconBubble: 'bg-amber-100 text-amber-700 border-card',
},
opened: {
card: 'bg-sky-50/40 hover:bg-sky-50/60',
accentBar: 'before:bg-sky-400',
circle: 'bg-sky-100 text-sky-800 border-sky-200',
statusChipBg: 'bg-sky-50 border-sky-200',
statusChipText: 'text-sky-800',
iconBubble: 'bg-sky-500 text-white border-card',
},
signed: {
card: 'bg-emerald-50/50 hover:bg-emerald-50/70',
accentBar: 'before:bg-emerald-500',
circle: 'bg-emerald-500 text-white border-emerald-500',
statusChipBg: 'bg-emerald-100 border-emerald-300',
statusChipText: 'text-emerald-800',
iconBubble: 'bg-emerald-500 text-white border-card',
},
declined: {
card: 'bg-rose-50/40 hover:bg-rose-50/60',
accentBar: 'before:bg-rose-500',
circle: 'bg-rose-500 text-white border-rose-500',
statusChipBg: 'bg-rose-100 border-rose-300',
statusChipText: 'text-rose-800',
iconBubble: 'bg-rose-500 text-white border-card',
},
};
/**
* Phase 6 polish: human-readable "X minutes/hours/days ago" for the
* activity badges (invited / opened / last reminded). Uses
* Intl.RelativeTimeFormat so it follows the user's locale.
* "X minutes/hours/days ago" using Intl.RelativeTimeFormat. Returns null
* when the input is null/invalid so callers can skip rendering.
*/
function humanRelative(isoOrNull: string | null | undefined): string | null {
if (!isoOrNull) return null;
@@ -64,14 +117,94 @@ function humanRelative(isoOrNull: string | null | undefined): string | null {
return rtf.format(-days, 'day');
}
/** Compact absolute timestamp for inline display next to relative time.
* Always renders date + time so a signer who signed weeks ago still
* reads as a real moment in the timeline (not just "Signed 12 days
* ago"). Year is omitted for the current calendar year to keep the
* string short; long-running EOIs that span year boundaries see the
* year so "Dec 3, 23:14" doesn't ambiguously mean last year or this. */
function compactAbsolute(isoOrNull: string | null | undefined): string | null {
if (!isoOrNull) return null;
const d = new Date(isoOrNull);
if (Number.isNaN(d.getTime())) return null;
const sameYear = d.getFullYear() === new Date().getFullYear();
const dateOpts: Intl.DateTimeFormatOptions = sameYear
? { month: 'short', day: 'numeric' }
: { year: 'numeric', month: 'short', day: 'numeric' };
const date = d.toLocaleDateString(undefined, dateOpts);
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
return `${date}, ${time}`;
}
/** Tick state every minute so relative-time strings ("Signed 3 min ago")
* re-render without a manual refresh. Returns a number that increments
* every 60s — components read it to invalidate memoization. */
function useMinuteTick(): number {
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 60_000);
return () => clearInterval(id);
}, []);
return tick;
}
/**
* Initials shown in the avatar circle.
*
* Cleans the signer name before deriving initials:
* - Strips the `(was: <orig-email>)` suffix that `applyRecipientRedirect`
* bakes into Documenso recipients when EMAIL_REDIRECT_TO is on.
* - Strips Documenso template placeholder markers like `(placeholder)`.
*
* Then derives the bubble label:
* - Real CRM-source-of-truth name (e.g. "David Mizrahi") → "DM".
* - Single-word role placeholder ("Developer" / "Approver" / "Client")
* → first letter only ("D" / "A" / "C"). Reads as a typed role
* marker rather than a truncated name.
* - Empty string → "?".
*/
function getInitials(name: string): string {
const clean = name
.replace(/\s*\(was:[^)]*\)/i, '')
.replace(/\s*\(placeholder\)/i, '')
.replace(/\s*\(placeholder\b[^)]*\)/i, '')
.trim();
const parts = clean.split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) {
const word = parts[0]!;
return word.slice(0, 1).toUpperCase();
}
return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase();
}
/**
* Cleaned signer display name (matches the initials derivation above).
* The raw `signerName` may carry redirect/placeholder suffixes; this is
* what the card surfaces as the headline. Exported so the document
* detail page can apply the same scrub (#67).
*/
export function cleanSignerName(name: string): string {
return name
.replace(/\s*\(was:[^)]*\)/i, '')
.replace(/\s*\(placeholder\)/i, '')
.replace(/\s*\(placeholder\b[^)]*\)/i, '')
.trim();
}
export function SigningProgress({ documentId, signers }: SigningProgressProps) {
const queryClient = useQueryClient();
// Force a re-render every 60s so the "X minutes ago" labels update
// even when the user leaves the tab open without a webhook arriving.
// Reading `tick` below is enough to wire the dependency.
const tick = useMinuteTick();
void tick;
const sorted = [...signers].sort((a, b) => a.signingOrder - b.signingOrder);
// Phase 6 — surface reminder cooldown / success / error in a toast
// rather than the silent catch the old handler used. Reps need to
// know whether the manual "Resend" actually fired.
// Reminder = follow-up nudge to someone who's already been invited.
// Documenso enforces per-signer rate-limiting (default once / 7 days)
// so this only fires when the cooldown has elapsed.
const remindMutation = useMutation({
mutationFn: (signerId: string) =>
apiFetch<{ data: { sent: boolean; reason?: string } }>(
@@ -89,75 +222,201 @@ export function SigningProgress({ documentId, signers }: SigningProgressProps) {
onError: (err) => toastError(err, 'Failed to send reminder'),
});
// Initial invitation = the first branded email containing the signing
// link. In `manual` send mode (per-port admin setting) the EOI is
// generated without auto-sending, so this is the rep's first chance
// to dispatch. In `auto` mode the initial email goes out at generate
// time and the button is hidden because invitedAt is already stamped.
const inviteMutation = useMutation({
mutationFn: (signerId: string) =>
apiFetch<{ data: { recipientId: string; sent: boolean } }>(
`/api/v1/documents/${documentId}/send-invitation`,
{ method: 'POST', body: { recipientId: signerId } },
),
onSuccess: () => {
toast.success('Invitation sent.');
queryClient.invalidateQueries({ queryKey: ['documents', documentId, 'signers'] });
},
onError: (err) => toastError(err, 'Failed to send invitation'),
});
return (
<div className="flex items-start gap-2">
{sorted.map((signer, idx) => {
<div className="space-y-2.5">
{sorted.map((signer) => {
const baseStatus = STATUS_META[signer.status] ?? STATUS_META.pending!;
// Promote `pending + has been opened` to the `opened` tone so the
// card reads visually distinct from "invited but never clicked".
const tone: Tone =
baseStatus.tone === 'pending' && signer.openedAt ? 'opened' : baseStatus.tone;
const styles = TONE_STYLES[tone];
const StatusIcon =
tone === 'opened' ? Eye : tone === 'signed' ? Check : tone === 'declined' ? X : Clock;
const statusLabel =
tone === 'opened'
? 'Opened'
: tone === 'signed'
? 'Signed'
: tone === 'declined'
? 'Declined'
: 'Pending';
const invitedAgo = humanRelative(signer.invitedAt);
const openedAgo = humanRelative(signer.openedAt);
const remindedAgo = humanRelative(signer.lastReminderSentAt);
return (
<div key={signer.id} className="flex items-center gap-2">
<div className="flex flex-col items-center gap-1">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full border-2 text-xs font-bold ${STATUS_COLORS[signer.status] ?? STATUS_COLORS.pending}`}
>
{signer.signingOrder}
<div key={signer.id} className="relative">
<div
className={cn(
// Left accent stripe via a `::before` so the colour reads
// immediately at the line of the card without competing
// with the avatar circle.
'relative flex items-start gap-3 rounded-lg border p-3 pl-4 transition-colors',
'before:absolute before:left-0 before:top-2 before:bottom-2 before:w-1 before:rounded-r',
styles.card,
styles.accentBar,
)}
>
{/* Avatar circle (initials) with status icon overlay so the
state reads from the avatar itself even before the
status pill is parsed. */}
<div className="relative shrink-0">
<div
className={cn(
'flex h-11 w-11 items-center justify-center rounded-full border-2 text-sm font-bold shadow-sm',
styles.circle,
)}
>
{getInitials(signer.signerName)}
</div>
<div
className={cn(
'absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full border-2 shadow-sm',
styles.iconBubble,
)}
>
<StatusIcon className="size-3" aria-hidden />
</div>
</div>
<div className="max-w-28 text-center">
<p className="truncate text-xs font-medium">{signer.signerName}</p>
<p className="truncate text-xs text-muted-foreground">
{ROLE_LABELS[signer.signerRole] ?? signer.signerRole}
</p>
<p className="text-xs text-muted-foreground">
{STATUS_LABELS[signer.status] ?? signer.status}
</p>
{signer.signedAt && (
<p className="text-xs text-muted-foreground">
{new Date(signer.signedAt).toLocaleDateString('en-GB')}
</p>
)}
{/* Phase 6 polish — activity badges so reps can see at a
glance when each signer was last touched. */}
{signer.status === 'pending' && (invitedAgo || openedAgo || remindedAgo) && (
<div className="mt-1 space-y-0.5">
{invitedAgo && (
<p
className="text-[10px] text-muted-foreground"
title={signer.invitedAt ?? ''}
>
Invited {invitedAgo}
</p>
{/* Name + role + email + status pill + activity */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">
{cleanSignerName(signer.signerName) || signer.signerEmail}
</span>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
styles.statusChipBg,
styles.statusChipText,
)}
{openedAgo && (
<p
className="text-[10px] text-muted-foreground"
title={signer.openedAt ?? ''}
>
Opened {openedAgo}
</p>
)}
{remindedAgo && (
<p
className="text-[10px] text-muted-foreground"
title={signer.lastReminderSentAt ?? ''}
>
Reminded {remindedAgo}
</p>
)}
</div>
)}
{signer.status === 'pending' && (
<button
onClick={() => remindMutation.mutate(signer.id)}
disabled={remindMutation.isPending}
className="mt-1 text-xs text-primary underline hover:no-underline disabled:opacity-50"
>
{remindMutation.isPending ? 'Sending…' : 'Resend'}
</button>
)}
<StatusIcon className="size-2.5" aria-hidden />
{statusLabel}
</span>
<span className="text-[11px] text-muted-foreground">
· {ROLE_LABELS[signer.signerRole] ?? signer.signerRole}
{' · '}
<span className="font-medium">#{signer.signingOrder}</span>
</span>
</div>
<p className="truncate text-xs text-muted-foreground">{signer.signerEmail}</p>
{/* Activity timeline — explicit "Not yet invited" state so
reps in manual-send mode know an action is required.
Once invited, each event surfaces with a precise
timestamp tooltip (the relative-time is the headline). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 pt-0.5 text-[11px] text-muted-foreground">
{!signer.invitedAt && signer.status === 'pending' ? (
<span className="inline-flex items-center gap-1 italic text-amber-700">
<Mail className="size-3" aria-hidden />
Not yet invited
</span>
) : null}
{invitedAgo && (
<span
className="inline-flex items-center gap-1"
title={signer.invitedAt ? new Date(signer.invitedAt).toLocaleString() : ''}
>
<Mail className="size-3" aria-hidden />
Invited {invitedAgo}
</span>
)}
{openedAgo && (
<span
className="inline-flex items-center gap-1"
title={signer.openedAt ? new Date(signer.openedAt).toLocaleString() : ''}
>
<Eye className="size-3" aria-hidden />
Opened {openedAgo}
</span>
)}
{remindedAgo && (
<span
className="inline-flex items-center gap-1"
title={
signer.lastReminderSentAt
? new Date(signer.lastReminderSentAt).toLocaleString()
: ''
}
>
<Bell className="size-3" aria-hidden />
Reminded {remindedAgo}
</span>
)}
{signer.signedAt && (
<span
className="inline-flex items-center gap-1 font-medium text-emerald-700"
title={new Date(signer.signedAt).toLocaleString()}
>
<Check className="size-3" aria-hidden />
Signed {humanRelative(signer.signedAt)}
<span className="font-normal text-emerald-700/70">
· {compactAbsolute(signer.signedAt)}
</span>
</span>
)}
</div>
</div>
{/* Per-signer action button — semantics depend on send state:
• `invitedAt === null` → "Send invitation" (the rep is the
one dispatching the first email; this fires the branded
invite + stamps invitedAt).
• `invitedAt !== null` → "Send reminder" (Documenso-side
nudge, rate-limited per cooldown).
• Signed/declined → no button. */}
{signer.status === 'pending' &&
(signer.invitedAt ? (
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs [&_svg]:size-3"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(signer.id)}
title="Send a follow-up reminder. Rate-limited by Documenso."
>
<Bell />
{remindMutation.isPending && remindMutation.variables === signer.id
? 'Sending…'
: 'Send reminder'}
</Button>
) : (
<Button
variant="default"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs [&_svg]:size-3"
disabled={inviteMutation.isPending}
onClick={() => inviteMutation.mutate(signer.id)}
title="Send the initial signing invitation to this recipient."
>
<Send />
{inviteMutation.isPending && inviteMutation.variables === signer.id
? 'Sending…'
: 'Send invitation'}
</Button>
))}
</div>
{idx < sorted.length - 1 && <div className="mb-6 h-0.5 w-8 shrink-0 bg-border" />}
</div>
);
})}

View File

@@ -156,7 +156,12 @@ export function EmailAccountsList() {
</Button>
<ConfirmationDialog
trigger={
<Button variant="ghost" size="icon" className="text-destructive">
<Button
variant="ghost"
size="icon"
className="text-destructive"
aria-label="Remove account"
>
<Trash2 className="h-4 w-4" aria-hidden />
</Button>
}

View File

@@ -118,8 +118,8 @@ export function FileGrid({
<div className="absolute right-1 top-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-3.5 w-3.5" />
<Button variant="ghost" size="icon" className="h-6 w-6" aria-label="File actions">
<MoreHorizontal className="h-3.5 w-3.5" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">

View File

@@ -75,6 +75,12 @@ interface BerthRecommenderPanelProps {
desiredLengthFt: number | null;
desiredWidthFt: number | null;
desiredDraftFt: number | null;
/**
* Unit the rep originally entered the dimensions in. Drives header
* display so a metric-entered deal doesn't render its dims as ft.
* Falls back to 'ft' when missing.
*/
desiredUnit?: 'ft' | 'm' | null;
}
const TIER_LABELS: Record<Tier, { label: string; tone: string }> = {
@@ -115,11 +121,23 @@ function formatDimensions(
return parts.join(' · ');
}
function formatDesired(length: number | null, width: number | null, draft: number | null): string {
function formatDesired(
length: number | null,
width: number | null,
draft: number | null,
unit: 'ft' | 'm' = 'ft',
): string {
// Storage is canonical-ft (the recommender's SQL ranks against
// berths.length_ft etc.). For display we convert back to whatever the rep
// entered. 0.3048 m/ft exactly.
const toDisplay = (ft: number): string => {
const v = unit === 'm' ? ft * 0.3048 : ft;
return v.toFixed(2).replace(/\.?0+$/, '');
};
const parts: string[] = [];
if (length !== null) parts.push(`${length}ft L`);
if (width !== null) parts.push(`${width}ft W`);
if (draft !== null) parts.push(`${draft}ft D`);
if (length !== null) parts.push(`${toDisplay(length)}${unit} L`);
if (width !== null) parts.push(`${toDisplay(width)}${unit} W`);
if (draft !== null) parts.push(`${toDisplay(draft)}${unit} D`);
return parts.length > 0 ? parts.join(' · ') : 'no dimensions set';
}
@@ -332,11 +350,14 @@ function AmenityFilterForm({ filters, onChange }: AmenityFilterFormProps) {
);
}
// destructure includes `desiredUnit` so the header formatter pivots on the
// rep's entered unit. Falls back to 'ft' (the legacy default) when missing.
export function BerthRecommenderPanel({
interestId,
desiredLengthFt,
desiredWidthFt,
desiredDraftFt,
desiredUnit,
}: BerthRecommenderPanelProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
@@ -364,7 +385,12 @@ export function BerthRecommenderPanel({
apiFetch<{ data: Recommendation[] }>(`/api/v1/interests/${interestId}/recommend-berths`, {
method: 'POST',
body: {
...(showAll ? { topN: 999 } : {}),
// `showAll` opens the floodgates: bumps `topN` AND raises the
// oversize-cap so berths well beyond the strict feasibility window
// surface. Without that second bump the user could end up staring
// at "no berths match" when the test data only had oversized rows
// — exactly the case in our seeded demo port.
...(showAll ? { topN: 999, maxOversizePct: 1000 } : {}),
...(Object.keys(amenityFilters).length > 0 ? { amenityFilters } : {}),
},
}).then((r) => r.data),
@@ -400,7 +426,13 @@ export function BerthRecommenderPanel({
<div className="min-w-0 space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="size-4 text-brand-600" aria-hidden />
Recommendations for {formatDesired(desiredLengthFt, desiredWidthFt, desiredDraftFt)}
Recommendations for{' '}
{formatDesired(
desiredLengthFt,
desiredWidthFt,
desiredDraftFt,
desiredUnit === 'm' ? 'm' : 'ft',
)}
</CardTitle>
{!hasDimensions ? (
<p className="text-xs text-muted-foreground">
@@ -489,9 +521,18 @@ export function BerthRecommenderPanel({
))}
</div>
) : recommendations.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No berths match the current dimensions and filters.
</p>
<div className="py-6 text-center text-sm text-muted-foreground space-y-2">
<p>
{showAll
? 'No berths in the port match these dimensions and filters.'
: 'No berths fit inside the strict oversize tolerance.'}
</p>
{!showAll && (
<Button type="button" size="sm" variant="outline" onClick={() => setShowAll(true)}>
Show oversized matches too
</Button>
)}
</div>
) : (
<div className="space-y-2">
{recommendations.map((rec) => (
@@ -507,7 +548,7 @@ export function BerthRecommenderPanel({
{hasDimensions && recommendations.length > 0 ? (
<div className="flex justify-center pt-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAll((v) => !v)}>
{showAll ? 'Show top recommendations' : 'Show all feasible'}
{showAll ? 'Show top in-tolerance only' : 'Show oversized matches too'}
</Button>
</div>
) : null}

View File

@@ -1,15 +1,17 @@
'use client';
import { Activity } from 'lucide-react';
import { useState } from 'react';
import { Activity, ExternalLink } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { computeDealHealth, type DealHealthInput } from '@/lib/services/deal-health';
import { cn } from '@/lib/utils';
const PULSE_TINT: Record<'cold' | 'warm' | 'hot', string> = {
hot: 'border-emerald-200 bg-emerald-50 text-emerald-800',
warm: 'border-amber-200 bg-amber-50 text-amber-800',
cold: 'border-rose-200 bg-rose-50 text-rose-800',
hot: 'border-emerald-200 bg-emerald-50 text-emerald-800 hover:bg-emerald-100',
warm: 'border-amber-200 bg-amber-50 text-amber-800 hover:bg-amber-100',
cold: 'border-rose-200 bg-rose-50 text-rose-800 hover:bg-rose-100',
};
const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
@@ -19,12 +21,17 @@ const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
};
/**
* Header chip surfacing the rule-based deal-health score. The tooltip
* exposes every signal that contributed to the score so the calculation is
* transparent — stakeholders averse to AI black boxes can read exactly
* which dates / stages drove the verdict.
* Header chip surfacing the rule-based deal-health score.
*
* Click opens a popover with the full per-signal breakdown + plain-language
* explanation of how the score is computed, plus a link to the docs page
* for users who want the deep-dive. Replaces the prior hover-tooltip so
* the content is keyboard-accessible, doesn't time out, and reads on
* touch devices.
*/
export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
const [open, setOpen] = useState(false);
// Closed / archived deals don't get a pulse — UX would be confusing.
if (interest.archivedAt || interest.outcome) return null;
@@ -33,46 +40,84 @@ export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
const label = PULSE_LABEL[health.pulse];
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium cursor-help',
tint,
)}
aria-label={`Deal pulse: ${label}, score ${health.score}/100`}
>
<Activity className="size-3" aria-hidden />
{label} · {health.score}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p className="font-semibold mb-1.5">
Deal pulse {label} ({health.score}/100)
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors cursor-pointer',
tint,
)}
aria-label={`Deal pulse: ${label}, score ${health.score}/100. Click for breakdown.`}
>
<Activity className="size-3" aria-hidden />
{label} · {health.score}
</button>
</PopoverTrigger>
<PopoverContent side="bottom" align="start" className="w-80 p-4 space-y-3">
<div>
<p className="text-sm font-semibold">
Deal pulse {label} ({health.score} / 100)
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
How likely this deal is to keep moving forward, scored from 0 to 100.
</p>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
What pushed the score
</p>
{health.signals.length === 0 ? (
<p className="text-xs">
Baseline score (50) nothing notable yet. Log contact or progress the stage to move
the dial.
<p className="mt-1 text-xs text-muted-foreground">
Nothing notable yet the score is sitting at the baseline (50). Log a contact,
progress the stage, or send a signing request and you&apos;ll see the dial move.
</p>
) : (
<ul className="space-y-1 text-xs">
<ul className="mt-1.5 space-y-1.5 text-xs">
{health.signals.map((s) => (
<li key={s.id} className="flex gap-2">
<span className={s.delta > 0 ? 'text-emerald-300' : 'text-rose-300'}>
<li key={s.id} className="flex items-start gap-2">
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold tabular-nums',
s.delta > 0 ? 'bg-emerald-100 text-emerald-800' : 'bg-rose-100 text-rose-800',
)}
>
{s.delta > 0 ? `+${s.delta}` : s.delta}
</span>
<span>{s.detail}</span>
<span className="text-foreground/90">{s.detail}</span>
</li>
))}
</ul>
)}
<p className="mt-2 text-[10px] opacity-70">
Rule-based. Every signal traces to a date or stage you can see no AI.
</div>
<div className="rounded-md bg-muted/40 p-2.5 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground/80">How this is calculated</p>
<p className="mt-0.5">
Every signal above traces to a specific date or pipeline stage on this deal. Recent
contact + recent stage movement push the score up; long silences and outdated documents
pull it down.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center justify-between gap-2">
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
Close
</Button>
<Button asChild variant="link" size="sm" className="text-xs">
<a
href="/docs/deal-pulse"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1"
>
Full guide
<ExternalLink className="size-3" aria-hidden />
</a>
</Button>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -211,7 +211,7 @@ export function InlineStagePicker({
const isOverride = !canTransitionStage(stage, target);
mutation.mutate({
next: target,
reason: isOverride ? 'Reverted to Open and unlinked all berths' : null,
reason: isOverride ? 'Reverted to New Enquiry and unlinked all berths' : null,
});
setOpenConfirmTarget(null);
} catch (err) {
@@ -226,7 +226,7 @@ export function InlineStagePicker({
setPendingStage(target);
mutation.mutate({
next: target,
reason: isOverride ? 'Reverted to Open (kept linked berths)' : null,
reason: isOverride ? 'Reverted to New Enquiry (kept linked berths)' : null,
});
setOpenConfirmTarget(null);
}
@@ -463,12 +463,13 @@ export function InlineStagePicker({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset this deal to Open?</AlertDialogTitle>
<AlertDialogTitle>Reset this deal to New Enquiry?</AlertDialogTitle>
<AlertDialogDescription>
This interest has {linkedBerthCount} linked{' '}
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to <strong>Open</strong>{' '}
usually means restarting the lead keeping the berth links would leave them showing
as under offer on the public map for a deal that&apos;s no longer in progress.
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to{' '}
<strong>New Enquiry</strong> usually means restarting the lead keeping the berth
links would leave them showing as under offer on the public map for a deal that&apos;s
no longer in progress.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">

View File

@@ -121,8 +121,13 @@ export function getInterestColumns({
const notesCount = row.original.notesCount ?? 0;
return (
<div className="flex items-center gap-1.5 min-w-0">
{/* Client cell on the Interests list links to the INTEREST detail
— not the client page. Users browsing the interest list want
the deal context, not the underlying client. The interest
detail header has its own "Client page" deep-link if the rep
actually wants the client surface. */}
<Link
href={`/${portSlug}/clients/${row.original.clientId}`}
href={`/${portSlug}/interests/${row.original.id}`}
className="truncate font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>

View File

@@ -13,6 +13,7 @@ import {
Mail,
Phone,
AlarmClock,
User,
} from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import Link from 'next/link';
@@ -316,8 +317,28 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
client without leaving the interest workspace. Resolved from
the linked client's primary contact channels (server-side
fetch in getInterestById). */}
{interest.clientPrimaryEmail || interest.clientPrimaryPhone || whatsappNumber ? (
{interest.clientPrimaryEmail ||
interest.clientPrimaryPhone ||
whatsappNumber ||
interest.clientId ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientId ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${interest.clientId}` as any}
aria-label="Open client page"
>
<User />
Client page
</Link>
</Button>
) : null}
{interest.clientPrimaryEmail ? (
<Button
asChild

View File

@@ -39,6 +39,7 @@ interface InterestData {
id: string;
content: string;
authorId: string;
authorName: string | null;
createdAt: string;
} | null;
berthId: string | null;

View File

@@ -5,9 +5,13 @@ import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
ArrowDown,
CheckCircle2,
Download,
Eye,
ExternalLink,
FileSignature,
GitBranch,
Loader2,
RefreshCw,
Upload,
@@ -18,12 +22,14 @@ import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { EoiCancelDialog } from '@/components/documents/eoi-cancel-dialog';
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import {
DOCUMENT_STATUS_ACTIVE,
DOCUMENT_STATUS_LABELS,
@@ -45,6 +51,10 @@ interface DocumentRow {
status: DocumentStatus;
createdAt: string;
signers?: Array<{ status: string }>;
/** Null while the EOI is in flight; populated by the completion webhook
* once the fully-signed PDF has been downloaded from Documenso and
* stored in MinIO/filesystem. Drives the "Download signed PDF" CTA. */
signedFileId?: string | null;
}
interface DocumentSigner {
@@ -141,6 +151,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
<span className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString()}
</span>
{d.signedFileId ? <SignedPdfActions fileId={d.signedFileId} /> : null}
{portSlug && (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -186,25 +197,56 @@ function ActiveEoiCard({
}) {
const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const [cancelOpen, setCancelOpen] = useState(false);
const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', doc.id, 'signers'],
queryFn: () => apiFetch<{ data: DocumentSigner[] }>(`/api/v1/documents/${doc.id}/signers`),
refetchInterval: 30_000,
// Polling backstop in case a webhook event misses the open browser
// (transient socket drop, user in a different tab when the event
// fires, cloudflared tunnel hiccup). Primary update path is
// socket-driven via `useRealtimeInvalidation` below — this just
// bounds the worst-case staleness to ~5s.
refetchInterval: 5_000,
});
// Surface the per-port signing-order preference (Sequential vs Concurrent
// = Parallel in Documenso parlance) so the team knows what order recipients
// will receive the signing chain in.
const { data: signingDefaultsRes } = useQuery<{
data: { signingOrder: 'PARALLEL' | 'SEQUENTIAL' };
}>({
queryKey: ['documents', 'signing-defaults'],
queryFn: () =>
apiFetch<{ data: { signingOrder: 'PARALLEL' | 'SEQUENTIAL' } }>(
'/api/v1/documents/signing-defaults',
),
staleTime: 60_000,
});
const signingOrder = signingDefaultsRes?.data?.signingOrder ?? 'PARALLEL';
const signers = signersRes?.data ?? [];
const signedCount = signers.filter((s) => s.status === 'signed').length;
const totalCount = signers.length;
const allSigned = totalCount > 0 && signedCount === totalCount;
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/cancel`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success('EOI cancelled.');
},
onError: (err) => toastError(err),
// Treat "all signers complete" as the finalised UX even when the
// DOCUMENT_COMPLETED webhook hasn't landed yet — defends against the
// gap between the last per-recipient sign event and the document-level
// completion event. The badge below flips to "Finalising" so the rep
// sees the in-flight state rather than a stale PARTIALLY_SIGNED chip.
const effectivelyCompleted = doc.status === 'completed' || allSigned;
const isAwaitingFinalisation = allSigned && doc.status !== 'completed';
// Real-time push: invalidate the signers query the moment a webhook
// fires `document:signer:*` so the card flips state without waiting
// for the 30s refetch interval. Same for `document:completed` so the
// "all signed" footer chip appears as soon as the last signer finishes.
useRealtimeInvalidation({
'document:signer:signed': [['documents', doc.id, 'signers'], ['documents']],
'document:signer:opened': [['documents', doc.id, 'signers']],
'document:completed': [['documents', doc.id, 'signers'], ['documents']],
'document:signer:rejected': [['documents', doc.id, 'signers'], ['documents']],
});
const remindAllMutation = useMutation({
@@ -223,12 +265,45 @@ function ActiveEoiCard({
<div className="flex items-center gap-2 flex-wrap">
<FileSignature className="size-4 text-foreground" aria-hidden />
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
<StatusBadge status={doc.status} />
{isAwaitingFinalisation ? (
<Badge variant="outline" className="border-sky-300 bg-sky-50 text-sky-800">
<Loader2 className="mr-1 size-3 animate-spin" aria-hidden /> Finalising
</Badge>
) : (
<StatusBadge status={doc.status} />
)}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>
Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '}
{totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}
</span>
{/* Signing-order badge — tells the team whether recipients
must sign in order or can sign concurrently. Drives off
the per-port setting; for v2 templates the template's
stored order wins server-side and we still surface our
local preference here so the UI matches what was sent. */}
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
signingOrder === 'SEQUENTIAL'
? 'border-indigo-200 bg-indigo-50 text-indigo-800'
: 'border-sky-200 bg-sky-50 text-sky-800',
)}
title={
signingOrder === 'SEQUENTIAL'
? 'Signers receive the invite chain one at a time — each must sign before the next is emailed.'
: 'All signers receive the invite at once and can sign in any order.'
}
>
{signingOrder === 'SEQUENTIAL' ? (
<ArrowDown className="size-2.5" aria-hidden />
) : (
<GitBranch className="size-2.5" aria-hidden />
)}
{signingOrder === 'SEQUENTIAL' ? 'Sequential' : 'Concurrent'}
</span>
</div>
<p className="text-xs text-muted-foreground">
Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '}
{totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{portSlug && (
@@ -242,7 +317,8 @@ function ActiveEoiCard({
</Link>
</Button>
)}
{!allSigned && (
{/* Remind all hides once every signer is signed — no-one to nudge. */}
{!effectivelyCompleted && (
<Button
variant="outline"
size="sm"
@@ -278,47 +354,147 @@ function ActiveEoiCard({
)}
</div>
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5">
<AlertTriangle className="size-3 text-amber-600" aria-hidden />
Reminders are rate-limited (max once per 7 days per signer).
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onUploadSigned}
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
>
<Upload />
Upload paper-signed copy
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={cancelMutation.isPending}
onClick={async () => {
const ok = await confirm({
title: 'Cancel EOI',
description: 'Signers will no longer be able to sign.',
confirmLabel: 'Cancel EOI',
});
if (ok) cancelMutation.mutate();
}}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel EOI
</Button>
{/* Signed-PDF inline preview, shown once the completion webhook has
downloaded + stored the final signed file. Defends in two ways:
(a) status === 'completed' (the ideal path), (b) doc reports a
signedFileId even when status hasn't flipped yet. */}
{doc.signedFileId ? (
<div className="mt-3 rounded-lg border bg-background p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signed document
</h3>
<SignedPdfActions fileId={doc.signedFileId} />
</div>
<SignedPdfPreview fileId={doc.signedFileId} />
</div>
</footer>
) : null}
{/* Footer hides once every signer is signed: Cancel + Remind reminder
stop making sense, and the rep's natural next action is to view
the signed PDF (rendered above) or open the linked document
detail page. Upload-paper-signed-copy stays available — useful
for in-person sign-out workflows even after the digital flow. */}
{!effectivelyCompleted ? (
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5">
<AlertTriangle className="size-3 text-amber-600" aria-hidden />
Reminders are rate-limited (max once per 7 days per signer).
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onUploadSigned}
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
>
<Upload />
Upload paper-signed copy
</Button>
{/* Regenerate is only safe when no one has signed yet — once
signatures are on the doc, the rep must go through the
cancel-with-notify path so collaborators learn about the
discard. */}
{signedCount === 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={async () => {
const ok = await confirm({
title: 'Regenerate this EOI?',
description:
'The current envelope will be voided silently — no recipients will be notified — and the generate dialog will re-open so you can rebuild.',
confirmLabel: 'Regenerate',
});
if (ok) {
try {
await apiFetch(`/api/v1/documents/${doc.id}/cancel`, {
method: 'POST',
body: { reason: 'regenerated', notifyRecipients: [] },
});
queryClient.invalidateQueries({
predicate: (q) => q.queryKey[0] === 'documents',
});
toast.success('EOI voided. Regenerate now.');
} catch (err) {
toastError(err);
}
}
}}
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
title="Void the current envelope (no notifications) and rebuild from scratch."
>
<RefreshCw />
Regenerate
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setCancelOpen(true)}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel EOI
</Button>
</div>
</footer>
) : null}
{confirmDialog}
<EoiCancelDialog
documentId={doc.id}
signers={signers}
open={cancelOpen}
onOpenChange={setCancelOpen}
/>
</section>
);
}
/**
* Inline iframe preview of a signed PDF. Fetches a short-lived presigned
* URL from `/api/v1/files/[id]/download` and renders the browser's native
* PDF viewer inside the EOI card. Constrained to a fixed max-height so a
* tall multi-page document doesn't blow out the page; the rep can open
* the file in a new tab via the alongside View button for full-screen.
*/
function SignedPdfPreview({ fileId }: { fileId: string }) {
const { data, isLoading, isError } = useQuery<{ data: { url: string; filename: string } }>({
queryKey: ['files', fileId, 'download-url'],
queryFn: () =>
apiFetch<{ data: { url: string; filename: string } }>(`/api/v1/files/${fileId}/download`),
// Presigned URL TTLs vary per backend — refresh well before they
// expire so a long-open card doesn't suddenly 403. 4 minutes is
// comfortably below the 5-minute MinIO default.
staleTime: 4 * 60_000,
});
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center text-xs text-muted-foreground">
<Loader2 className="mr-2 size-3 animate-spin" aria-hidden /> Loading preview
</div>
);
}
if (isError || !data?.data.url) {
return (
<p className="text-xs italic text-muted-foreground">
Preview unavailable use the Download button to grab the signed PDF.
</p>
);
}
return (
<iframe
src={data.data.url}
title="Signed EOI preview"
className="h-[560px] w-full rounded border bg-white"
/>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyEoiState({
@@ -368,3 +544,47 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
</Badge>
);
}
/**
* View + Download buttons for a signed PDF. `/api/v1/files/[id]/download`
* returns a presigned URL in JSON (rather than streaming the file), so
* we fetch the URL via `apiFetch` and then either open it in a new tab
* (View) or trigger a programmatic download (Download).
*/
function SignedPdfActions({ fileId }: { fileId: string }) {
const open = async (mode: 'view' | 'download') => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${fileId}/download`,
);
if (mode === 'view') {
window.open(res.data.url, '_blank', 'noopener,noreferrer');
} else {
const a = document.createElement('a');
a.href = res.data.url;
a.download = res.data.filename;
a.click();
}
} catch (err) {
toastError(err, 'Failed to fetch signed PDF');
}
};
return (
<>
<button
type="button"
onClick={() => open('view')}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Eye className="size-3" aria-hidden /> View
</button>
<button
type="button"
onClick={() => open('download')}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Download className="size-3" aria-hidden /> Download
</button>
</>
);
}

View File

@@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
@@ -120,6 +121,26 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const [createYachtOpen, setCreateYachtOpen] = useState(false);
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
// Auto-fill pipelineStage + leadCategory based on whether a berth was
// picked. Once the rep manually edits either field we stop touching it,
// so we don't fight the user. Edit mode skips the auto-fill entirely —
// changing the berth on an in-flight interest shouldn't silently demote
// it back to "enquiry".
const userTouchedStage = useRef(false);
const userTouchedCategory = useRef(false);
useEffect(() => {
if (isEdit) return;
const hasBerth = !!selectedBerthId;
if (!userTouchedStage.current) {
setValue('pipelineStage', hasBerth ? 'qualified' : 'enquiry');
}
if (!userTouchedCategory.current) {
setValue('leadCategory', hasBerth ? 'specific_qualified' : 'general_interest');
}
// setValue is stable from RHF; isEdit doesn't change after mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBerthId]);
function requestClose() {
if (isDirty && !isSubmitting && !mutation.isPending) {
setDiscardConfirmOpen(true);
@@ -146,6 +167,39 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
]
: undefined;
// Probe whether the selected client (or their member companies) owns any
// yachts. When zero, the form swaps the picker for an "Add yacht" CTA so
// reps don't get stuck on an empty dropdown wondering what to do. We hit
// the same autocomplete endpoint the picker uses but with an empty query
// to get the full unfiltered list scoped to the owner filter.
// Tags-availability probe — drives whether the whole Tags section
// (label + picker) renders. The picker itself returns null when empty,
// but the wrapping label/separator needed the same gate.
const { data: tagsList } = useQuery<{ data: Array<{ id: string }> }>({
queryKey: ['tag-availability-for-interest-form'],
queryFn: () => apiFetch('/api/v1/tags/options'),
staleTime: 60_000,
});
const tagsAvailable = (tagsList?.data?.length ?? 0) > 0;
const { data: yachtCount } = useQuery<{ data: Array<{ id: string }> }>({
queryKey: [
'yacht-count-for-interest-form',
selectedClientId,
memberCompanyIds.sort().join(','),
],
queryFn: () => {
const params = new URLSearchParams({ q: '' });
if (selectedClientId) params.set('ownerClientId', selectedClientId);
if (memberCompanyIds.length > 0) {
params.set('ownerCompanyIds', memberCompanyIds.join(','));
}
return apiFetch(`/api/v1/yachts/autocomplete?${params.toString()}`);
},
enabled: !!selectedClientId,
});
const hasAnyYachts = (yachtCount?.data?.length ?? 0) > 0;
const {
options: clientOptions,
isLoading: clientsLoading,
@@ -230,10 +284,27 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
method: 'POST',
body: enriched,
});
// Materialise any additional berths the rep picked in the multi-
// select. The first (primary) berth is already linked via the create
// payload's berthId; everything else gets a follow-up POST to the
// junction endpoint. We fire them in parallel — failure on one is
// surfaced as a toast but doesn't roll back the interest creation.
if (additionalBerthIds.length > 0) {
await Promise.allSettled(
additionalBerthIds.map((berthId) =>
apiFetch(`/api/v1/interests/${res.data.id}/berths`, {
method: 'POST',
body: { berthId, isSpecificInterest: false },
}),
),
);
}
return { id: res.data.id, created: true };
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['interests'] });
// M-U10: confirm the write landed.
toast.success(result.created ? 'Interest created' : 'Interest updated');
onOpenChange(false);
// F20: navigate to the new interest's detail page so the rep can
// start the workflow immediately. Edits stay in place — no point
@@ -254,6 +325,15 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const selectedClient = clientOptions.find((c) => c.value === selectedClientId);
const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId);
// Additional berths (beyond the primary `berthId`) accumulated by the
// multi-select. On create, after the interest row exists, each id here
// gets a follow-up POST /interests/{id}/berths so they show up in the
// linked-berths list with isPrimary=false. The primary berth (the form's
// `berthId`) is materialised by the standard create path. Edit mode
// doesn't surface this — managing extra berths post-create happens on
// the interest detail page's linked-berths section.
const [additionalBerthIds, setAdditionalBerthIds] = useState<string[]>([]);
return (
<Sheet
open={open}
@@ -337,7 +417,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</div>
<div className="space-y-1">
<Label>Berth (optional)</Label>
<Label>Berths (optional)</Label>
<Popover open={berthOpen} onOpenChange={setBerthOpen} modal>
<PopoverTrigger asChild>
<Button
@@ -346,10 +426,20 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
aria-expanded={berthOpen}
className={cn(
'w-full justify-between',
!selectedBerthId && 'text-muted-foreground',
!selectedBerthId &&
additionalBerthIds.length === 0 &&
'text-muted-foreground',
)}
>
{selectedBerth?.label ?? interest?.berthMooringNumber ?? 'Select berth...'}
<span className="truncate">
{selectedBerthId
? `${selectedBerth?.label ?? interest?.berthMooringNumber ?? selectedBerthId}${
additionalBerthIds.length > 0
? ` + ${additionalBerthIds.length} more`
: ''
}`
: 'Select berths…'}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
</Button>
</PopoverTrigger>
@@ -362,43 +452,80 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</CommandEmpty>
<CommandGroup>
<CommandItem
value=""
value="__clear__"
onSelect={() => {
setValue('berthId', undefined);
setBerthOpen(false);
setAdditionalBerthIds([]);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
!selectedBerthId ? 'opacity-100' : 'opacity-0',
!selectedBerthId && additionalBerthIds.length === 0
? 'opacity-100'
: 'opacity-0',
)}
/>
None
</CommandItem>
{berthOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(val) => {
setValue('berthId', val);
setBerthOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedBerthId === option.value ? 'opacity-100' : 'opacity-0',
{berthOptions.map((option) => {
const isPrimary = selectedBerthId === option.value;
const isAdditional = additionalBerthIds.includes(option.value);
const isSelected = isPrimary || isAdditional;
return (
<CommandItem
key={option.value}
value={option.value}
onSelect={(val) => {
// Multi-select toggle. First pick becomes
// the primary berthId (the one the API uses
// for templates / list views). Subsequent
// picks go into additionalBerthIds and are
// materialised via POST /berths after the
// interest is created.
if (isPrimary) {
// Demote primary; promote first additional
// (if any) to primary so the deal still
// has one primary berth.
const promote = additionalBerthIds[0];
setValue('berthId', promote ?? undefined);
setAdditionalBerthIds(additionalBerthIds.slice(1));
} else if (isAdditional) {
setAdditionalBerthIds(
additionalBerthIds.filter((id) => id !== val),
);
} else if (!selectedBerthId) {
setValue('berthId', val);
} else {
setAdditionalBerthIds([...additionalBerthIds, val]);
}
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0',
)}
/>
<span className="flex-1">{option.label}</span>
{isPrimary && (
<span className="ml-2 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
primary
</span>
)}
/>
{option.label}
</CommandItem>
))}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
Pick one or more berths. The first becomes the primary berth (used in templates and
list views); the rest get linked as alternates and can be promoted later from the
interest detail page.
</p>
</div>
<div className="space-y-2">
@@ -406,7 +533,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<Label>
Yacht <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
{selectedClientId && (
{selectedClientId && hasAnyYachts && (
<Button
type="button"
variant="ghost"
@@ -419,15 +546,34 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</Button>
)}
</div>
<YachtPicker
value={selectedYachtId ?? null}
onChange={(id) => setValue('yachtId', id ?? undefined)}
ownerFilter={yachtOwnerFilter}
disabled={!selectedClientId}
placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'}
/>
{/* Hide the picker entirely when the selected client has no
yachts on file (and isn't linked to a company with yachts).
An empty dropdown is a dead-end UX — the only useful action
in that state is "create a yacht for this client". */}
{selectedClientId && !hasAnyYachts ? (
<div className="rounded-md border border-dashed bg-muted/40 p-3 text-sm">
<p className="text-muted-foreground">This client has no yachts on file yet.</p>
<Button
type="button"
size="sm"
className="mt-2"
onClick={() => setCreateYachtOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" aria-hidden />
Add a yacht for this client
</Button>
</div>
) : (
<YachtPicker
value={selectedYachtId ?? null}
onChange={(id) => setValue('yachtId', id ?? undefined)}
ownerFilter={yachtOwnerFilter}
disabled={!selectedClientId}
placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'}
/>
)}
<p className="text-xs text-muted-foreground">
Required before the interest can leave the &quot;Open&quot; stage.
Required before the interest can leave the New Enquiry stage.
{memberCompanyIds.length > 0 && (
<>
{' '}
@@ -450,10 +596,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<div className="space-y-1">
<Label>Stage</Label>
<Select
value={watch('pipelineStage') ?? 'open'}
onValueChange={(v) =>
setValue('pipelineStage', v as (typeof PIPELINE_STAGES)[number])
}
value={watch('pipelineStage') ?? 'enquiry'}
onValueChange={(v) => {
userTouchedStage.current = true;
setValue('pipelineStage', v as (typeof PIPELINE_STAGES)[number]);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select stage" />
@@ -472,12 +619,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<Label>Lead Category</Label>
<Select
value={watch('leadCategory') ?? ''}
onValueChange={(v) =>
onValueChange={(v) => {
userTouchedCategory.current = true;
setValue(
'leadCategory',
v ? (v as (typeof LEAD_CATEGORIES)[number]) : undefined,
)
}
);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
@@ -583,13 +731,19 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
)}
</div>
<Separator />
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
</div>
{/* Tags — TagPicker itself returns null when the port has no tags
configured AND the form has nothing selected. We hide the
wrapping label + separator in that same case so an empty
"Tags" header doesn't sit in the form. */}
{(tagIds.length > 0 || tagsAvailable) && (
<>
<Separator />
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
</div>
</>
)}
<SheetFooter>
<Button type="button" variant="outline" onClick={requestClose}>

View File

@@ -12,6 +12,9 @@ import {
TagsIcon,
} from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
@@ -67,6 +70,13 @@ export function InterestList() {
const { confirm, dialog: confirmDialog } = useConfirmation();
const { viewMode, setViewMode } = usePipelineStore();
// M-U14: surface the page title in the mobile topbar.
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Interests', showBackButton: false });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
// Force the list view at mobile widths even when the user previously
// toggled the kanban from desktop — the board is desktop-only.
useEffect(() => {
@@ -143,7 +153,7 @@ export function InterestList() {
queryClient.invalidateQueries({ queryKey: ['interests'] });
const s = res.data.summary;
if (s.failed > 0) {
alert(
toast.warning(
`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed — check the activity log.`,
);
}
@@ -230,26 +240,30 @@ export function InterestList() {
placeholder="Filter by tag / event…"
/>
</div>
{/* Columns + saved views are table-only concepts; the kanban
* always shows the same compact card across every stage so
* hiding both controls in board mode keeps the toolbar honest. */}
{viewMode === 'table' ? (
<>
<SavedViewsDropdown
entityType="interests"
onApplyView={(savedFilters) => {
setAllFilters(savedFilters);
}}
/>
<ColumnPicker
columns={INTEREST_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
</>
) : null}
<StageLegend />
{/* Right-aligned toolbar group: saved views + column picker + stage
legend. `ml-auto` pushes the group to the right edge so it sits
flush with where the table extends to on desktop. Wraps to a new
line on narrow viewports because the outer container is
`flex-wrap`. Kanban view hides the table-only controls. */}
<div className="ml-auto flex flex-wrap items-center gap-2">
{viewMode === 'table' ? (
<>
<SavedViewsDropdown
entityType="interests"
onApplyView={(savedFilters) => {
setAllFilters(savedFilters);
}}
/>
<ColumnPicker
columns={INTEREST_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
</>
) : null}
<StageLegend />
</div>
</div>
<SaveViewDialog

View File

@@ -7,6 +7,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react';
import { parsePhone } from '@/lib/i18n/phone';
import type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -14,9 +16,24 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { RecommendationList } from '@/components/interests/recommendation-list';
// Legacy `RecommendationList` removed 2026-05-15 — replaced by the same
// rule-based `BerthRecommenderPanel` (already imported above) used on the
// Overview tab so the scoring + UI stay consistent. The old component
// pulled stale "AI"-style rows that all scored 50% because the underlying
// generate endpoint was orphaned.
import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-panel';
import { LinkedBerthsList } from '@/components/interests/linked-berths-list';
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
// Shared parser for the interest's stringly-typed numeric columns (Drizzle
// returns Postgres numeric as string). Used by both the Overview milestone
// classifier and the Recommendations tab so the conversion stays
// consistent regardless of entry point.
function toNum(v: string | null | undefined): number | null {
if (v === null || v === undefined) return null;
const n = parseFloat(v);
return Number.isFinite(n) ? n : null;
}
import { InterestTimeline } from '@/components/interests/interest-timeline';
import { WonStatusPanel } from '@/components/interests/won-status-panel';
import { SupplementalInfoRequestButton } from '@/components/interests/supplemental-info-request-button';
@@ -65,6 +82,10 @@ interface InterestTabsOptions {
desiredLengthFt?: string | null;
desiredWidthFt?: string | null;
desiredDraftFt?: string | null;
/** Unit the rep originally entered the dims in — drives the
* recommender header's display so a metric-entered deal doesn't
* render as ft. The three columns share an entry unit in practice. */
desiredLengthUnit?: string | null;
leadCategory: string | null;
source: string | null;
eoiStatus: string | null;
@@ -83,6 +104,23 @@ interface InterestTabsOptions {
contractDocStatus?: string | null;
/** Final outcome — 'won' surfaces the wrap-up checklist panel. */
outcome?: string | null;
/** Interest id — needed for the queryClient.invalidateQueries calls
* that fire after an inline contact edit. The parent passes this
* through `interestId` already, but the inline-edit handlers below
* use the structured object form. */
id: string;
/** Linked client id — required for the PATCH /api/v1/clients/[id]/
* contacts/[contactId] flow that the inline Email + Phone editors
* use. Null on an unlinked interest (rare but possible). */
clientId: string | null;
/** Primary contact channels resolved from the linked client record by
* getInterestById — both editable inline. The contact row's id is
* exposed alongside so the inline editor can PATCH the right row
* without an extra fetch. */
clientPrimaryEmail?: string | null;
clientPrimaryEmailContactId?: string | null;
clientPrimaryPhone?: string | null;
clientPrimaryPhoneContactId?: string | null;
dateFirstContact: string | null;
dateLastContact: string | null;
dateEoiSent: string | null;
@@ -105,6 +143,7 @@ interface InterestTabsOptions {
id: string;
content: string;
authorId: string;
authorName: string | null;
createdAt: string;
} | null;
tags?: Array<{ id: string; name: string; color: string }>;
@@ -476,12 +515,21 @@ function FutureMilestones({
function OverviewTab({
interestId,
interest,
clientId,
}: {
interestId: string;
interest: InterestTabsOptions['interest'];
clientId: string | null;
}) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
// QueryClient lifted to the top of the tab so the inline-edit email +
// phone handlers below can invalidate ['interest', id] on success.
const queryClient = useQueryClient();
// Lift the EOI generate dialog into the Overview so the milestone card
// can launch it inline — same dialog the dedicated EOI tab uses, so the
// editing/confirmation flow is identical regardless of entry point.
const [eoiGenerateOpen, setEoiGenerateOpen] = useState(false);
const mutation = useInterestPatch(interestId);
const stageMutation = useStageMutation(interestId);
const { confirm, dialog: confirmDialog } = useConfirmation();
@@ -530,10 +578,8 @@ function OverviewTab({
// genuinely skips stages — the click then routes through the same
// override-confirm flow as the inline stage picker.
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
const eoiIdx = PIPELINE_STAGES.indexOf('eoi');
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
const contractIdx = PIPELINE_STAGES.indexOf('contract');
// Sub-status carries the "is this milestone's doc actually signed?" bit
// for the doc-bearing stages (eoi / reservation / contract). A milestone
@@ -543,55 +589,41 @@ function OverviewTab({
const reservationSigned = interest.reservationDocStatus === 'signed';
const contractSigned = interest.contractDocStatus === 'signed';
// Berth Interest milestone — first thing the rep needs to capture
// (especially for general_interest leads). Completes the moment ANY
// berth is linked to the interest via the junction. While unset, it
// sits as the "current" milestone unless the deal has already moved
// past EOI sent (in which case the rep clearly didn't need a berth
// pinned first, so we mark it 'past' implicitly).
// 2026-05-15: rewrote phase classification so the Overview always
// surfaces a CURRENT milestone for the rep, regardless of where the
// pipeline-stage column happens to sit. The previous "phase === current
// only when stageIdx exactly matches" rule produced an empty Overview
// for the qualified + nurturing stages (no milestone marked current, EOI
// hidden under "show upcoming") — exactly the gap the rep complained
// about. New model: the FIRST not-yet-complete milestone in the fixed
// berth_interest → eoi → reservation → deposit → contract order is
// 'current'. Everything before is 'past'; everything after is 'future'.
const hasLinkedBerth = (interest.linkedBerthCount ?? 0) > 0;
const berthInterestPhase: Phase = hasLinkedBerth
? 'past'
: stageIdx === -1 || stageIdx >= eoiIdx
? 'past'
: 'current';
const eoiPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx > eoiIdx || (stageIdx === eoiIdx && eoiSigned)
? 'past'
: stageIdx === eoiIdx
? 'current'
: 'future';
const reservationPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx > reservationIdx || (stageIdx === reservationIdx && reservationSigned)
? 'past'
: stageIdx === reservationIdx
? 'current'
: 'future';
// Deposit becomes 'current' once the reservation is signed; auto-advance
// moves it to 'past' the moment the running deposit total catches up.
const depositPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx > depositIdx
? 'past'
: stageIdx === depositIdx
? 'past'
: stageIdx === reservationIdx && reservationSigned
? 'current'
: 'future';
const contractPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx === contractIdx && contractSigned
? 'past'
: stageIdx === contractIdx
? 'current'
: 'future';
const reservationStageReached = stageIdx >= reservationIdx;
const depositComplete = stageIdx > depositIdx;
const milestoneCompletion = {
berth_interest: hasLinkedBerth,
eoi: eoiSigned,
reservation: reservationSigned,
deposit: depositComplete,
contract: contractSigned,
} as const;
const order = ['berth_interest', 'eoi', 'reservation', 'deposit', 'contract'] as const;
const firstIncompleteKey = order.find((k) => !milestoneCompletion[k]) ?? null;
const phaseFor = (k: (typeof order)[number]): Phase => {
if (milestoneCompletion[k]) return 'past';
if (k === firstIncompleteKey) return 'current';
return 'future';
};
const berthInterestPhase: Phase = phaseFor('berth_interest');
const eoiPhase: Phase = phaseFor('eoi');
const reservationPhase: Phase = phaseFor('reservation');
const depositPhase: Phase = phaseFor('deposit');
const contractPhase: Phase = phaseFor('contract');
// Payments-section visibility: useless real estate until a deposit is
// actually expected (reservation stage onwards). Reps on enquiry /
// qualified / nurturing should see stage-guidance instead.
const showPaymentsSection = reservationStageReached;
const activeMilestone: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract' | null =
berthInterestPhase === 'current'
@@ -606,11 +638,8 @@ function OverviewTab({
? 'contract'
: null;
const toNum = (v: string | null | undefined): number | null => {
if (v === null || v === undefined) return null;
const n = parseFloat(v);
return Number.isFinite(n) ? n : null;
};
// toNum extracted to module scope so the Recommendations tab can use it
// alongside the Overview tab. See top of file.
const milestones: Array<{
key: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract';
@@ -659,7 +688,11 @@ function OverviewTab({
label: 'EOI sent',
date: interest.dateEoiSent,
advanceStage: 'eoi',
actionLabel: 'Mark EOI as sent',
// 99% of the time the EOI is sent through Documenso and this
// stamps automatically via the webhook. Label as "manually" so
// reps reach for it only when Documenso fails to deliver or the
// EOI was sent outside the integrated flow.
actionLabel: 'Mark EOI as sent manually',
},
{
label: 'EOI signed',
@@ -667,9 +700,30 @@ function OverviewTab({
// Stage stays at 'eoi'; the sub-status badge flips via a separate
// PATCH (see MilestoneAdvanceButton.onConfirm fallback below).
advanceStage: 'eoi',
actionLabel: 'Mark EOI as signed',
actionLabel: 'Mark EOI as signed manually',
},
],
// When the EOI milestone is the active next step but nothing's been
// sent yet, surface the actual generation entry points instead of
// making the rep navigate to the EOI tab first. Mirrors the EOI
// tab's Generate flow exactly — same dialog component, same
// confirmation step — so behaviour stays consistent.
footer:
eoiPhase === 'current' && !interest.dateEoiSent ? (
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button type="button" size="sm" onClick={() => setEoiGenerateOpen(true)}>
Generate EOI
</Button>
<Button asChild type="button" size="sm" variant="outline">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/interests/${interestId}?tab=eoi` as any}
>
Open EOI tab
</Link>
</Button>
</div>
) : null,
pastSummary: interest.dateEoiSigned
? `Signed ${formatDate(interest.dateEoiSigned)}`
: 'Completed',
@@ -778,12 +832,17 @@ function OverviewTab({
{/* Payments — bank-issued invoices live elsewhere; this is the
internal audit record of money received against the deal. The
running deposit total here drives the auto-advance into the
deposit_paid stage server-side. */}
<PaymentsSection
interestId={interestId}
depositExpectedAmount={interest.depositExpectedAmount ?? null}
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
/>
deposit_paid stage server-side. Hidden before the reservation
stage: no deposit is expected yet, so the empty card is just
noise — the next-milestone card carries the actionable copy
instead. */}
{showPaymentsSection && (
<PaymentsSection
interestId={interestId}
depositExpectedAmount={interest.depositExpectedAmount ?? null}
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
/>
)}
{/* Sales-process milestones — phase-aware so the user only sees
what's actionable now. Past milestones collapse into a tight
@@ -865,12 +924,73 @@ function OverviewTab({
</dl>
</div>
{/* Contact dates (read-only - kept compact next to Lead) */}
{/* Contact — client's primary email + phone (from the linked client
record) AND the first/last-contact activity dates from the
contact log. Phone is rendered via libphonenumber-js's
international formatter so `+33633219796` reads as
`+33 6 33 21 97 96` (matches the canonical client-page display).
Both email + phone are click-to-edit: the PATCH flows to the
underlying client_contacts row (resolved via the
`*ContactId` fields surfaced by the interest read). */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3>
<dl>
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
<InfoRow label="Last Contact" value={formatDate(interest.dateLastContact)} />
<EditableRow label="Email">
{interest.clientPrimaryEmailContactId ? (
<InlineEditableField
variant="text"
value={interest.clientPrimaryEmail ?? ''}
onSave={async (next) => {
if (!interest.clientId || !interest.clientPrimaryEmailContactId) return;
await apiFetch(
`/api/v1/clients/${interest.clientId}/contacts/${interest.clientPrimaryEmailContactId}`,
{ method: 'PATCH', body: { value: next } },
);
await queryClient.invalidateQueries({
queryKey: ['interest', interest.id],
});
}}
/>
) : (
<span className="text-muted-foreground"></span>
)}
</EditableRow>
<EditableRow label="Phone">
{interest.clientPrimaryPhoneContactId ? (
<InlineEditableField
variant="text"
value={
interest.clientPrimaryPhone
? (parsePhone(interest.clientPrimaryPhone).international ??
interest.clientPrimaryPhone)
: ''
}
onSave={async (next) => {
if (!interest.clientId || !interest.clientPrimaryPhoneContactId) return;
await apiFetch(
`/api/v1/clients/${interest.clientId}/contacts/${interest.clientPrimaryPhoneContactId}`,
{ method: 'PATCH', body: { value: next } },
);
await queryClient.invalidateQueries({
queryKey: ['interest', interest.id],
});
}}
/>
) : (
<span className="text-muted-foreground"></span>
)}
</EditableRow>
{interest.dateFirstContact || interest.dateLastContact ? (
<>
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
<InfoRow label="Last Contact" value={formatDate(interest.dateLastContact)} />
</>
) : (
<p className="mt-1 text-xs text-muted-foreground italic">
No contact activity logged yet log a call, email, or meeting from the Contact log
tab to start tracking.
</p>
)}
{interest.reservationStatus ? (
<InfoRow label="Reservation" value={interest.reservationStatus} />
) : null}
@@ -918,7 +1038,11 @@ function OverviewTab({
addSuffix: true,
})}
{interest.recentNote.authorId
? ` · ${interest.recentNote.authorId === 'system' ? 'system' : interest.recentNote.authorId}`
? ` · ${
interest.recentNote.authorId === 'system'
? 'system'
: (interest.recentNote.authorName ?? 'Unknown')
}`
: ''}
</p>
</div>
@@ -963,8 +1087,19 @@ function OverviewTab({
desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
/>
{confirmDialog}
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
footer button can launch the dialog without leaving the tab. Same
dialog component the dedicated EOI tab uses — single source of
truth for the editing/confirmation flow. */}
<EoiGenerateDialog
interestId={interestId}
clientId={clientId}
open={eoiGenerateOpen}
onOpenChange={setEoiGenerateOpen}
/>
</div>
);
}
@@ -1000,7 +1135,7 @@ export function getInterestTabs({
{
id: 'overview',
label: 'Overview',
content: <OverviewTab interestId={interestId} interest={interest} />,
content: <OverviewTab interestId={interestId} interest={interest} clientId={clientId} />,
},
{
id: 'contact-log',
@@ -1049,7 +1184,15 @@ export function getInterestTabs({
{
id: 'recommendations',
label: 'Recommendations',
content: <RecommendationList interestId={interestId} />,
content: (
<BerthRecommenderPanel
interestId={interestId}
desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
/>
),
},
{
id: 'activity',

View File

@@ -274,7 +274,9 @@ function LinkedBerthRowItem({
>
{row.mooringNumber ?? row.berthId}
</Link>
{row.area ? <span className="text-xs text-muted-foreground">{row.area}</span> : null}
{/* `row.area` is the area letter (A, B, C…) which is already the
leading character of the mooring number rendered above, so
surfacing it again is pure noise. Hidden 2026-05-15. */}
<StatusPill status={statusToPill(row.status)}>{formatStatus(row.status)}</StatusPill>
{row.isPrimary ? (
<span className="inline-flex items-center gap-1 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-800">
@@ -386,8 +388,8 @@ function LinkedBerthRowItem({
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-[11px] leading-snug">
Include this berth in the EOI&apos;s signed berth range. When on, the berth is
covered by the same signature and shows up in the EOI&apos;s
<strong> Berth Range</strong> form field (e.g. &quot;A1-A3, B5-B7&quot;). Turn off
covered by the same signature and shows up in the EOI&apos;s{' '}
<strong>Berth Range</strong> form field (e.g. &quot;A1-A3, B5-B7&quot;). Turn off
to keep the link without legal coverage.
</TooltipContent>
</Tooltip>
@@ -546,7 +548,7 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
{dealBerth ? renderRow(dealBerth, { highlight: true }) : null}
</BerthSection>
{bundleRows.length > 0 || dealBerth ? (
{bundleRows.length > 0 ? (
<BerthSection
title="In EOI bundle"
hint="Additional berths covered by the same EOI signature. Won't drive templates, but the client's signature applies to all of them."

View File

@@ -30,8 +30,14 @@ export function MultiEoiChip({ interestId }: { interestId: string }) {
staleTime: 60_000,
});
// "In-flight" = the deal actually has more than one ACTIVE EOI the rep
// could be confused by. Excludes terminal statuses (cancelled / voided /
// declined / deleted / completed) and archived rows. Without this filter
// a deal with one active EOI + N cancelled / deleted ones from prior
// attempts surfaces a misleading "N EOIs" warning.
const TERMINAL_STATUSES = new Set(['cancelled', 'voided', 'declined', 'deleted', 'completed']);
const inflight = (data?.data ?? []).filter(
(d) => !d.archivedAt && d.status !== 'voided' && d.status !== 'declined',
(d) => !d.archivedAt && !TERMINAL_STATUSES.has(d.status),
);
if (inflight.length < 2) return null;

View File

@@ -20,6 +20,7 @@ interface QualificationRow {
confirmedAt: string | null;
confirmedBy: string | null;
notes: string | null;
autoSatisfied: boolean;
}
interface QualificationResponse {
@@ -109,7 +110,11 @@ export function QualificationChecklist({
<Checkbox
id={`qual-${c.key}`}
checked={c.confirmed}
disabled={toggleMutation.isPending}
// Auto-satisfied rows can't be unchecked from the UI — the
// underlying data signal would just re-tick the box on the next
// refetch. The rep clears the dimensions tick by removing the
// yacht dims or desired-berth dims from the interest.
disabled={toggleMutation.isPending || c.autoSatisfied}
onCheckedChange={(v) =>
toggleMutation.mutate({ criterionKey: c.key, confirmed: v === true })
}
@@ -118,14 +123,25 @@ export function QualificationChecklist({
<label
htmlFor={`qual-${c.key}`}
className={cn(
'flex-1 text-sm cursor-pointer',
'flex-1 text-sm',
c.autoSatisfied ? 'cursor-default' : 'cursor-pointer',
c.confirmed ? 'text-foreground' : 'text-foreground/90',
)}
>
<span
className={cn('font-medium', c.confirmed && 'line-through text-muted-foreground')}
>
{c.label}
<span className="flex flex-wrap items-center gap-1.5">
<span
className={cn('font-medium', c.confirmed && 'line-through text-muted-foreground')}
>
{c.label}
</span>
{c.autoSatisfied && (
<span
className="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
title="System-derived from data on this interest"
>
Auto
</span>
)}
</span>
{c.description ? (
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>

View File

@@ -2,9 +2,14 @@
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar';
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
import { MobileLayoutProvider } from '@/components/layout/mobile/mobile-layout-provider';
import { MobileTopbar } from '@/components/layout/mobile/mobile-topbar';
import { MobileBottomTabs } from '@/components/layout/mobile/mobile-bottom-tabs';
import { MoreSheet } from '@/components/layout/mobile/more-sheet';
import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
type SidebarProps = ComponentProps<typeof Sidebar>;
type TopbarProps = ComponentProps<typeof Topbar>;
@@ -27,11 +32,23 @@ interface AppShellProps {
const MOBILE_QUERY = '(max-width: 1023.98px)';
/**
* #26: single-tree responsive shell. Pre-fix the layout mounted BOTH
* desktop and mobile shells in the DOM and CSS-hid one — doubling React
* state, fetches, Tabs providers, and a11y landmarks. AppShell decides
* once per render which tree to mount, so a page only ever runs the
* effects + queries it actually displays.
* #26 + H-09: single-tree responsive shell with stable children subtree.
*
* The shell renders ONE `<main>` and ONE `<MobileLayoutProvider>` at all
* viewports — only the chrome (sidebar+topbar vs mobile-topbar+bottom-tabs)
* conditionally renders. Two payoffs:
*
* - #26 / first ship: no double-mount of chrome subtrees (Sidebar +
* MobileTopbar both running fetches / providers in parallel like the
* old layout did).
* - H-09: `{children}` stays mounted across viewport flips. A rep
* editing an inline field on desktop who resizes through the mobile
* breakpoint no longer loses the draft mid-edit — the children tree's
* position in the DOM is invariant, so React preserves its state.
*
* The mobile-only floating panels (MoreSheet, MobileSearchOverlay) only
* mount in the mobile branch — they have no desktop counterpart and would
* be wasteful to keep mounted otherwise.
*
* SSR safety: the server passes its UA-classified hint via `initialFormFactor`;
* the first client render uses the same value so hydration matches. After
@@ -46,6 +63,8 @@ export function AppShell({
children,
}: AppShellProps) {
const [isMobile, setIsMobile] = useState(initialFormFactor === 'mobile');
const [moreOpen, setMoreOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
useEffect(() => {
const mq = window.matchMedia(MOBILE_QUERY);
@@ -55,17 +74,54 @@ export function AppShell({
return () => mq.removeEventListener('change', update);
}, []);
if (isMobile) {
return <MobileLayout>{children}</MobileLayout>;
}
// Build the chrome subtree based on form factor; the children's parent
// chain (MobileLayoutProvider > div > main) is invariant across both
// branches, so React reconciliation keeps the children subtree mounted
// when isMobile flips.
const chrome = isMobile ? (
<>
<MobileTopbar />
</>
) : (
<Sidebar portRoles={portRoles} isSuperAdmin={isSuperAdmin} user={user} ports={ports} />
);
const footer = isMobile ? (
<>
<MobileBottomTabs
onMoreClick={() => setMoreOpen(true)}
onSearchClick={() => setSearchOpen(true)}
/>
<MoreSheet open={moreOpen} onOpenChange={setMoreOpen} />
<MobileSearchOverlay open={searchOpen} onOpenChange={setSearchOpen} />
</>
) : null;
const desktopTopbar = !isMobile ? <Topbar ports={ports} user={user} /> : null;
return (
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar portRoles={portRoles} isSuperAdmin={isSuperAdmin} user={user} ports={ports} />
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar ports={ports} user={user} />
<main className="flex-1 overflow-y-auto bg-background px-6 pt-3 pb-6">{children}</main>
<MobileLayoutProvider>
<div
className={cn(
'bg-background',
isMobile ? 'min-h-[100dvh]' : 'flex h-screen overflow-hidden',
)}
>
{chrome}
<div className={cn(isMobile ? 'contents' : 'flex-1 flex flex-col overflow-hidden min-w-0')}>
{desktopTopbar}
<main
className={cn(
isMobile
? 'px-4 min-h-[100dvh] pt-[calc(56px+env(safe-area-inset-top)+1rem)] pb-[calc(56px+env(safe-area-inset-bottom)+2rem)]'
: 'flex-1 overflow-y-auto bg-background px-6 pt-3 pb-6',
)}
>
{children}
</main>
</div>
{footer}
</div>
</div>
</MobileLayoutProvider>
);
}

View File

@@ -8,6 +8,7 @@ import {
Bookmark,
Building2,
FileSignature,
FileText,
Globe,
Home,
Inbox,
@@ -66,6 +67,9 @@ const MORE_GROUPS: MoreGroup[] = [
label: 'Operations',
items: [
{ label: 'Alerts & Reminders', icon: Inbox, segment: 'inbox' },
// M-U15: invoices was missing from the mobile nav — reps doing
// mobile follow-ups had to type the URL by hand.
{ label: 'Invoices', icon: FileText, segment: 'invoices' },
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
{ label: 'Reports', icon: BarChart3, segment: 'reports' },

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Bell } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -59,11 +59,24 @@ export function NotificationBell() {
const notifications = data?.data ?? [];
// Auto-mark-as-read on display: when the dropdown opens and lists land,
// POST /read-all so the badge clears once the user has actually seen the
// items. Individual rows still link out — the auto-clear here is the
// "I've seen these" gesture; the per-row mark-read action stays
// available for selective dismissal in the inbox page.
useEffect(() => {
if (!open || isLoading) return;
if (notifications.some((n) => !n.isRead)) {
markAllReadMutation.mutate();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isLoading, notifications.length]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
<Bell className="h-5 w-5" aria-hidden />
{unreadCount > 0 && (
<span
key={unreadCount}

View File

@@ -101,7 +101,10 @@ export function CountryCombobox({
disabled={disabled}
className={cn(
'justify-between',
compact ? 'w-20 px-2' : 'w-full',
// `shrink-0` keeps the country trigger from collapsing below its
// natural width when the parent flex row gets squeezed, which
// was causing "🇺🇸 US +1" to wrap vertically inside PhoneInput.
compact ? 'w-24 shrink-0 px-2' : 'w-full',
!selected && 'text-muted-foreground',
className,
)}

View File

@@ -0,0 +1,47 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
interface DevFlags {
emailRedirectTo: string | null;
isDev: boolean;
}
/**
* Single-line warning banner shown across the app whenever a dev-mode
* safety net is active (today: `EMAIL_REDIRECT_TO`). Sticky at the top
* of every authenticated surface so reps and admins can't miss that
* every outbound email is being rerouted to a single inbox.
*
* Production hides the banner entirely because env.ts refuses to boot
* with EMAIL_REDIRECT_TO set when NODE_ENV=production — the flag is
* only ever non-null in dev / staging.
*/
export function DevModeBanner() {
const { data } = useQuery<{ data: DevFlags }>({
queryKey: ['internal', 'dev-flags'],
queryFn: () => apiFetch<{ data: DevFlags }>('/api/v1/internal/dev-flags'),
staleTime: 5 * 60_000,
// Don't refetch on focus; the flag changes only on a restart.
refetchOnWindowFocus: false,
});
const redirect = data?.data?.emailRedirectTo;
if (!redirect) return null;
return (
<div
role="alert"
className="flex items-center justify-center gap-2 border-b border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-900"
title={`Every outbound email is rewritten so the recipient is ${redirect}. The original address is preserved in the recipient name as "(was: original@...)". Unset EMAIL_REDIRECT_TO in your env to disable.`}
>
<AlertTriangle className="size-3.5 shrink-0" aria-hidden />
<span>
Dev mode: outbound emails redirected to <code className="font-mono">{redirect}</code>
</span>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { apiFetch } from '@/lib/api/client';
import { stageLabel } from '@/lib/constants';
type NoteSource =
| 'client'
@@ -31,6 +32,9 @@ interface Note {
source?: NoteSource;
sourceId?: string;
sourceLabel?: string;
/** Pipeline stage the linked interest was at when the note was authored.
* Only populated for interest notes — drives the small stage chip. */
pipelineStageAtCreation?: string | null;
}
type NotesEntityType =
@@ -280,6 +284,19 @@ export function NotesList({ entityType, entityId, currentUserId, aggregate }: No
{SOURCE_LABEL[note.source]} · {note.sourceLabel}
</span>
)}
{/* Pipeline-stage stamp: shows what stage the linked
interest was at when the note was authored. Lets a
rep trace how the deal's notes evolved (concerns
raised at qualified vs after reservation). Only
populated for interest notes from 2026-05-15+. */}
{note.pipelineStageAtCreation && (
<span
className="inline-flex items-center rounded-full bg-indigo-50 px-1.5 py-0.5 text-[10px] font-medium text-indigo-900"
title="Pipeline stage when note was authored"
>
@ {stageLabel(note.pipelineStageAtCreation)}
</span>
)}
{note.isLocked && <Lock className="h-3 w-3 text-muted-foreground" aria-hidden />}
{canEdit(note) && (
<span className="text-xs text-muted-foreground">{getTimeRemaining(note)}</span>

View File

@@ -108,7 +108,10 @@ export function PhoneInput({
return (
<div
className={cn(
'flex items-stretch gap-1.5',
// `w-full` keeps the row matching the field width below it instead
// of collapsing to its content's intrinsic width when nested inside
// a flex/grid cell.
'flex w-full items-stretch gap-1.5',
invalid && '[&_input]:border-destructive [&_button[role=combobox]]:border-destructive',
)}
>

View File

@@ -234,47 +234,61 @@ export function YachtForm({ open, onOpenChange, yacht, initialOwner }: YachtForm
<Separator />
{/* Dimensions (ft) */}
{/* Dimensions — auto-convert ft ↔ m. Whichever unit the operator
types into, the other unit gets recomputed in place. We round
the converted value to keep the input clean (2 decimal places),
and skip the recompute when the user is mid-edit on the same
field so the cursor doesn't jump. */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Dimensions (ft)
Dimensions
</h3>
<p className="text-xs text-muted-foreground -mt-2">
Type a value in either ft or m the other unit auto-fills.
</p>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<Label>Length (ft)</Label>
<Input {...register('lengthFt')} placeholder="120" />
</div>
<div className="space-y-1">
<Label>Width (ft)</Label>
<Input {...register('widthFt')} placeholder="25" />
</div>
<div className="space-y-1">
<Label>Draft (ft)</Label>
<Input {...register('draftFt')} placeholder="8" />
</div>
</div>
</div>
<Separator />
{/* Dimensions (m) */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Dimensions (m)
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<Label>Length (m)</Label>
<Input {...register('lengthM')} placeholder="36.5" />
</div>
<div className="space-y-1">
<Label>Width (m)</Label>
<Input {...register('widthM')} placeholder="7.6" />
</div>
<div className="space-y-1">
<Label>Draft (m)</Label>
<Input {...register('draftM')} placeholder="2.4" />
</div>
<DimensionPair
label="Length"
ftValue={watch('lengthFt')}
mValue={watch('lengthM')}
onFtChange={(v) => {
setValue('lengthFt', v, { shouldDirty: true });
setValue('lengthM', ftToM(v), { shouldDirty: true });
}}
onMChange={(v) => {
setValue('lengthM', v, { shouldDirty: true });
setValue('lengthFt', mToFt(v), { shouldDirty: true });
}}
placeholders={{ ft: '120', m: '36.58' }}
/>
<DimensionPair
label="Width"
ftValue={watch('widthFt')}
mValue={watch('widthM')}
onFtChange={(v) => {
setValue('widthFt', v, { shouldDirty: true });
setValue('widthM', ftToM(v), { shouldDirty: true });
}}
onMChange={(v) => {
setValue('widthM', v, { shouldDirty: true });
setValue('widthFt', mToFt(v), { shouldDirty: true });
}}
placeholders={{ ft: '25', m: '7.62' }}
/>
<DimensionPair
label="Draft"
ftValue={watch('draftFt')}
mValue={watch('draftM')}
onFtChange={(v) => {
setValue('draftFt', v, { shouldDirty: true });
setValue('draftM', ftToM(v), { shouldDirty: true });
}}
onMChange={(v) => {
setValue('draftM', v, { shouldDirty: true });
setValue('draftFt', mToFt(v), { shouldDirty: true });
}}
placeholders={{ ft: '8', m: '2.44' }}
/>
</div>
</div>
@@ -369,3 +383,69 @@ export function YachtForm({ open, onOpenChange, yacht, initialOwner }: YachtForm
</Sheet>
);
}
// 1 ft = 0.3048 m exactly. Round to 2 decimals so the cross-filled value is
// readable but stable; `trimZero` strips trailing `.0` so a whole-number
// conversion like `5 ft → 1.52 m → 1.52` doesn't render as `1.520000`.
const FT_PER_M = 3.28084;
function trimZero(s: string): string {
if (!s.includes('.')) return s;
return s.replace(/\.?0+$/, '');
}
function ftToM(value: string | null | undefined): string {
if (value == null || value === '') return '';
const n = Number(value);
if (!Number.isFinite(n)) return '';
return trimZero((n * 0.3048).toFixed(2));
}
function mToFt(value: string | null | undefined): string {
if (value == null || value === '') return '';
const n = Number(value);
if (!Number.isFinite(n)) return '';
return trimZero((n * FT_PER_M).toFixed(2));
}
function DimensionPair({
label,
ftValue,
mValue,
onFtChange,
onMChange,
placeholders,
}: {
label: string;
ftValue: string | null | undefined;
mValue: string | null | undefined;
onFtChange: (value: string) => void;
onMChange: (value: string) => void;
placeholders: { ft: string; m: string };
}) {
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium">{label}</Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">ft</span>
<Input
inputMode="decimal"
value={ftValue ?? ''}
onChange={(e) => onFtChange(e.target.value)}
placeholder={placeholders.ft}
/>
</div>
<div className="space-y-1">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">m</span>
<Input
inputMode="decimal"
value={mValue ?? ''}
onChange={(e) => onMChange(e.target.value)}
placeholder={placeholders.m}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,12 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Plus, Archive, Tag as TagIcon, TagsIcon } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
@@ -39,6 +42,13 @@ export function YachtList() {
const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
// M-U14: surface the page title in the mobile topbar.
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Yachts', showBackButton: false });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true));
const [editYacht, setEditYacht] = useState<YachtRow | null>(null);
@@ -63,7 +73,7 @@ export function YachtList() {
queryClient.invalidateQueries({ queryKey: ['yachts'] });
const s = res.data.summary;
if (s.failed > 0) {
alert(`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed.`);
toast.warning(`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed.`);
}
},
});

View File

@@ -27,7 +27,11 @@ export async function processDocumensoPoll(): Promise<void> {
if (!doc.documensoId) continue;
try {
const remoteDoc = await getDocumensoDoc(doc.documensoId);
// Pass the doc's portId so the client uses per-port credentials
// (admin-set Documenso URL/key/version), not the global env fallback.
// Without portId a multi-port deployment hits the wrong instance and
// 401s every poll.
const remoteDoc = await getDocumensoDoc(doc.documensoId, doc.portId);
// Reconcile signer statuses
for (const remoteRecipient of remoteDoc.recipients) {
@@ -59,10 +63,16 @@ export async function processDocumensoPoll(): Promise<void> {
// Reconcile document status
if (remoteDoc.status === 'COMPLETED' && doc.status !== 'completed') {
logger.info({ documentId: doc.id, portId: doc.portId }, 'Reconciling completed document from poll');
logger.info(
{ documentId: doc.id, portId: doc.portId },
'Reconciling completed document from poll',
);
await handleDocumentCompleted({ documentId: doc.documensoId, portId: doc.portId });
} else if (remoteDoc.status === 'EXPIRED' && doc.status !== 'expired') {
logger.info({ documentId: doc.id, portId: doc.portId }, 'Reconciling expired document from poll');
logger.info(
{ documentId: doc.id, portId: doc.portId },
'Reconciling expired document from poll',
);
await handleDocumentExpired({ documentId: doc.documensoId, portId: doc.portId });
}
} catch (err) {

View File

@@ -20,13 +20,19 @@ export function parseQuery<T extends ZodSchema>(req: NextRequest, schema: T): z.
/**
* Parses the JSON request body against a Zod schema.
* Throws a ZodError on validation failure (caught by `errorResponse`).
*
* H-14: tolerates empty request bodies (content-length 0 or req.json()
* throwing on an empty stream) by substituting `{}` so DELETE/PATCH
* routes whose schemas have all-optional fields don't crash with a
* 500 — the schema's own optionality decides whether the empty object
* is a valid input.
*/
export async function parseBody<T extends ZodSchema>(
req: NextRequest,
schema: T,
): Promise<z.infer<T>> {
const body = await req.json();
return schema.parse(body);
const body = await req.json().catch(() => ({}));
return schema.parse(body ?? {});
}
/**

View File

@@ -51,7 +51,13 @@ export type AuditAction =
// evaluateRule() call so admins can debug "why did this fire / not fire"
// without reading server logs. Distinct from the actual `update` audit
// row the auto-applied path emits when it mutates berth status.
| 'rule_evaluated';
| 'rule_evaluated'
// M-AU04: distinct verbs for outcome-set / outcome-cleared. The pre-fix
// path used a generic `update` row with `metadata.type = 'outcome_set'`,
// which the audit filter dropdown couldn't surface as its own bucket
// and the FTS GENERATED index missed entirely.
| 'outcome_set'
| 'outcome_cleared';
/**
* Common shape passed to service functions so they can stamp audit logs and
@@ -220,6 +226,24 @@ export function diffFields(
const DEFAULT_SEVERITY_BY_ACTION: Partial<Record<AuditAction, AuditSeverity>> = {
permission_denied: 'warning',
hard_delete: 'critical',
// L-AU01: explicit severities so the row badge in /admin/audit lights
// up correctly. Without these, security-relevant verbs landed as
// generic 'info' grey rows next to read events.
password_change: 'warning',
portal_invite: 'info',
portal_activate: 'info',
portal_password_reset_request: 'warning',
portal_password_reset: 'warning',
revoke_invite: 'warning',
request_gdpr_export: 'info',
send_gdpr_export: 'info',
request_hard_delete_code: 'warning',
outcome_set: 'info',
outcome_cleared: 'info',
// Webhook lifecycle defaults to warning when a delivery fails.
webhook_failed: 'warning',
webhook_dead_letter: 'error',
job_failed: 'error',
};
const AUTH_ACTIONS = new Set<AuditAction>(['login', 'logout', 'password_change']);

View File

@@ -0,0 +1,10 @@
-- The `dimensions` qualification criterion is auto-satisfied when EITHER
-- the linked yacht has length/width/draft OR the interest itself has
-- desired berth dimensions set. The original description ("We know the
-- vessel's length, width, and draft") implied the yacht-only path, which
-- confused reps after the auto-satisfy rule shipped. Updated to reflect
-- both paths.
UPDATE qualification_criteria
SET description = 'Vessel dimensions OR desired berth dimensions are recorded (length, width, draft).'
WHERE key = 'dimensions'
AND description = 'We know the vessel''s length, width, and draft.';

View File

@@ -0,0 +1,11 @@
-- Documenso v2 webhooks send only the numeric internal ID (`payload.id = 19`),
-- but the rest of the v2 API expects the public `envelope_xxx` string that we
-- already store in `documents.documenso_id`. To resolve incoming webhooks
-- against our documents, capture the numeric id alongside the envelope id at
-- create time and let the resolver try either column.
--
-- v1 documents only have a single numeric id; existing rows leave this column
-- null and continue resolving by `documenso_id` as before.
ALTER TABLE documents ADD COLUMN IF NOT EXISTS documenso_numeric_id text;
CREATE INDEX IF NOT EXISTS idx_docs_documenso_numeric_id ON documents(documenso_numeric_id);

View File

@@ -0,0 +1,10 @@
-- Snapshot the linked interest's pipeline_stage at note-creation time so
-- the timeline of notes carries the stage they were made at. Read by the
-- NotesList UI to render a per-note stage chip.
--
-- Pre-2026-05-15 rows stay null — backfill from audit_logs would be
-- inaccurate (the audit row only captures the AFTER-stage on stage moves,
-- not the at-rest state when a note was inserted). New notes carry the
-- stamp going forward.
ALTER TABLE interest_notes ADD COLUMN IF NOT EXISTS pipeline_stage_at_creation text;

View File

@@ -0,0 +1,94 @@
-- H-01: explicit ON DELETE actions for previously-implicit NO ACTION FKs.
--
-- Without an explicit action Postgres defaults to NO ACTION, so a hard-
-- delete of a parent (client, port, berth, file, document signer) is
-- blocked at FK check time — sometimes intentional, often surprising.
-- Each FK below now declares whether parent deletion is RESTRICT (block,
-- force the operator to archive the parent or unlink the children first)
-- or SET NULL (allow the deletion, null the FK so child rows stay around
-- as historical records).
--
-- All ALTER COLUMNs are idempotent because we drop the constraint first
-- (if it exists) and re-add it under the same name; if the constraint is
-- already in the desired shape this is a no-op against Postgres.
-- interests: required parent links; archive-first is the supported path,
-- so RESTRICT a hard-delete to surface the misuse loudly.
ALTER TABLE interests DROP CONSTRAINT IF EXISTS interests_port_id_ports_id_fk;
ALTER TABLE interests
ADD CONSTRAINT interests_port_id_ports_id_fk
FOREIGN KEY (port_id) REFERENCES ports(id) ON DELETE RESTRICT;
ALTER TABLE interests DROP CONSTRAINT IF EXISTS interests_client_id_clients_id_fk;
ALTER TABLE interests
ADD CONSTRAINT interests_client_id_clients_id_fk
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE RESTRICT;
-- documents: client/file links are nullable already; tolerate parent
-- deletion via SET NULL so the document row stays for audit purposes.
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_client_id_clients_id_fk;
ALTER TABLE documents
ADD CONSTRAINT documents_client_id_clients_id_fk
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE SET NULL;
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_file_id_files_id_fk;
ALTER TABLE documents
ADD CONSTRAINT documents_file_id_files_id_fk
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE SET NULL;
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_signed_file_id_files_id_fk;
ALTER TABLE documents
ADD CONSTRAINT documents_signed_file_id_files_id_fk
FOREIGN KEY (signed_file_id) REFERENCES files(id) ON DELETE SET NULL;
-- document_events: outlive their signer row so the audit trail stays
-- intact when a recipient is removed.
ALTER TABLE document_events DROP CONSTRAINT IF EXISTS document_events_signer_id_document_signers_id_fk;
ALTER TABLE document_events
ADD CONSTRAINT document_events_signer_id_document_signers_id_fk
FOREIGN KEY (signer_id) REFERENCES document_signers(id) ON DELETE SET NULL;
-- berth_reservations: every parent FK gets RESTRICT (canonical occupancy
-- record; never silently orphaned). interestId is nullable and SET NULL
-- so a reservation legitimately outlives the originating deal.
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_berth_id_berths_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_berth_id_berths_id_fk
FOREIGN KEY (berth_id) REFERENCES berths(id) ON DELETE RESTRICT;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_port_id_ports_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_port_id_ports_id_fk
FOREIGN KEY (port_id) REFERENCES ports(id) ON DELETE RESTRICT;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_client_id_clients_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_client_id_clients_id_fk
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE RESTRICT;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_yacht_id_yachts_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_yacht_id_yachts_id_fk
FOREIGN KEY (yacht_id) REFERENCES yachts(id) ON DELETE RESTRICT;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_interest_id_interests_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_interest_id_interests_id_fk
FOREIGN KEY (interest_id) REFERENCES interests(id) ON DELETE SET NULL;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_contract_file_id_files_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_contract_file_id_files_id_fk
FOREIGN KEY (contract_file_id) REFERENCES files(id) ON DELETE SET NULL;
-- reminders.client_id: nullable, tolerate parent delete with SET NULL.
ALTER TABLE reminders DROP CONSTRAINT IF EXISTS reminders_client_id_clients_id_fk;
ALTER TABLE reminders
ADD CONSTRAINT reminders_client_id_clients_id_fk
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE SET NULL;
-- invoices.pdf_file_id: nullable, tolerate parent delete with SET NULL.
ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_pdf_file_id_files_id_fk;
ALTER TABLE invoices
ADD CONSTRAINT invoices_pdf_file_id_files_id_fk
FOREIGN KEY (pdf_file_id) REFERENCES files(id) ON DELETE SET NULL;

Some files were not shown because too many files have changed in this diff Show More