diff --git a/CLAUDE.md b/CLAUDE.md index 7709cedb..9c0a809b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). diff --git a/assets/README.md b/assets/README.md index 71c59623..f2ab7f7a 100644 --- a/assets/README.md +++ b/assets/README.md @@ -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). diff --git a/docs/AUDIT-FINDINGS-2026-05-15.md b/docs/AUDIT-FINDINGS-2026-05-15.md new file mode 100644 index 00000000..731c03b5 --- /dev/null +++ b/docs/AUDIT-FINDINGS-2026-05-15.md @@ -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/` 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/` 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=`. 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=` 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', , portId, meta)`. `upsertSetting` records `newValue: { value: '' }`. `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 `{children}` and the desktop `
...{children}...
`. 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 `` 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 `` / `` / `` 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 `` or `` 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 `More folder actions`. +- **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//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.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: ` 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 `` | `src/components/admin/audit/audit-log-list.tsx:524` | Replace with `` | +| 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: `` + `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 `` 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 `` | +| 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 `` 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 `` 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/` shows friendly "Client not found... different port" page | +| MT-02 cross-port PATCH | ✅ clean: `PATCH /api/v1/interests/` 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) diff --git a/docs/AUDIT-FIX-WAVE-2026-05-18.md b/docs/AUDIT-FIX-WAVE-2026-05-18.md new file mode 100644 index 00000000..13fdf092 --- /dev/null +++ b/docs/AUDIT-FIX-WAVE-2026-05-18.md @@ -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 `
{children}
` 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 ``. +- **M-U09** invoice delete dialog migrated from hand-rolled overlay to + `` (focus trap, ESC-to-close, a11y semantics). +- **M-U10** ClientForm + InterestForm fire `toast.success(...)` on + create/edit. +- **M-U11** logo preview `` 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, ~1–2 weeks) or wait until Phases 1+2 can land + together (also Documenso template push, ~3–4 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 2–7 (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. diff --git a/docs/MANUAL-TESTING-BACKLOG-2026-05-15.md b/docs/MANUAL-TESTING-BACKLOG-2026-05-15.md new file mode 100644 index 00000000..435271f8 --- /dev/null +++ b/docs/MANUAL-TESTING-BACKLOG-2026-05-15.md @@ -0,0 +1,1622 @@ +# Manual-testing backlog — 2026-05-15 + +Source: live walkthrough of the CRM by Matt while testing the Documenso integration +end-to-end on `port-amador`. Each item here was either noted mid-stream or surfaced +during testing and is queued for a future focused pass. Items already shipped during +the same session are listed in the "Reference: shipped this session" appendix for +context. + +Format per item: **what / where / desired state / effort / notes**. Use as a punch +list — work top-to-bottom or cherry-pick by area. + +--- + +## 0 · Blocked — needs Matt's decision before pickup + +Two items can't be picked up until a design call lands. Surfaced here so they +don't get buried under implementation chatter. + +### 0.1 Reminders data model — lightweight columns vs richer `reminders` table + +- **Cross-reference:** §3.2 below. +- **The choice:** Today every interest carries `reminderEnabled: boolean` + + `reminderDays: integer` directly on the row. A separate `reminders` table + already exists (see `operations.ts`) with richer fields — `title`, `note`, + `priority`, `assignedTo`, `dueAt` (timestamp, not days-from-now), + `snoozedUntil`, `googleCalendarEventId`, etc. +- **Path A — extend the lightweight columns** on `interests`: migration adds + `reminderNote`, `reminderTimeOfDay`, optionally `reminderPriority` and + recurrence flags. Stays single-row, every interest gets exactly one + reminder hook. +- **Path B — push richer reminders into the `reminders` table**: leave + `reminders_enabled`/`reminders_days` as the simple per-interest hook (one + follow-up tick), use the rich table for everything else (dated tasks, + assigned reminders to specific reps, recurring nudges, etc.). Already + partially wired — `searchReminders` queries it, `RemindersInbox` likely + renders from it. +- **Why this is blocked:** path A is faster but creates a parallel data model + for a thing that already has a richer home. Path B is the right shape but + requires a UI for "create a task on this interest" that doesn't exist yet, + and a clear answer to "what does the existing per-interest reminder do + once the rich path exists?". +- **What I need from Matt:** which path, and (if B) does the per-interest + cadence stay or get retired? + +### 0.2 Supplemental info form — CRM-hosted vs marketing-site + +- **Cross-reference:** §8.1 below. +- **The unknown:** Clicking "Send supplemental info form" emails the client a + one-time link. That link's `/supplemental/` route — does it resolve + to a CRM-hosted page (works out of the box) or to the marketing site (which + may not have the route deployed yet)? +- **Why this is blocked:** every other answer downstream depends on it. If + CRM-hosted, this just needs a UX polish pass (§8.2 dual-mode + padding). If + marketing-site-hosted, the marketing repo needs the page shipped before any + testing makes sense, AND we need to confirm the marketing repo's deploy + story for non-Port-Nimara ports. +- **What I need from Matt:** a green-light to spend ~15 minutes tracing the + route end-to-end. He flagged this as "save for end of pass" so I haven't + touched it; capturing here so it doesn't get forgotten. + +--- + +## 1 · Interest detail — Overview tab + +### 1.1 Interest timeline detail + +- **Where:** `src/components/interests/interest-timeline.tsx`, Activity tab on + interest detail page. +- **Current:** Shows only "Interest created" event. Every subsequent PATCH (berth + linked, desired-dims updated, stage moved, qualification confirmed, etc.) is + swallowed. +- **Desired:** One row per audited mutation — labelled by entity/action — so reps + see the full life of the deal without leaving the page. Don't have to show the + raw before/after values, just the _fact_ of the change. +- **Effort:** Medium. Audit-log rows exist server-side already + (`audit_logs` for `entityType IN ('interest','interest_berths','interest_qualifications', +…)`). Timeline query is filtering too aggressively or missing entity types. +- **Files:** `interest-timeline.tsx`, possibly a service helper that builds the + activity stream. + +### 1.2 Assigned-to default + permission granularity + +- **Where:** New-interest creation flow + `AssignedToChip` on detail page. +- **Current:** New interests default to "Unassigned"; any user with the right + permission can be assigned to. +- **Desired:** + - Default the assignee to the creating user when that user has the + "can-be-assigned-to-sales" permission. + - Add a fine-grained per-user toggle so admins / directors / etc. who normally + aren't sales-facing can be flipped on as assignable when they're standing in + on a deal. + - **Broader principle:** every permission should be tunable per-user, not only + via role assignment. Already partially supported via `userPermissionOverrides` + — make sure "is assignable to sales" surfaces in that override UI. +- **Effort:** Medium. Permission-key addition, default-on-create branch in + `interests.service.ts`, surface in the user-edit drawer's Permissions tab. +- **Files:** `interests.service.ts`, `roles` / `users` admin pages, permissions schema. + +### 1.X Interest Overview — inline-edit Email + Phone on Contact section + +- **Where:** "Contact" section of the OverviewTab in + `src/components/interests/interest-tabs.tsx`. +- **Current state:** Email + Phone surface as read-only echoes of the + client's primary email/phone (`clientPrimaryEmail` / `clientPrimaryPhone` + from `getInterestById`). To edit them today the rep has to jump to the + client page. +- **Desired:** Click-to-edit inline using the canonical client-page + components — `InlineEditableField` for email, `` (with the + country-flag picker + E.164 normalization) for phone. +- **Wiring needed:** + - `getInterestById` returns the `contactId` (`client_contacts.id`) for + the primary email + primary phone row, plus the existing `value` and + `valueE164`. + - `PATCH /api/v1/clients/{clientId}/contacts/{contactId}` handler that + updates value + valueE164 (phone) or value (email) and re-asserts the + primary flag. + - On save: invalidate every cache that surfaces this data — `clients/{id}`, + `interests/{id}`, `interests/{id}/eoi-context`, and any list endpoint + that materializes the email/phone in the row (e.g. client-list, search + results, dedup-candidates panel). The "data on the interest record must + reflect everywhere it's used" invariant is real here. +- **Effort:** Small-medium (~45 min). Service-side return-shape change + + new PATCH route + 2 inline-edit components + the cache-invalidation list. + +### 1.3 Payments section: stage-aware + +- **Where:** OverviewTab. +- **Status:** Partially shipped — Payments now hidden before reservation stage. +- **Outstanding:** When hidden, should we show a stage-specific "what to do next" + card instead? Currently the real estate is just empty until the milestone card + takes over. Consider an explicit "Next step" card with shortcuts (Send EOI, + Generate Reservation, Record deposit, etc.) per stage. + +### 1.4 Quick "Log contact" button + +- **Where:** Add to Overview tab on **interest detail** and **client detail**. +- **Current:** Reps have to navigate to the Contact Log tab and click Compose. +- **Desired:** A small button (probably next to "Email / Call / WhatsApp" pills) + that opens the compose drawer pre-populated. On the **client** variant, allow + the rep to optionally attach the new contact log to one of the client's + interests for better organization. +- **Effort:** Small-medium. Just a button + open the existing compose dialog with + a `defaultInterestId` prop. + +--- + +## 2 · Contact-log compose + +### 2.1 Convert modal → side drawer + +- **Where:** `src/components/contact-log/compose-dialog.tsx`. +- **Current:** Centered modal. Cramped when adding a follow-up reminder + body. +- **Desired:** Convert to `` like the CRM's other form + surfaces. More room for the body, reminder, attachments, etc. Matches the + Sheet vs Drawer doctrine in CLAUDE.md. +- **Effort:** Small (component swap + a few class tweaks). + +### 2.2 Contact-log bells & whistles + +- Voice memo upload + transcription (OpenAI Whisper or local). +- Attach a follow-up to a specific interest (covered by 1.4 on the client variant). +- Multi-attachment support for screenshots / docs received during the call. +- "Outcome" picker (positive / neutral / negative / blocked) for funnel analytics. +- Templated quick-snippets ("Left voicemail", "Scheduled walkthrough", etc.). +- Editable-after-save with audit-log trail (currently contact logs are immutable + per most CRM patterns — confirm desired behaviour). + +--- + +## 3 · Reminders + +### 3.1 Standardize across surfaces + +- **Where:** `InterestForm`, contact-log compose, anywhere else `reminderEnabled` + + `reminderDays` are settable. +- **Current:** Each surface has its own copy of the cadence picker + (`ReminderDaysInput`). The component is shared but each surface configures it + independently. +- **Desired:** A single per-port `reminder_presets` setting registry that drives + the cadence chip options + the default selected cadence across every reminder + surface. Admin sets the port's defaults once; every rep-facing surface inherits. +- **Effort:** Medium. New registry entry, new `useReminderPresets()` hook, replace + hardcoded `PRESETS` array in `ReminderDaysInput`. + +### 3.2 Reminder customization (richer fields) + +- **Where:** `interests.reminderEnabled` + `reminderDays` columns. +- **Current:** Toggle + integer days. No note, no priority, no time-of-day, no + recurrence. +- **Desired (optional schema migration):** + - `reminderNote text` — what the reminder is about + - `reminderTimeOfDay text` — HH:MM in port timezone + - `reminderPriority text` — low/medium/high (mirrors `reminders` table) + - `reminderRecurring boolean` + `reminderRecurringDays integer` +- **Alternative:** Keep the lightweight model on `interests` and push richer + reminders into the existing `reminders` table (already has title/note/priority/ + assignedTo/snoozedUntil — see `operations.ts`). +- **Decision needed:** which model? Lightweight stays as-is and the rich + table is for explicit dated tasks. **Pending Matt's call.** See also + the per-EOI reminder control work in [[4.7]] which covers reminder + cadence at the document + per-signer granularity. + +--- + +## 4 · EOI generation + +### 4.1 Full inline editing in the Generate-EOI drawer + +- **Where:** `src/components/documents/eoi-generate-dialog.tsx`. +- **Status:** Partially shipped: + - ✅ Dialog → Sheet conversion + - ✅ Inline fix-it form for MISSING name/email/address (uses Input + CountryCombobox; persists via clients PATCH + addresses/contacts POST) + - ✅ Inline-edit pencil for name/nationality/yacht-name already exists on `PreviewRow` +- **Outstanding:** + - **Email** — needs an inline-edit row that PATCHes the matching `client_contacts.value` row. Requires surfacing `contactId` in `eoi-context.ts` response (currently flat `primaryEmail` only) + a `PATCH /api/v1/clients/{id}/contacts/{contactId}` wrapper. + - **Phone** — same, plus needs the `` component for formatting (don't show raw E.164 + flag as a plain text field). The data path mirrors email. + - **Address** — multi-field (street/city/country). Needs `addressId` in the context payload, then an inline sub-form (3 inputs + CountryCombobox) that PATCHes `/addresses/{addressId}`. + - **Yacht dimensions** — Length/Width/Draft should be editable inline with the same ft↔m auto-convert as `YachtForm`. Persists via PATCH /yachts/{id}. +- **Effort:** Medium-high (~1-2h focused). Server-side change to enrich + `eoi-context.ts` with row IDs, new PATCH wrappers for contacts, multi-field + editor component for address. + +### 4.2 EOI-scoped data overrides (don't touch the canonical record) + +- **Where:** EOI Generate drawer. +- **Use case:** Sales wants to render an EOI with values **different from** the + client/interest record without overwriting the canonical record. Example: the + client's billing address is in London (primary on the record), but for this + specific EOI they want to use a secondary Monaco address. Today, editing in + the drawer PATCHes the canonical record — there's no way to "use this value + for this EOI only". +- **Desired:** A per-field "use this only for the EOI" toggle (default off). + When on: + - The field value goes into the generated EOI document + - The canonical client/interest record stays untouched + - The override value is persisted as a secondary record with a flag + (`is_eoi_only: true` or similar), tagged with a note like _"Captured for + EOI #{externalId} on {date}"_, so it's auditable + recoverable but doesn't + leak into the sales-process surfaces (e.g. doesn't become a candidate for + "primary address", doesn't show up in the dedup picker as the address of + record). +- **Data model implications:** + - `client_addresses` and `client_contacts` already support `is_primary`. + Could add an `is_eoi_only boolean` (or a more general `scope text` — + `'primary' | 'eoi-only' | 'archived'`) to mark these rows. + - The eoi-context resolver would need to know to prefer the EOI-only row + over the primary when an active EOI override exists; behave normally + otherwise. + - Yacht / berth dim overrides could be modelled via a sibling + `eoi_overrides` JSONB on the interest row, since yachts aren't keyed for + multi-instance-per-scope. +- **UX:** Each editable row in the drawer gets a small checkbox below the + input: _"Use this only on this EOI (don't change the client record)"_. Default + unchecked = the current behavior (PATCH record). Checked = persists as + EOI-only scoped. +- **Effort:** High. Schema change + resolver branch + UI toggle + audit-log + story. Worth scoping carefully — easy to introduce subtle "which value won?" + bugs in downstream surfaces. +- **Open questions:** + - Does the override apply only to this specific EOI document, or to ALL + future EOIs for this interest? (Lean: this EOI only, by storing the + document_id reference on the override row.) + - When the rep reopens the Generate drawer after an EOI was previously + sent with an override, do we show the original override values or fall + back to the canonical record? (Lean: fall back to canonical; force the + rep to re-tick if they want the override again.) + - Are these overrides reusable for related docs (reservation, contract) or + EOI-only? +- **Companion: interest-level data overrides (broader scope).** Beyond the + EOI-only override above, Matt wants a "set contact/address details for + just this interest" toggle on the **interest record itself**. When flipped, + the email/phone/address entered overrides what's on the client record for + this interest only — and wherever that interest's contact data is shown + elsewhere in the app (search results, dedup panel, EOI preview, contact + log), the surfaced value is tagged with a small "interest-only" badge so + reps understand they're seeing a deal-scoped override, not the client's + canonical info. Same data-model shape as the EOI-only case but the + `is_interest_only` flag would be keyed on `interest_id` instead of + `document_id`. Shares the resolver-precedence model with the EOI flag — + interest-only > client-primary at lookup time when the caller is in that + interest's scope; client-primary everywhere else. + +### 4.3 EOI Address field — composition control + overflow safety + +- **Where:** `formatAddress()` in `src/lib/services/documenso-payload.ts` + + `src/lib/pdf/fill-eoi-form.ts`, EOI source PDF AcroForm `Address` field. +- **Now shipping** (this session): the EOI Address field renders as + `street, city, REGION, postal, COUNTRY` where REGION is the ISO-3166-2 + suffix (e.g. `NY`) and COUNTRY is the alpha-2 ISO code (e.g. `US`). + Inline fix-it form on the generate drawer now accepts street + city + + region + postal + country. The standalone `Nationality` PDF field has + been retired — the resident's country lives on the Address line. +- **Still open / deferred:** + - **Admin setting** per-port to choose which address pieces are included + on the EOI Address line (e.g. allow ports that want to drop the + subdivision because their clients are mostly EU where `XX-XX` codes + aren't recognised). Backed by a `system_setting` like + `eoi_address_components`: `['street','city','subdivision','postal','countryIso']`. + Default to all five. + - **Dynamic font sizing inside the AcroForm box** — pdf-lib supports + `field.setFontSize(...)`; need to measure the rendered string width + against the field's available width and step the size down (e.g. 11pt + → 9pt → 8pt) until it fits. Currently the PDF's `Address` field has a + fixed font size set in the template, so a too-long address line will + truncate or overflow. + - **Preview check on the generate drawer** that shows a warning when + the projected line exceeds a known character threshold so the rep can + shorten before signing (e.g. swap the formal street name for an + abbreviation). +- **Why:** Test cases like `108 Avenue du Trois Septembre, Cap d'Ail, , +98000, FR` already push the box; longer EU/Asian addresses overflow. +- **Effort:** Medium. Admin setting is straightforward registry entry + + resolver call. Font auto-fit needs measurement helper + PDF-pass change. + +### 4.4 Bypass-with-warning button + +- **Status:** **Intentionally not shipped** — Matt mentioned it then accepted the + reasoning (EOI's top legal paragraph requires the missing fields; bypass + produces unsignable docs). Note here in case the requirement returns. +- **If revisited:** Add a `skipValidation: true` flag on the generate endpoint, + surface a confirm modal in the drawer with explicit "this EOI will have + blank legal fields" warning. + +### 4.5 Documenso recipients — CRM as source of truth for all 3 signers + +- **Where:** `buildDocumensoPayload` in `src/lib/services/documenso-payload.ts`, + per-port settings in `src/lib/settings/registry.ts`. +- **Status:** **Next up — actively being shipped.** Matt's call: changes to a + signer (e.g. David Mizrahi leaves, replaced by someone else) should happen + ONLY in our CRM admin settings — never in the Documenso template UI. +- **Documenso v2 behaviour confirmed via OpenAPI + docs:** + - Template recipients can be saved with **placeholder values** (e.g. + `developer@placeholder.crm` · `Developer (placeholder)`) — Documenso lets + you save the template that way. The "Use template" UI gate (and the + `/api/v2/template/use` endpoint) requires every slot to have a valid + email/name though, so placeholders must be present at use-time. + - At `/api/v2/template/use` time, the `recipients` array maps slot `id` → + `{email, name}`. **Provided values win for that document only — the + template's stored values are untouched.** Slots omitted from the array + fall through to the template's stored values. +- **Current state of the code:** `buildDocumensoPayload` passes `email: '', +name: ''` for developer/approver so template values win. That's the OPPOSITE + of what Matt wants — and it relies on v2 silently treating `''` as + fall-through, which the spec doesn't actually guarantee. +- **Target state:** Resolve real email + name for all 3 slots from per-port + CRM settings: + - Client slot — already populated from the EOI context (no change). + - Developer + Approver slot — resolve in this order: + 1. If `documenso__user_id` is set → look up that user's + `userProfiles.displayName` + primary email (joined from `user` table). + 2. Otherwise → fall back to two new free-text registry settings + `documenso__email` + `documenso__name` so ports without a + linked CRM user can still pin a static value. + - Pass these as real values in the `recipients` array. Template placeholders + are then invisible in practice — they exist only to satisfy v2's UI gate. +- **Setup Matt needs to do once per port** before shipping: + - On the Documenso v2 template, add 3 recipient rows with placeholder values: + - `client@placeholder.crm` · `CRM Client (placeholder)` · SIGNER + - `developer@placeholder.crm` · `Developer (placeholder)` · SIGNER + - `approver@placeholder.crm` · `Approver (placeholder)` · APPROVER + - Enable signing order on the template, ordered Client → Developer → Approver. + - Save template. Run "Sync from Documenso" in the CRM admin. + - Fill in the per-port linked-CRM-user dropdowns (developer + approver). +- **Effort:** ~30 min — registry entries + resolver helper that joins to + `user`/`userProfiles` + `buildDocumensoPayload` change + tests. + +### 4.6 EOI dimensions — unit toggle + per-field emission + +- **Where:** "Dimensions (L × W × D, ft)" preview row in + `src/components/documents/eoi-generate-dialog.tsx` + + `buildDocumensoPayload` / `fill-eoi-form` Length / Width / Draft + formValues. +- **Current state:** + - The drawer's preview row hardcodes "ft" in the label and renders + `[lengthFt, widthFt, draftFt]` joined with `×`. + - `buildDocumensoPayload` and `fill-eoi-form` already emit `Length`, + `Width`, `Draft` as **separate formValues** — so per-field send is good. + - However the values passed are always in **feet** regardless of which + unit the rep originally entered on the yacht. +- **Desired:** + - **Unit toggle** (ft / m) at the top of the dimensions row in the EOI drawer. + - **Default** to whichever unit the rep entered on the yacht record. The + yacht row stores both `lengthFt/widthFt/draftFt` AND `lengthM/widthM/draftM` + — we need a way to know which was the "source of entry". Either: + 1. Add a `dimensions_unit_source: 'ft' | 'm' | null` column on `yachts` + that the YachtForm sets when the rep types into either input. Or + 2. Heuristic: if `lengthM` is set but `lengthFt` is null → m; vice versa. + (Brittle when both are saved.) + - The selected unit's value flows into Length/Width/Draft formValues so the + rendered EOI matches what the rep entered. +- **Effort:** Small-medium (~45 min). Toggle component + dimension formatter + + schema column (if going with option 1) + buildDocumensoPayload swap. + +### 4.10b Document detail page — full refactor + +- **Where:** `/[portSlug]/documents/[id]` → + `src/components/documents/document-detail.tsx` (~407 lines). +- **Symptom:** The page has been there since pre-EOI work but never + caught up to the polish we shipped on the EOI tab. Multiple + problems compound: confusing "Watchers" section with no Add button, + no way to send invitations from this page (only "Remind" buttons + that are ambiguous), Linked Entity row shows a bare "Interest →" + with no name, Activity panel always says "No events yet" even when + the document has dozens of events in `document_events`. +- **The full refactor — 6 deliverables (in priority order):** + 1. **State-aware action button per signer**, matching the EOI tab's + just-shipped pattern: + - `invitedAt === null` → primary "Send invitation" button + (paper-plane icon, fires the same `send-invitation` route the + EOI tab uses, which now handles v2 distribute-or-self-heal). + - `invitedAt !== null && status === 'pending'` → outline "Send + reminder" button (bell icon). + - `status === 'signed'` → no button, signed-when timestamp. + - `status === 'declined'` → no button, rose tint card, "Declined + on {date}" line. Surfaces the rejection. + 2. **Visual parity with `` from the EOI tab.** + Avatar circle with cleaned initials (strip `(was: ...)` / + `(placeholder)` suffixes), status-icon overlay, color-tinted card + per status (pending neutral, opened sky, signed emerald, declined + rose), left accent stripe, activity timestamps inline. Promote + the existing `` component into a shared element + this page also uses — same data shape. + 3. **Linked Entity row — clickable name + entity-typed label.** + Resolve the polymorphic FK chain on `documents` (`interestId`, + `clientId`, `yachtId`, `companyId`, `reservationId`) into a tile + showing the entity TYPE + NAME with a `` to its detail + page. e.g. "Interest — Matt Ciaccio (Berth A2)" linking to + `/interests/`. Multiple linked entities show as a chip row. + 4. **Watchers section — copy + Add UI.** + - Heading subtitle: "Watchers get an in-app notification on + every signing event (opened, signed, declined, completed)." + - "Add watcher" combobox picking from CRM users in the port. + - Existing watcher rows get a delete (×) button. + - Backend: routes already exist + (`/api/v1/documents/{id}/watchers` POST + DELETE). + 5. **Activity panel — read from `document_events`.** + - Reverse-chrono list of every event for this document: + created, sent, viewed (by signer name + time), reminder sent + (to whom + when), signed (by whom + when), rejected (by whom + + when + reason if Documenso passed one), completed, voided, + deleted. + - Each row: small icon per event type, actor name + relative + time + precise tooltip. Mirror the audit-log row pattern. + - Empty state only renders when there are genuinely zero rows + (not the current "default to empty even when rows exist" + behaviour — confirm the read path on `getDocumentDetail` + actually returns events). + 6. **Cleanup the leaked `(was: )` suffix** on signer name + displays — same `cleanSignerName` helper we shipped on + SigningProgress applies here. Currently rows render + `Matt Ciaccio (was: matt@letsbe.solutions)` which is the + EMAIL_REDIRECT_TO redirect leaking through. +- **Effort:** Medium-high (~4-6h). Most data is already in DB + + routes; this is mostly a UI rebuild + activity-feed read-path fix + - adopting the shared SigningProgress component. +- **Wires nicely with §9.Y2** (the dev-mode EMAIL_REDIRECT_TO badge + initiative) — this page is one of the surfaces that needs the + per-row redirect badge when the var is on. + +### 4.10 Delete EOI from history — UI on top of the shipped soft-delete backend + +- **Where:** EOI history list section of `interest-eoi-tab.tsx` (the + cancelled / past-EOIs strip) + the document list page. +- **Backend status:** ALREADY SHIPPED this session. `deleteDocument` + was changed from hard-delete to soft-delete: sets `status='deleted'`, + inserts a `documentEvents` row with `eventType='deleted'`, calls + `documensoVoid` on the linked envelope (best-effort), and audit-logs + the action with old/new status. Refuses to fire while signing is + in-progress (`sent` / `partially_signed`) — rep has to cancel first. +- **What's missing — frontend:** + 1. **Per-row Delete button** on cancelled / expired / completed-with- + no-file rows in the EOI history list. Confirms first with a + concise modal: "Delete this EOI? The Documenso envelope will be + voided and removed from upstream; the audit log keeps a record. + This can't be undone." (Stronger copy than cancel because the + surface implies permanence even though the docs row stays.) + 2. **Filter the primary EOI list** to exclude `status='deleted'` rows + so deleted entries don't clutter the timeline. + 3. **Surface deleted rows under a "Deleted" filter chip** alongside the + existing status chips so the rep can browse history. + 4. **Server-side filter check** — `getDocumentsForInterest` / + `listDocuments` need a `includeDeleted: boolean` knob (defaults + to false). Without this the "Deleted" filter has nothing to query. + 5. **Audit-log surface**: deleted docs show up in the interest's + activity timeline as "Deleted by {user} on {date}" — confirm this + is wired (`createAuditLog` is fired by the service; check the + timeline component reads action='delete' on entityType='document'). +- **Effort:** Small-medium (~45 min). Pure frontend + one server-side + filter knob on the list endpoints. + +### 4.12 v2 envelope title — debug why update doesn't stick in Documenso UI + +- **Where:** `src/lib/services/documenso-client.ts` → + `documensoGenerateFromTemplate` v2 branch. The update path is wired: + ``` + POST /api/v2/envelope/update + body { envelopeId, data: { title } } + ``` + Per Documenso v2 docs that's the correct shape. Title field accepts a + string while envelope is in DRAFT (ours is, we update before distribute). +- **Symptom:** Documenso's "Documents" list keeps rendering the + template's underlying PDF filename + (`Port Nimara-Berth-EOI-NDA_October2025_FINAL.pdf`) instead of our + intended title (`Matt Ciaccio-EOI-NDA-A2`). Persists after multiple + fresh generates with the corrected endpoint shape. +- **Hypotheses (ordered most → least likely):** + 1. **Documenso UI displays PDF filename even when envelope.title is + set.** The list view's "Title" column may prefer the underlying PDF + name as a fallback. To rule out: check the envelope detail view + (`signatures.letsbe.solutions/t/.../documents/envelope_xxx`) — if + the detail header shows `Matt Ciaccio-EOI-NDA-A2`, the API is + working and only the list UI is misleading. + 2. **Update call returns 200 with `{success: false}` silently.** Our + `documensoFetch` only throws on non-2xx HTTP. The verification + log line shows what the API actually persisted vs what we sent. + `titleMatches: false` here would mean the update is being + accepted-but-not-applied (likely a v2 schema validation that + drops unknown / malformed fields without erroring). + 3. **Field name mismatch.** Maybe v2 internally stores `data.name` + not `data.title`, and the docs are stale. Could try `data.title` + AND `data.name` in the same body and see if either takes. + 4. **Template-bound titles.** v2 might enforce that envelopes + created via `/template/use` inherit and lock the template's + title — and `envelope/update` is for non-template envelopes + (made via `/envelope/create` with a fresh PDF). Workaround in + that case: rename the underlying PDF before uploading to the + template, OR use `/envelope/create` instead of `/template/use` + so we control the source PDF filename per-document. + 5. **Auth header on update call.** Docs show `Authorization: api_xxx` + but our `documensoFetch` always prefixes `Bearer`. The Bearer + prefix works for `/template/use` and `/envelope/distribute`, so + this is unlikely — but worth checking the response if hypothesis + 2 looks accepted-but-coerced. +- **Debug plan (pair w/ Matt):** + 1. Tail Next.js dev server console (`pnpm dev` terminal). + 2. Generate one fresh EOI from the Overview tab. + 3. Capture the two new log lines: + - `Documenso envelope title update — response` + - `Documenso envelope title update — verification` + 4. Decision tree: + - `titleMatches: true` → hypothesis 1 wins. Open the envelope + detail URL in Documenso, confirm title is right there, file + a Documenso UI bug / decide if we care. + - `titleMatches: false` → hypothesis 2/3. Inspect raw + `updateResponse` body for any validation errors. Try the + dual-field POST (`data: { title, name }`) as the next test. + - No log lines printed → the update call isn't firing at all. + Check the order of operations in the v2 branch + verify + `desiredTitle` resolves to a non-empty string at runtime. + +### 4.14 Deal pulse + sales process: missing signals, oscillation risk, Regenerate flow + +- **Where:** `computeDealHealth` in `src/lib/services/deal-health.ts`, + pipeline-stage auto-advance (`advanceStageIfBehind` in + `interests.service.ts`), EOI cancel flow in `documents.service.ts`, + EOI tab UI. +- **Three coupled changes from Matt's design question:** + 1. **Add positive "EOI sent" signal to deal pulse.** Today + `computeDealHealth` only has the NEGATIVE signal "EOI awaiting + signature for >14d → -10" (lines 165-178). No `+X` for the moment + the EOI is dispatched. Add: when `dateEoiSent` is set AND + `eoiDocStatus` IN ('sent', 'partially_signed') AND the doc is NOT + cancelled/rejected/deleted AND `<14d` since send → +10. The + existing -10 trips automatically once we cross the 14d threshold. + 2. **DECISION NEEDED — auto-advance pipelineStage on EOI generate.** + Currently generating an EOI doesn't move the stage from + `qualified` → `eoi`. Auto-advance via `advanceStageIfBehind` + would give an additional +10 to the pulse via `stage_progress` + AND make the kanban / pipeline view reflect what's actually + happening. **Risk:** introduces stage oscillation if EOI is + cancelled later (no auto-rollback). Mitigated by the Regenerate + flow below — but only for the typo-fix path; full cancels + would still leave the stage at `eoi` until the rep moves it + manually. **Matt's call needed: ship the auto-advance or keep + stage moves explicit?** + 3. **Replace cancel+regenerate with a single "Regenerate" button.** + UX win + oscillation defuser: + - Single button on the active EOI card, next to "Cancel EOI". + - **Pre-invite path** (`invitedAt === null` on every signer): + silent in-place replace. Wraps cancel + generate-new in one + transaction so `interest.eoiDocStatus`, `dateEoiSent`, + deal-pulse score, and pipeline stage never dip between the + two calls. The Documenso envelope is voided + a fresh one + created; the UI just flashes the new EOI in place. + - **Post-invite path** (anyone in the chain has been emailed): + warning modal listing each signer's email + `invitedAt` + timestamp + a required reason field ("Why are we regenerating? + Logged for audit"). Then same in-transaction cancel+generate. + - Both paths reopen the EOI generate drawer pre-filled with the + current details first, so the rep can fix the wrong-data + reason before the new envelope mints. + - Captures the actual workflow — Matt's right that ~all cancels + are "I made a typo, let me redo" rather than "kill this deal" + (the latter goes through Cancel + don't regenerate). +- **Effort:** Medium (~3-4h). New deal-pulse signal + decision-gated + stage auto-advance hook in `generateAndSignViaDocumensoTemplate` + + new POST `/api/v1/documents/{id}/regenerate` route that wraps + cancel+generate in a transaction + reopen the EOI generate drawer + with prefill + UI button placement. + +### 4.15 Sales-process + deal-pulse trigger audit + +- **Why:** Matt called out that the automatic raising/lowering triggers + for both the pipeline-stage advance flow AND the deal-pulse score + aren't surfaced anywhere as a holistic list, and likely have gaps + (e.g. the missing positive "EOI sent" signal in §4.14 above is one + symptom). +- **Audit scope — list every place that touches each automatic + trigger:** + - **Pipeline-stage auto-advance** (`advanceStageIfBehind` callers, + plus any direct `changeInterestStage` calls fired by service + code rather than the user). Map every call site to the trigger + condition + the target stage. Cover: EOI signed-webhook flow, + deposit-received auto-advance, contract-signed webhook, + reservation-stamped, won/lost outcome flips, manual stage moves. + - **Stage auto-rollback** (does any code path move a stage + BACKWARD automatically — e.g. when an EOI is cancelled or + rejected?). Likely the answer is "no" — confirm in the audit, + decide whether that's correct or a gap. + - **Deal-pulse signals** (`computeDealHealth` — every `signals.push` + - the conditions guarding it). Document each: + * `active_engagement` (+5 if any contact-log entries in last 7d) + * `contact_recent` (+20 if dateLastContact <=7d) + * `contact_warm` (+10 if <=14d) + * `contact_stale` (-15 if >=30d) + * `stage_progress` (+10/+20/+30, capped, per pipelineStage index) + * `stuck_top_funnel` (-10 if firstDays >=30 + stage in + enquiry/qualified) + * `eoi_awaiting` (-10 if eoiSentDays >=14 + not signed) + * `deposit_pending` (-10 if reservation signed >=21d + no deposit) + * `contract_awaiting` (-10 if contract sent >=14d + not signed) + - **Heat tooltip explainer** — verify the in-product copy matches + the actual computation logic (any drift = confusing). +- **Gaps to flag as candidates for a fix wave:** + - Missing positive signals: EOI sent (§4.14 §1), deposit received, + contract signed (the moments of progress should each contribute). + - Missing negative signals: signer declined / EOI rejected, + interest archived-and-unarchived cycle (zombie deals), reservation + cancelled, deposit refunded, berth status change to sold-to-other. + - No "signer engagement" pulse signals — even though Documenso + fires `RECIPIENT_VIEWED` webhooks. A signer who opened but + didn't sign in N days is a stalling-signal worth surfacing. + - Stage auto-rollback policy — currently nothing rolls a stage + back on EOI cancel; that may or may not be correct. + - Reminder cadence on `eoi_awaiting` — currently a single -10 at + 14d. Could escalate to -20 at 21d, -30 at 30d. +- **Output:** A short audit doc (`docs/deal-pulse-trigger-audit.md`) + listing every trigger + a punch-list of gaps to address in + follow-up commits. Once that's in front of Matt, he picks which + gaps to ship vs which to defer. +- **Effort:** Small (audit + doc, ~1h). The fixes themselves are + scoped per-gap once the audit is in. + +### 4.13 EOI rejection — cascade emails + notifications + UI banner + +- **Where:** webhook handler `handleDocumentRejected` in + `src/lib/services/documents.service.ts` (already wired end-to-end at + the data layer) + new UI banner on the EOI card + new + cascade-email service. +- **What's already wired at the data layer (no change needed here):** + - `DOCUMENT_REJECTED` / `DOCUMENT_DECLINED` webhook events are + handled by `handleDocumentRejected`. It flips + `documentSigners.status = 'declined'` for the rejecting recipient, + `documents.status = 'rejected'`, `interests.eoiStatus = 'rejected'`, + inserts a `documentEvents` row with `eventType: 'rejected'`, emits + `document:rejected` over the socket bus. +- **What's missing (what to ship):** + 1. **Role-based cascade email** when a signer rejects. Logic per + Matt: + - **Client rejects** → email developer + approver. The deal is + likely dead — internal team needs to know to stop work / close + the interest. + - **Developer rejects** → email **both** client and approver. + Internal sign-off failed; both sides of the table need to know + the deal stops here. + - **Approver rejects** → email **developer only**. Final-stage + internal escalation; client doesn't know yet so the developer + can attempt to salvage (renegotiate terms, escalate further) + before notifying the client. + - Cascade fires inside `handleDocumentRejected` via a new + `sendRejectionCascade(documentId, rejectingRole)` helper that + reads the doc's signers + the rejecting role + dispatches via + the existing `sendSigningInvitation`-adjacent path with a + per-port branded "EOI rejected" template. + 2. **In-app notification** to: + - Interest assignee (always, regardless of who rejected) + - The CRM users linked to the developer + approver slots + (`documenso_developer_user_id` / `_approver_user_id` if set — + fall back to the port admin list if unset). + - Notification body: who rejected (signer name + role) + reason + if Documenso captured one + deep-link to the EOI tab. + 3. **UI banner on the EOI card** when `documents.status = 'rejected'`: + - Distinct from the existing CANCELLED state (rose accent stripe + on the card top edge + a single-line banner). + - Reads: "Rejected by {signerName} ({role}) on {date}". + - If Documenso passes a rejection reason, show it on a second + line: "Reason: {reason}". + - CTAs on the banner: "Reopen this interest (re-negotiate)" + + "Archive interest (deal dead)" — both audit-logged. + 4. **Per-signer card visual on the SigningProgress component** — + the rose tint + X icon overlay already exists for `declined` (was + shipped in the signing-progress redesign), so this just naturally + surfaces once the webhook fires. No extra UI change. +- **Cascade-email template content** needs admin-tunable copy via the + registry (new section `email.rejection_templates`) — three keys: + `rejection_email_to_internal_subject` / + `rejection_email_to_internal_body` (used when developer or approver + rejects → emails to client) and + `rejection_email_internal_escalation_subject` / + `rejection_email_internal_escalation_body` (approver-rejects → + developer-only escalation). Default copy ships in the migration. +- **Effort:** Medium-high (~3-4h). Service helper + 3-4 email + templates + UI banner + notification entries + audit log + tests. + +### 4.11 EOI real-time signing-progress tracking — verify wiring end-to-end + +- **Where:** Documenso webhook handler → `documentSigners.status` updates + → realtime broadcast → `SigningProgress` re-renders. +- **What's already there:** + - Documenso webhook receiver at `/api/webhooks/documenso/route.ts` + handles `RECIPIENT_VIEWED` / `RECIPIENT_SIGNED` / + `DOCUMENT_SIGNED` / `DOCUMENT_COMPLETED` and updates the matching + `document_signers` row (by token or email). + - `SigningProgress` polls `/api/v1/documents/{id}/signers` every 30s + (see `useQuery` refetchInterval). +- **Gaps to verify (Matt to test):** + - When a signer opens the doc → does `openedAt` get stamped + does + the next poll surface the new "Opened" state (blue tint card + + eye icon) within 30s? + - When a signer signs → does the status flip to "Signed" (emerald + card + checkmark) + the EOI card header counter "X of N signed" + update on the same poll? + - Socket-based push (not just poll) — `emitToRoom` is wired on doc + events; check that the interest detail page subscribes to + `port:{portId}` and invalidates the signers query on + `document:signer_updated` so the UI updates within seconds rather + than waiting for the 30s tick. +- **If gaps exist**: implement socket-subscribe → query invalidate on + the interest detail or EOI tab so the SigningProgress card updates + in real time. +- **Effort:** Verification (15 min Playwright) + small fix if missing + (~30 min). + +### 4.9a Embedded signing host — Test button + verified-at gate + +- **Architectural rule (Matt 2026-05-15):** all outbound signing-invite + emails go through our branded `sendSigningInvitation` template. + Documenso never fires its own emails for our envelopes + (`meta.distributionMethod: 'NONE'` enforced at distribute time). + The link inside our branded email points to either: + - The wrapped marketing-site URL (`{embeddedSigningHost}/sign//`) + when the host is configured AND **verified working**. + - The raw Documenso signing URL otherwise. +- **What's wired today:** `wrapBrandedSigningUrl` in + `document-signing-emails.service.ts` checks `embeddedSigningHost !== +null` and either wraps or passes through. No verification gate. +- **What's missing:** the "verified" half. Typo or unreachable + marketing host = broken email links + no warning to the rep. +- **Ship:** + 1. **Test connection button** on the admin + `embedded_signing_host` field. Click → server-side fetches + `{host}/sign/__probe__` (or an agreed sentinel path) with a + short timeout, expects a known 200/404 shape. + 2. On success, persist `embedded_signing_host_verified_at` in + system_settings. + 3. `wrapBrandedSigningUrl` checks + `verifiedAt IS NOT NULL && verifiedAt within last 30d` before + wrapping; otherwise falls back to raw Documenso URL even if + the host is set. + 4. Admin UI badge per state: green "Verified {date}", amber + "Verified more than 30d ago", red "Not verified — links fall + back to Documenso". + 5. Saving a new host clears `verifiedAt` so the rep has to re-test + after every change. +- **Effort:** Medium (~1-2h). New POST endpoint + service helper + + registry timestamp column + UI button + branded badge. + +### 4.9b Embedded signing — admin help button with setup instructions + +- **Priority:** LOWEST. Don't touch until everything else in §4 is + shipped. +- **Why:** Right now the only way a port admin can stand up a NEW + marketing site to host our Documenso embedded signing pages is by + pinging us. Knowledge is tribal. Adding an in-product help surface + makes it self-serve for future ports / contractors. +- **What to ship:** + - Small `?` / "Setup instructions" button next to the + `embedded_signing_host` admin field. Click → opens a side Sheet + (right slide-in) with the full how-to. + - Content of the how-to: a step-by-step that covers everything a + fresh marketing-site project needs to wire up the embed: + 1. The `/sign/[type]/[token]` route (signature host page — + iframe wrapper for the Documenso UI). + 2. The runtime config the route reads + (`useRuntimeConfig().public.documensoHost` or equivalent). + 3. Env vars the marketing site needs (Documenso instance URL, + any CSP / sandbox flags). + 4. Post-sign redirect page (the URL Documenso sends signers to + after they finish — our `documenso_redirect_url` setting must + point at it). + 5. DNS / Cloudflare config for the signing subdomain + (`signatures.{port-domain}` typically). + 6. How to verify end-to-end: generate an EOI, send invitation, + open the email, click the wrapped URL, confirm the embed + loads + signing flow completes. +- **Source repos to analyse before writing the instructions** (so the + doc reflects what actually works, not what we think works): + - **THIS CRM repo** — `wrapBrandedSigningUrl` in + `document-signing-emails.service.ts`, the + `embedded_signing_host` + `documenso_redirect_url` registry + entries, how the `/sign//` URL shape is generated. + - **OLD CRM repo** — any legacy scripts or docs that already + captured this integration (don't reinvent if there's prior art). + - **Port Nimara website repo** + (`/Users/matt/Repos/Port Nimara/Website`) — the actual + `/sign/[type]/[token].vue` route that wraps the iframe, the + runtime config it reads, env vars it expects. +- **Effort:** Small-medium (~1-2h once we commit to doing it). Mostly + documentation work + a help Sheet component. No new wiring. +- **Pairs with §4.9a** (verified-at gate) — the help instructions + should reference the Test button and the verified-at workflow. + +### 4.9 Marketing-site embedded signing link + +- **Where:** `embedded_signing_host` setting + marketing-site `/sign/[type]/[token]` route. +- **Current:** Dev tests use raw Documenso URLs (skipping the wrap layer). +- **Desired for prod-style testing:** Patch the marketing site's hardcoded + `documensoHost = 'https://signatures.portnimara.dev'` (line 142 of + `/Users/matt/Repos/Port Nimara/Website/pages/sign/[type]/[token].vue`) to read + from `useRuntimeConfig().public.documensoHost` so the iframe can point at the + user's testing Documenso instance. +- **Effort:** Small (marketing site repo). Out of scope of CRM repo but needed + end-to-end. + +--- + +## 5 · Berth recommender + +### 5.1 "Add to interest" surfacing + +- **Where:** `BerthRecommenderPanel` rec cards. +- **Current:** Add button only appears inside the expanded card body. Reps + scrolling the list have to click each card to reveal it. +- **Desired:** Quick-add button on the collapsed card row too (small icon button + next to the score). Same `AddBerthToInterestDialog` opens. +- **Effort:** Small (just an extra Button in the row header). + +### 5.2 Add-berth dialog: in-EOI-bundle toggle + +- **Where:** `src/components/interests/add-berth-to-interest-dialog.tsx`. +- **Current:** Radio between "Pitching specifically" (marks "Under Offer" on + public map) and "Just exploring" (internal-only). +- **Desired:** Third toggle for `isInEoiBundle` ("genuinely interested — include + in the EOI's signed berth range"). Matt mentioned wanting "if they're genuinely + interested" alongside the map-marking toggle. +- **Effort:** Small. Service already accepts the field; add UI checkbox. + +--- + +## 6 · Global search + +### 6.1 Verify coverage gaps + +- **Status:** Search 500 (reminders bug) shipped. Archived clients confirmed hidden + from search per Matt's choice. Search-by-email confirmed working (via + `client_contacts` JOIN). +- **Unverified — possible gaps:** + - **Client address fragments** — `clients.address` JSONB isn't queried in + `searchClients`. + - **Yacht hull number / registration** — should be in `searchYachts`; needs a + spot-check. + - **Company tax ID / billing address** — same. +- **Effort:** Small per field (ILIKE predicate in the existing SQL block). + +--- + +## 7 · Layout / copy / UX cleanups (small) + +### 7.1 Heat / deal-pulse explainer docs page + +- **Where:** `DealPulseChip` popover has a "Full guide" link to `/docs/deal-pulse`. +- **Current:** Route doesn't exist yet. Click 404s. +- **Desired:** Static doc page or markdown render explaining the rule-based score + in plain English. +- **Effort:** Small. + +### 7.2 Stage guidance card + +- **Where:** Overview tab. +- **Desired:** For each pipeline stage, a small "next step" card on the Overview + that explains what the rep needs to do to move to the next stage, with + shortcut buttons (Send EOI, Generate Reservation, Record deposit, etc.). + Replaces the empty Payments slot at non-deposit stages. +- **Effort:** Medium (stage-aware component + per-stage copy). + +--- + +## 8 · Supplemental info request form + +### 8.1 CRM-hosted vs marketing-site + +- **Status:** Matt asked whether the supplemental-info button on the + EOI-not-ready card creates the form on the CRM or relies on the marketing site. + Said **save for end of pass**. +- **Action when revisited:** Trace `SupplementalInfoRequestButton` → + the public form route → confirm whether it's CRM-hosted (good — works out of + the box) or marketing-site-hosted (needs the website repo to ship the form). + Fix gap if any. + +### 8.2 Conditional render + dual-mode copy + +- **Where:** `SupplementalInfoRequestButton` card (the "Need more info before + drafting the EOI?" card on the interest Overview). +- **Current:** Always renders the same copy ("Need more info before drafting the + EOI?"), regardless of whether the client actually has the required fields. +- **Desired (two modes):** + - **Missing-data mode** (current copy): when the interest's EOI context is + failing the required-field check (name / email / address / yacht / berth not + set), show the prompt as it currently reads — the rep should send the form + to fill the gaps. + - **Confirmation mode** (new): when everything required is already on file, + swap to a softer prompt — "Send a one-time form to the client to **confirm** + their info?" — for cases where the sales person wants the client to verify + the data themselves before the EOI goes out. +- **Plus:** Add padding above the header at the top of the card (currently the + title sits flush against the parent edge). +- **Effort:** Small. Conditional copy branch driven by the same `eoi-context` + check the dialog already uses, plus a `pt-6` (or similar) on the CardHeader. + +--- + +## 9 · Reference: shipped this session (for context) + +Settings / admin: + +- Env→admin migration registry + resolver + form +- "Save N changes" bulk button on every registry-driven form +- "Reveal" endpoint for encrypted settings (admin can verify what's saved) +- Sync result panel persists across refresh (cached `documenso_eoi_template_sync_report`) +- Template-level meta (signing order / distribution method / redirect URL) shown after Sync +- EOI generation + EOI templates card migrated to registry-driven form + +Documenso v2 buildout: + +- `getTemplate(envelope_id|numeric_id)` + template sync endpoint +- AcroForm inspection: downloads each envelope item's PDF, inspects native fields, diffs against CRM expected EOI label set +- `prefillFields`-by-ID emission for v2 instances +- `updateEnvelope` v2-only wrapper +- CC / VIEWER recipient roles + `extraRecipients` +- Health check now uses `/api/v2/document` for v2 (was `/api/v1/health` which doesn't exist on v2 cloud) +- Sync misrouting bug fixed (recipient IDs were being written to user-ID keys) +- User-select dropdown for the developer/approver linked-CRM-user fields +- Default name/email placeholders ("David Mizrahi" / "Abbie May") removed; blank now passes `""` to Documenso which falls through to template's stored values + +Forms / UI: + +- Yacht dimensions auto-convert ft↔m (paired inputs, single Dimensions section) +- Phone input width fixed (CountryCombobox `w-24 shrink-0`; PhoneInput wrapper `w-full`) +- Primary contact per-channel (Primary email + Primary phone, etc.) +- Yacht picker: dashed-border "Add yacht for this client" prompt when zero yachts +- Lead Category + Stage auto-fill on berth pick (create mode, manual-touch wins) +- Tags section hidden when port has zero tags +- Multi-berth selector on InterestForm (first=primary, extras via `/berths` POST) +- Dedup-suggestion panel shows archived state with Restore link +- "Open" stage copy purged (replaced with "New Enquiry") +- "no AI" mentions removed from DealPulse popover + heat tooltip copy + +Search / data: + +- Search 500 fixed (`searchReminders` calling `.toISOString()` on string) +- All `archived_at.toISOString()` calls hardened across `search.service.ts` +- `match-candidates` returns `archivedAt` so dedup panel can render archived state +- New `/api/v1/admin/users/picker` for the user-select dropdown +- Search archived behaviour decided: hide (matches Matt's call) + +Interest detail / pipeline: + +- Phase classification rewritten — Overview always surfaces a CURRENT milestone +- Payments section hidden before reservation stage +- DealPulseChip popover (click instead of hover, plain-language explainer, "Full guide" link placeholder) +- EOI MilestoneSection footer with `Generate EOI` (opens drawer) + `Open EOI tab` (deep link) buttons +- `EoiGenerateDialog` mounted at OverviewTab level (state was wired but component never rendered) +- Recommendations tab swapped from legacy "AI" `RecommendationList` to the same rule-based `BerthRecommenderPanel` used on Overview +- Recommendations empty-state "Show oversized matches too" button (raises `maxOversizePct` to 1000 so berths beyond strict tolerance surface) +- `dimensions` qualification auto-satisfies on yacht-dims OR desired-berth-dims +- Area letter dedup, In-EOI-bundle empty-header dedup, Berth Range tooltip spacing fix +- Recommendations header shows entered unit (ft or m) +- "Mark EOI as sent" → "Mark EOI as sent manually" (Documenso webhook auto-stamps for normal sends) + +EOI generate drawer (in flight): + +- Dialog → Sheet conversion +- Inline fix-it form for missing name/email/address (uses canonical components, persists via PATCH/POST) +- Real upstream error message surfaces ("Cannot generate EOI — missing X, Y, Z") instead of generic "preview failed" +- Address inline fix-it now accepts **street, city, region, postal, country** (was just + street + city + country); persists postalCode + subdivisionIso to the address row +- Rendered EOI Address line format: `street, city, REGION, postal, COUNTRY-ISO` + (e.g. `123 Sesame Street, Staten Island, NY, 10306, US`) — shortest comprehensive + form to fit the AcroForm box +- `Nationality` removed from the required preview rows + Section 2 helper copy; + the resident's country code on the Address line carries that meaning now + +Interest Overview teaser: + +- "Latest note" no longer renders the raw `authorId` UUID — `getInterestById` + now LEFT JOINs `userProfiles` so the teaser shows the author's display name + (falls back to "Unknown" if the user row is missing) + +Documenso integration polish (later in this session): + +- **CRM-as-source-of-truth signer wiring** — `buildDocumensoPayload` now + resolves developer + approver name/email per port via: + linked CRM user (`documenso__user_id` → `userProfiles.displayName` + - `user.email`) → free-text override (`documenso__email/name`) + → legacy `eoi_signers` JSON blob → empty (template wins). Replaces the + hardcoded "David Mizrahi" / "Abbie May" placeholders. +- **EOI title format** — `-EOI-NDA-` (e.g. + `Matt Ciaccio-EOI-NDA-A2`). Tested for single + multi-berth + no-berth. +- **v2 title PATCH after `template/use`** — fixed broken endpoint + shape: was `PATCH /api/v2/envelope/{id}` with `{title}`; correct is + `POST /api/v2/envelope/update` with `{envelopeId, data: {title}}`. + Restricted to DRAFT envelopes which is what we always have post-create. +- **`document_signers` rows inserted on generate** — was missing; the + EOI tab's "Signing progress" panel showed "No signers loaded" forever + because the webhook handler only updates existing rows. Now the + Documenso `recipients` array from `/template/use` is persisted at + create time. +- **Interest milestone stamping** — `eoiDocStatus='sent'` + `dateEoiSent` + flip immediately on generate so the Overview tab transitions out of + the "Generate EOI" prompt without waiting for the next refresh. +- **Cache invalidation on generate** — the EOI dialog's onSuccess now + invalidates `interests/{id}`, `interests/{id}/eoi-context`, + `interests/{id}/timeline`, and the documents predicate so every + surface that shows the new EOI state updates without a manual refresh. +- **Dimension unit toggle in EOI drawer** — ft/m chip in the Optional + section header; defaults to the rep's original entry unit + (`yacht.lengthUnit`). Drives the preview-row display + the + `Length`/`Width`/`Draft` formValues sent to Documenso / filled into + the in-app PDF. +- **EOI Address line format** — `street, city, REGION, postal, COUNTRY-ISO` + (e.g. `123 Sesame Street, Staten Island, NY, 10306, US`). Inline + fix-it form accepts all 5 fields. Nationality requirement gone — the + resident's country code on the Address line carries that meaning now. +- **Soft-delete documents** — `deleteDocument` now sets + `status='deleted'` + voids the upstream envelope (best-effort) + + inserts a `documentEvents` row + audit-logs the action. No more hard + delete that destroyed event history. +- **Signing-progress card redesign** — vertical card list (no connector + line), per-status visual treatments (pending/opened/signed/declined), + initials-aware avatar with status icon overlay, state-aware action + button ("Send invitation" before `invitedAt`, "Send reminder" after), + precise timestamps in tooltips, and a Sequential / Concurrent + signing-order badge in the EOI card header pulled from the synced + template meta with fallback to per-port setting. +- **Phone-number formatting in interest Overview Contact section** — + uses libphonenumber-js `formatInternational()` so raw E.164 + (`+33633219796`) renders as `+33 6 33 21 97 96`. +- **Interest detail header** — added "Client page" button next to the + Email/Call/WhatsApp quick actions. +- **Qualification "dimensions" criterion copy** — "We know the vessel's + length, width, and draft" → "Vessel dimensions OR desired berth + dimensions are recorded (length, width, draft)" to reflect the + auto-satisfy rule. Migration `0067` applied to dev DB. + +--- + +## 9.Y Notifications: auto-mark-as-read on dropdown view + +- **Where:** `NotificationItem` / notifications dropdown components in + `src/components/notifications/`. +- **Current:** Notifications stay unread until the user clicks each one or + hits "Mark all read". +- **Desired:** Auto-mark each notification that was actually rendered in + the dropdown as read. Two flavours to pick from: + - **(a) On display** — fire `markRead(id)` as each item renders. Pro: + matches Slack/Linear pattern. Con: unread bubble drops to 0 the + moment the dropdown opens, even before the user has time to scan. + - **(b) On dropdown close** — batch-mark every currently-rendered ID + when the user closes the dropdown. Pro: bubble + bold styling stay + while the dropdown is open so the user can re-find unread items. +- **Question for Matt:** Pick (a) or (b)? The skipped (un-rendered) + notifications stay unread either way. +- **Effort:** Small. New `POST /api/v1/notifications/mark-read-bulk` + endpoint (or per-id PATCH × N), useEffect in NotificationItem (option a) + or onOpenChange handler on the dropdown (option b). + +--- + +## 9.X Same-stage move emits a confusing notification + +- **Where:** `changeInterestStage` in `src/lib/services/interests.service.ts` + (notification block at ~line 944) and any other call site that emits + `interest_stage_changed` (`advanceStageIfBehind`, webhook auto-stamps, + Documenso completion handlers). +- **Symptom:** Notification panel shows e.g. "Marco Bianchi moved to EOI / + Stage changed from EOI to EOI" — same-stage transition surfaces in the + inbox even though `changeInterestStage` returns `STAGE_NOOP` for the + matching pipeline-stage case. +- **Suspect:** A different code path (likely `advanceStageIfBehind` or one of + the Documenso-webhook auto-advance helpers) emits a stage-changed + notification without checking `oldStage !== newStage`. Or `STAGE_LABELS` + has two raw stage codes that map to the same display label so the + notification reads "EOI → EOI" when the real codes were different. +- **Fix:** Find the emitting site, add an `oldStage === newStage` early-return + before `createNotification`. Audit the same guard on every other + `interest_stage_changed` emitter. Same-stage events stay invisible to + reps (they're already filtered out of the audit log via STAGE_NOOP). + +--- + +### 4.7 Per-EOI reminder controls (with per-signer fine-tune) + +- **Where:** EOI card footer in `interest-eoi-tab.tsx` ("Reminders are + rate-limited (max once per 7 days per signer)" line) + the per-signer + rows in `signing-progress.tsx`. +- **Current:** Single global per-port `reminder_default_days` setting + + doc-level `remindersDisabled` + `reminderCadenceOverride` columns (the + schema already has them — see `documents.ts` lines 102-103). No UI + surfaces them. +- **Desired (tiered controls):** + - **EOI-level**: inline toggle to disable reminders entirely for this + EOI + an inline "Remind every X days" picker that overrides the port + default just for this document. Lives in the EOI card footer. + - **Per-signer fine-tune**: each signer card on the signing-progress + list gets its own "Remind this signer every X days" override picker + AND a per-signer toggle. **Reason from Matt: the developer is + known to miss emails — they need a tighter cadence than the rest.** + Persists on a new JSONB column `reminder_overrides` on + `document_signers` (or `documents.reminder_overrides_by_signer_id` + keyed by signer id — either works; signer-level is more discoverable + in the UI but locks the schema). +- **Resolution chain at reminder-send time** (existing + `sendReminderIfAllowed` worker): per-signer override → per-document + override → per-port default → null (no auto-reminders). +- **Surface the active value on each card**: small italic line under the + signer row like "Auto-reminds every 3 days · last reminded 2 days ago" + so the rep knows what's happening without digging. +- **Effort:** Medium (~1-2h). Backend: new JSONB column + resolver + branch in the reminder worker. Frontend: inline pickers on EOI card + footer + per-signer cards. The schema columns + `remindersDisabled` + `reminderCadenceOverride` are already there + for the document level — the per-signer dimension is the only new + thing. + +### 4.8 Cancel EOI — signature-aware modal + honest "void" copy + +- **Where:** `src/components/interests/interest-eoi-tab.tsx` (the + `Cancel EOI` button at the bottom of the EOI card) and + `src/lib/services/documents.service.ts`'s `cancelDocument` (already + calls `documensoVoid` correctly — no backend change needed). +- **Why this matters:** today the button reads just "Cancel EOI" and + pops a single-line confirm. Two real problems: + 1. **Documenso doesn't hard-delete on void.** `DELETE +/api/v2/envelope/{id}` (and the v1 DELETE) is a **void**, not an + erase — Documenso retains the envelope under a "Voided" status for + legal audit-trail reasons (industry-standard across DocuSign, + HelloSign, Documenso). Reps see the cancelled doc still listed in + Documenso and assume the void didn't fire. It did — it just + didn't disappear. + 2. **No protection against accidentally voiding partially-signed + docs.** If 1 of 3 signers already signed, the current single-line + confirm doesn't surface that — clicking Cancel discards the + captured signature with no warning. +- **What to ship:** + - **Signature-aware confirmation modal** that scales with state: + - **0 signed** → quick confirm. Copy: "Cancel this EOI? The + envelope will be voided on Documenso and signers won't be able to + access it." One click. + - **1+ signed but not all** → fuller warning modal listing + `{name} signed {humanRelative(signedAt)}` per already-signed + row. Body copy: "If you continue, those collected signatures + will be discarded and the envelope voided. This is + non-recoverable. Continue?" + - **All signed** → block the cancel entirely (the deal is past the + decision point). Offer an "Archive on our side" path instead + that hides the doc from the active EOI list but leaves the + Documenso envelope untouched as the legal record. + - **Button copy**: "Cancel & void on Documenso" (replaces just + "Cancel EOI") so the rep knows what happens upstream. + - **Helper tooltip / footer line** explaining void ≠ delete and that + legal traceability is the reason Documenso keeps the audit row. + - **After successful void**, hide cancelled docs from the primary + EOI tab list. Surface them under a "Cancelled" filter / pill so the + rep can browse history without "ghost" clutter. +- **Backend:** No change. `cancelDocument` already calls + `documensoVoid(documensoId)` which DELETEs against Documenso v1 or + v2 depending on the per-port `apiVersion`. Idempotent on 404. +- **Effort:** Small (~45 min). New modal component, the signature-count + branch logic, copy changes, filter UI on the EOI tab. + +--- + +## 9.Y2 Dev-mode `EMAIL_REDIRECT_TO` badge across all surfaces + +- **The quirk:** `env.EMAIL_REDIRECT_TO` is a dev/staging guardrail that + silently rewrites every outbound email recipient to a single address + (matt@letsbe.solutions in current setup) so test EOIs / portal invites + / completion emails can't accidentally land in a real client inbox. + Hard-blocked in production (`env.ts` refuses to boot when both + `NODE_ENV=production` and `EMAIL_REDIRECT_TO` are set). When the var + is on, the originals are baked into the recipient `name` field as + `(was: )` so they're recoverable for audit. +- **Why the user is asking:** today the redirect is invisible in the UI + — reps see the SigningProgress card showing `matt@letsbe.solutions` + for every recipient and think the per-role admin settings are + broken. They aren't — the settings ARE saved correctly, the redirect + just rewrites everything on the wire. +- **What to ship:** + - Add a small `DEV REDIRECT →
` pill in the global header / status bar + when the var is set. Single source-of-truth for "I am in dev-redirect + mode" so reps don't have to guess. + - Surface a per-row "DEV REDIRECT" badge on every UI that shows a + recipient email/name pair that the redirect modifies. Tooltip body: + "This email would normally go to {original}. The dev-only + EMAIL_REDIRECT_TO env var rewrote it to {redirectTo} so no real + recipient is contacted. Unset the var to send for real." +- **Surfaces that need the badge (enumerate so the implementer doesn't + miss any):** + 1. `SigningProgress` card on the EOI / Reservation / Contract tabs — + each signer row. **The Option A treatment (show parsed + `(was: original-email)` under the redirected email) ships now + as part of this initiative's first slice; the global pill + + other surfaces follow.** + 2. Document detail page recipient list. + 3. "Send invitation" / "Send reminder" confirmation toasts. + 4. EOI generate drawer — preview Section 2 client email row. + 5. Contact log → email-out action surfaces (when we add direct + email actions; some are placeholders today). + 6. Supplemental info request modal — the "request email will be sent + to: " preview line. + 7. Portal-activation send-out (`scripts/dev-trigger-portal-invite.ts` + surfaces UI confirmation; need badge on the admin "resend + activation" button). + 8. Password-reset / set-password confirmation modals. + 9. Signed-PDF completion email composer dialog ("Email signed PDF to + all signatories"). + 10. Outbound webhook dispatch UI in admin (webhooks are skipped when + `EMAIL_REDIRECT_TO` is set, per `workers/webhooks.ts` line 89-104). +- **Plumbing:** Add a tiny endpoint `GET /api/v1/system/email-redirect-state` + returning `{active: boolean, redirectTo: string | null}`. A React + context provider (``) hydrates once at app + boot and exposes it to a `` and + `` component. Each surface drops the + component in next to the relevant recipient. +- **Audit-pair extraction helper.** A shared + `parseRedirectedRecipient(name): { displayName, originalEmail | null }` + so every surface renders the `(was: ...)` consistently rather than + doing ad-hoc regex matching like the `cleanSignerName` I just shipped. +- **Effort:** Medium-high — backend endpoint + 2 React components + + ~10 surface drop-ins. Touchpoint enumeration above means it's + parallelisable across a few PRs. +- **Defers Option B**: not pursued. The redirect is correct behavior + for dev; the goal is making it visible, not hiding it. + +--- + +## 9.Z BIG: in-app PDF field editor + template builder + +- **Idea (Matt):** Build a browser-side editor inside our admin that lets + reps upload a PDF, place fillable fields on it (text / checkbox / date / + dropdown / signature), connect each field to a CRM data token from + `VALID_MERGE_TOKENS` (`src/lib/templates/merge-fields.ts`), and either + use the result for in-app document generation OR push it to Documenso + as a template via the API. +- **Why it's worth doing:** + - Centralizes template management in CRM (no Documenso UI round-trip + just to fix a typo / move a field). + - Unlocks custom one-off documents (port-specific addenda, info + requests, reservation variants) that currently can't be filled by + either pathway because there's no template editor. + - Same field-to-token mapping logic shared between in-app fill and the + Documenso push path. +- **Tech is all here — `pdfme` (already in stack) covers ~80%:** + - `pdf-lib` (already in stack) handles read/write of AcroForm fields + + positioning + types — used for the AcroForm-import bridge below. + - `pdf.js` renders PDF pages to a browser canvas at known DPI. + - **`pdfme` (already in stack) gives us almost the whole editor for free:** + - `Designer` class (`@pdfme/ui`) — drag-and-drop browser editor that + mounts into a DOM element, takes any PDF as `basePdf`, lets users + place + resize + rename + delete field schemas live. Built-in + Ctrl+S save hook + change listeners + page navigation. + - Built-in field types — `text`, `image`, `signature`, `checkbox`, + `radioGroup`, `qrcode`, barcodes — each with position + size + + per-type props (font, alignment, options for radio/dropdown). + - `Form` class — preview-with-sample-data is exactly what this does + out of the box (covers Matt's "placeholder per field" + "preview + with real record" requirements with no extra build). + - `generateForm()` — emits a real **interactive AcroForm** PDF + (text/checkbox/radioGroup). End users can fill in Acrobat or any + PDF viewer. This is the "fillable custom document" path Matt + asked about. + - `generate()` — flattened output for the "render + email/sign" + path. Same template, different output mode. + - Plugin API for custom field types (`Plugin` etc). + We'd use this to add a "Documenso signing widget placeholder" + field type for the Documenso push path. ~50 lines per custom type. + - Merge-token catalog already exists at + `src/lib/templates/merge-fields.ts` (`VALID_MERGE_TOKENS`). +- **What pdfme does NOT cover — what we'd actually build:** + - **AcroForm → pdfme schema import bridge** (the one piece of net-new + code). pdfme uses its own schema format, not AcroForm. To edit a PDF + that was made in Acrobat, we use pdf-lib's `form.getFields()` to read + every existing field's name + type + widget rect, generate matching + pdfme schemas, strip the AcroForm from the basePdf (so pdfme owns + placements). ~50–100 lines. + - **CRM merge-token mapping UI.** Repurpose pdfme schema's `name` field + as the merge token, OR add a sidecar map keyed by schema id. Add a + token-picker dropdown to each schema's pdfme edit panel. ~20 lines. + - **Documenso template push.** POST `/api/v2/template/create` with + multipart `{PDF, recipient slots, field placements as %-page coords}`. + %-coords are computable from pdfme schema positions. +- **Phased plan:** + - **Phase 1 — MVP (1–2 weeks focused).** Text + checkbox AcroForm + fields, in-app fill at generate-time only. New + `document_templates` row variant with a `field_map` JSONB + (`{fieldName: tokenOrSlot}`). Save path rewrites PDF via + `form.createTextField/Checkbox/Dropdown` + `addToPage`. Generation + fills AcroForm by name → resolved token value. + - **Phase 2 — Documenso template push (+2–3 weeks).** + `POST /api/v2/template/create` with multipart `{PDF, recipient slots, +field placements as %-page coords}`. Editor grows recipient-slot + config + signing-widget overlay placement (Documenso treats sig + fields as overlays, not AcroForm — store separately). Returned + `templateId` + recipient IDs persisted on our template row so the + existing `documenso-template` pathway can use it. + - **Phase 3 — Polish (+1–2 weeks).** All field types incl. radio groups, + conditional fields, validation rules, drag-snap-to-grid, page-aware + preview, dim mode. +- **Risk areas to flag upfront:** + - PDF coordinate gotchas (pdf-lib origin bottom-left vs canvas top-left; + rotated pages; flate-compressed pages need round-trip). + - AcroForm subtleties beyond text — radio groups + embedded JS. + - Documenso's %-coord placement is finicky; needs a preview iteration + loop or "looks right in browser" doesn't = "right when Documenso + renders". +- **Two additional requirements from Matt:** + - **Placeholder / preview text per field.** Each field config gets a + "Preview value" input alongside its name + token mapping. Live + preview re-renders the current page with placeholders filled + (overlay on the pdf.js canvas for live-typing, or pdf-lib fill + + re-render). Ship a "Preview with real record" mode too — pick an + existing client/interest/etc and render with that record's actual + values, so long French street names + non-Latin scripts + joint- + signer names actually surface their overflow cases. This is the + real-world bench for what would otherwise need a full EOI send to + catch. + - **Edit existing Acrobat-authored fields.** On upload, read every + AcroForm field via `form.getFields()` and render them as already- + placed boxes on the canvas. Select / rename / resize / reposition / + delete each. Quirk: pdf-lib doesn't expose `setPosition` directly on + a widget — workaround is read-properties → delete → recreate at the + new coords. Plain text/checkbox/dropdown/radio fields are fully + preserved; **calculated fields / embedded JavaScript will not + survive a save** (pdf-lib doesn't round-trip JS — flag this loudly + in the UI if the loaded PDF contains scripted fields). Acrobat's + "appearance streams" need regeneration via + `form.updateFieldAppearances()` on save. + - This explicitly covers the recurring "open my Acrobat-made EOI + source PDF, move the Email field down 6px, save it back" workflow + that currently requires bouncing to Acrobat for every pixel-level + template adjustment. +- **Decision needed from Matt:** Ship Phase 1 alone first (biggest win, + shortest path, in-app custom docs immediately) or wait until we can + scope Phases 1+2 together? + +--- + +## 9.V Audit log — expandable rows with full detail + +- **Where:** `/admin/audit` page; `audit-log-list.tsx` table + the + already-built `AuditLogCard` component. +- **Symptom:** Rows show a placeholder summary (entity, action chip, + changed-fields list, actor name + email, short IP) but can't be + clicked to expand. The actual `oldValue` / `newValue` / `metadata` / + full `userAgent` are stored in DB and even typed on + `AuditLogCard` — they're just not surfaced on desktop. +- **What to ship:** + - Make each row click-to-expand OR open a side Sheet (right slide-in). + Side Sheet is cleaner because `oldValue` / `newValue` can be deeply + nested JSON that doesn't render well inline. Less reflow on a + long-list page. + - Detail panel content (everything the audit table currently hides): + - Full timestamp with ms precision + relative ("about 1 hour ago") + - Action + entity + entityId (clickable deep-link to the entity + page where possible) + - Actor block: display name, email, userId, port name + slug + - Network: full IP, full userAgent string, request-id if present, + port context, port + global flags + - **Side-by-side oldValue / newValue diff** — JSON pretty-printed + with the changed keys highlighted (mirror the `git-diff` look: + removed red, added green). Falls back to plain pretty-print for + `view` / `delete` events where one side is null. + - Metadata block — pretty-printed JSON + - Related events: any audit-log siblings emitted within the same + request-id, listed compactly so a forensic trace of a multi-step + action is one click away. + - "Copy as JSON" button on the detail panel for forensic exports. +- **Effort:** Medium (~2h). Wire `cardRender` style detail to a Sheet + on click + a JSON diff visualiser. The existing `AuditLogCard` already + has most of the layout — promote it into the Sheet body. + +--- + +## 9.W Admin email-routing — Sales send-from card bugs + +- **Where:** `src/components/admin/sales-email-config-card.tsx` + + `src/components/admin/email-routing-card.tsx` on the + `/admin/email-routing` page. +- **Bugs (compound):** + 1. **Sales card shows noreply creds as "already saved."** The + screenshot field values (`mail.letsbe.solutions`, + `noreply@letsbe.solutions`, dotted password) come from + `smtp_*_override` (the noreply/transactional keys). The actual + `sales_smtp_*` keys are empty in the DB. The form should render + empty placeholders when nothing is saved for sales — + OR explicitly show a "Inherits from noreply above" badge with a + "Configure separately" CTA. Today reps think they've configured + sales when they haven't. + 2. **No independent Save button.** The Sales card piggy-backs on the + overall page save. It's a separate account with separate creds — + should have its own Save button keyed to the `sales_smtp_*` + subset of settings, mirroring the noreply card pattern. + 3. **Description is ambiguous.** Current copy: "SMTP credentials for + human-touch outbound (brochures + per-berth PDFs)". Doesn't say + this is a separate account from the noreply one. Update to: + _"Optional dedicated SMTP for sales-team-initiated emails + (brochures, per-berth PDFs, signed-doc completions). Distinct from + the noreply transactional account configured above. When unset, + all outbound falls back to the noreply account."_ + 4. **No Test connection button** on either Sales or Noreply cards. + Add `Test connection` that opens an SMTP connection to the + configured host:port with the saved password, ATTLS-upgrades when + SSL is off, AUTH-LOGINs as the configured user, and reports + success / specific error. Existing `documenso/test` and + `imap-probe` patterns are a good template. + 5. **Bottom-of-page "automated email sending addresses" list lies.** + It shows `sales@…` even when sales SMTP is empty (so those flows + actually fall through to noreply). The list must read the + resolved-effective-address for each flow, not the configured one, + and tag each entry "via sales" or "via noreply (sales not + configured)". +- **Plumbing already in place:** + - `sales-email-config.service.ts` SETTING_KEYS has the per-key + constants: `sales_smtp_host`, `sales_smtp_port`, + `sales_smtp_secure`, `sales_smtp_user`, `sales_smtp_pass_encrypted`. + - `email-routing.ts` already routes per-flow (brochure / berth-PDF / + signed-doc completion → sales; activation / portal / digest → + noreply). The fallback when sales is empty needs to be + surface-honest, not silent. +- **Effort:** Medium (~2-3h). Form split + new mutation per card + + test-connection endpoints (×2) + bottom-list resolver tweak. + +--- + +## 11 · URGENT bugs surfaced 2026-05-15 end-of-session + +### 11.1 Webhook can't resolve v2 envelope (numeric vs envelope_xxx ID mismatch) + +- **Where:** `resolveWebhookDocument` in `src/lib/services/documents.service.ts`. +- **Symptom:** Documenso fires webhook after signer signs, our endpoint + returns 200, but logs "Document not found for webhook (port-scoped)" + and never updates `document_signers.status` or + `interest.eoiDocStatus`. UI stays "Awaiting signatures" forever even + after all signers have completed in Documenso. Matt's session: signed + in Documenso → CRM shows 0/3 signed indefinitely. +- **Root cause:** Documenso v2 webhook payload's `payload.id` is the + internal NUMERIC pk (e.g. `19`). We store `documents.documenso_id` + as the ENVELOPE_XXX string identifier (after the normalizer fix that + made title-update + distribute work). The two never match. + Documenso webhook payloads do NOT carry the envelope_xxx string + identifier at all — per their docs. +- **Fix options (Option A recommended):** + - **A. Add `documents.documenso_numeric_id` column.** Capture + Documenso's numeric `id` from /template/use response at create time + (it's there alongside `envelopeId`). Webhook resolver tries + `documenso_id` OR `documenso_numeric_id`. Backfill script for + existing rows iterates `GET /envelope` to map. + - **B. Resolve by `externalId`.** Documenso webhook payload includes + `externalId` (we set `loi-`). Add documents column for + externalId, resolve by it as fallback. Doesn't help non-EOI doc + types that don't set externalId. + - **C. Synchronous API translation.** Webhook receiver sees numeric ID + → calls Documenso `GET /envelope/{numericId}` → finds the + envelope_xxx → resolves our doc. Adds API round-trip per webhook. +- **Effort:** Small-medium (~1-2h). Migration + capture-at-create + + resolver chain. +- **Until shipped:** every signed / viewed / rejected event is silently + dropped on the floor. + +### 11.2 Sequential signing not actually enforced — envelope is PARALLEL + +- **Where:** v2 branch of `documensoGenerateFromTemplate` in + `src/lib/services/documenso-client.ts`. +- **Symptom:** Matt signed as signer #2 (Developer) before signer #1 + (Client) on an EOI marked "SEQUENTIAL" in the CRM UI. Documenso + accepted both signatures. +- **Root cause:** `/template/use` doesn't accept a `meta` field at all + — our payload's `meta.signingOrder: 'SEQUENTIAL'` is silently + dropped. The envelope inherits the TEMPLATE's stored signingOrder, + which defaults to PARALLEL on v2 templates unless explicitly set + via the template editor. Our follow-up `/envelope/update` call sets + the title but NOT the signingOrder. So envelope ships PARALLEL and + any signer can sign at any time. The local "Sequential" badge on + the EOI card reads our per-port setting — not the envelope's actual + state. +- **Fix:** In the v2 generate path, after the title `/envelope/update`, + send a second `/envelope/update` with + `meta: { signingOrder: }`. Verify it stuck by re-reading + the envelope's `documentMeta.signingOrder`. Pair with the existing + signing-order display fix (read authoritatively from the sync report + / envelope meta, not the local setting). +- **Effort:** Small (~30 min). One additional update call + verify + step. + +### 11.3 Automated emails — full refactor (luxury-port tone + old-system copy + per-port branding) + +- **Where:** every file under `src/lib/email/templates/`. +- **What Matt called out:** + - **Tone**: current copy reads unprofessional for a luxury port. + Refactor every template to match the tone/voice of the old CRM + repo's email templates (locate in: + `/Users/matt/Repos/Port Nimara` or related — needs locating). + - **Subject format:** "{first_name}, your EOI for {portName} is + ready to be signed". Currently a flat title-cased "Expression of + Interest ready to sign — Port Amador". + - **Per-signer copy:** confirm with Matt whether each role gets + unique body copy or a single template that branches by + signerRole. If unique → load the old system's per-role copy. + - **Signature attribution:** today reads "Thank you, Developer, + Port Amador" — the literal placeholder name. Should pull the + actual sender's display name + port name (e.g. "Sales Team, + Port Amador" or the linked-CRM-user's display name from + `documenso_developer_user_id`). + - **Per-port branding** (logo, colors, background image): registry + has `branding_logo_url`, `branding_primary_color`, + `branding_app_name`, `branding_email_header_html`, + `branding_email_footer_html`. Verify those flow into the email + templates' header — Matt saw Port Nimara branding on a Port + Amador EOI which suggests the per-port branding chain isn't + being honoured at render time. (User retracted the wrong-port + claim — was confused — but the branding question is still + valid: confirm `branding_logo_url` per-port resolves correctly.) + - **Greeting cleanup**: today renders `Dear Matt Ciaccio (was: +matt@letsbe.solutions),` because the redirect-helper bakes the + original email into the name. We stripped this at the signer + insert step today — confirm the email greeting reads cleanly + after the latest fix. + - **"Signing happens directly inside our website — your data + isn't sent to a third-party signing service"**: misleading if + the marketing-site embed isn't set up + the link goes straight + to Documenso. Copy should branch on whether the + `embedded_signing_host` is configured + verified (pairs with + §4.9a). +- **Effort:** Large (~1 day). Audit every template, replace tone, + rewire branding resolution if broken, refactor the per-role + branching logic, verify subject format + signature attribution + pull from the right sources. + +--- + +## 10 · Known broken (pre-existing, not from current work) + +### 10.1 Documenso webhook integration tests + +- **Where:** `tests/integration/documenso-webhook-route.test.ts` (4 of 5 tests). +- **Symptom:** "Document not found for webhook (port-scoped)" — the secret + matches but to the wrong port, so the port-scoped document lookup misses. +- **Root cause:** The env-to-admin migration earlier in the session made + `DOCUMENSO_WEBHOOK_SECRET` optional in `env.ts`. The test now sends + `env.DOCUMENSO_WEBHOOK_SECRET ?? ''`. The receiver's + `listDocumensoWebhookSecrets()` prefers per-port DB rows over the env + fallback — and a stale port-scoped row in the test DB matches the same + shared `your-webhook-secret-min-16-chars` first, capturing the request + for the wrong portId. +- **Fix:** Have each test seed the secret on its `makePort()` port (insert + into `system_settings` with key `documenso_webhook_secret`, value + encrypted, portId = the freshly created port). Then the test-created + port wins the secret match. +- **Effort:** ~10 min. + +--- + +## How to use this doc + +1. **Pick an area** (top-of-file index) and work through items in order. +2. Most items reference a specific file or service — start there. +3. The "Effort" tag is rough: Small = ~30 min, Medium = ~1-2h, Medium-high = ~half-day. +4. When you finish an item, move it to a "Done" section at the bottom (or open a + PR that references the heading). +5. Items 4.1, 3.1, and 1.1-1.2 are the most user-visible if you want to pick a + high-impact first slice. + +For Matt: the "Decision needed" line in 3.2 (reminders) and the "save for end" +note in §8 (supplemental form) need a call before they can be picked up. + +--- + +## After this doc — older audit follow-ups still live elsewhere + +This file is the **current** testing-cycle backlog (everything found +during the Documenso v2 buildout + manual click-through 2026-05-15 +onward). Once we work through this list, do NOT close out the audit +process — earlier passes have their own punch-lists that still need to +be tackled: + +- **`docs/AUDIT-FOLLOWUPS.md`** — the rolling deferred-item index from + every audit so far. Single source of truth across cycles. +- **`docs/audit-comprehensive-2026-05-05.md`** + the 2026-05-06 frontend + audit + `docs/AUDIT-CATALOG.md` (320+ check catalog) — pre-Documenso + findings, many of which haven't been touched yet. +- **`docs/audit-final-deferred.md`** — items explicitly punted with + rationale; revisit each entry's "should we?" verdict now that + surrounding code has moved. +- **`docs/audit-2026-05-15.md`** + **`docs/AUDIT-FINDINGS-2026-05-15.md`** + — the comprehensive Playwright sweep findings that were partially + fixed (A1/A2/A4/A6/A8/A9/A16/A17/A19/A20) but had a long tail still + open before the Documenso work began. + +**When this doc is done**: re-open those audit punch-lists, dedupe +anything we accidentally already fixed during Documenso work, and +start the next ship cycle from there. Don't lose work between +cycles. diff --git a/docs/audit-findings-tmp/01a-legacy-master-grep.md b/docs/audit-findings-tmp/01a-legacy-master-grep.md new file mode 100644 index 00000000..b524f367 --- /dev/null +++ b/docs/audit-findings-tmp/01a-legacy-master-grep.md @@ -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.** diff --git a/docs/audit-findings-tmp/01b-legacy-rendering-surfaces.md b/docs/audit-findings-tmp/01b-legacy-rendering-surfaces.md new file mode 100644 index 00000000..f260b0ef --- /dev/null +++ b/docs/audit-findings-tmp/01b-legacy-rendering-surfaces.md @@ -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. diff --git a/docs/audit-findings-tmp/01c-legacy-adjacent-enums.md b/docs/audit-findings-tmp/01c-legacy-adjacent-enums.md new file mode 100644 index 00000000..6ba36e14 --- /dev/null +++ b/docs/audit-findings-tmp/01c-legacy-adjacent-enums.md @@ -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` diff --git a/docs/audit-findings-tmp/02-multitenancy-schema.md b/docs/audit-findings-tmp/02-multitenancy-schema.md new file mode 100644 index 00000000..4e1f5b80 --- /dev/null +++ b/docs/audit-findings-tmp/02-multitenancy-schema.md @@ -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.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 diff --git a/docs/audit-findings-tmp/03-routes-auth.md b/docs/audit-findings-tmp/03-routes-auth.md new file mode 100644 index 00000000..5f2f28ab --- /dev/null +++ b/docs/audit-findings-tmp/03-routes-auth.md @@ -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=`. 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=` 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: ` 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-' '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`) diff --git a/docs/audit-findings-tmp/04-audit-log.md b/docs/audit-findings-tmp/04-audit-log.md new file mode 100644 index 00000000..f7bdc477 --- /dev/null +++ b/docs/audit-findings-tmp/04-audit-log.md @@ -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', , portId, meta)`. `upsertSetting` records `newValue: { value: '' }`. `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 diff --git a/docs/audit-findings-tmp/05-documents-files.md b/docs/audit-findings-tmp/05-documents-files.md new file mode 100644 index 00000000..00ec9819 --- /dev/null +++ b/docs/audit-findings-tmp/05-documents-files.md @@ -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 `` 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=` URL state — three-state (absent → undefined hub root, `=root` → null, `=` → 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 diff --git a/docs/audit-findings-tmp/06-security.md b/docs/audit-findings-tmp/06-security.md new file mode 100644 index 00000000..de63348a --- /dev/null +++ b/docs/audit-findings-tmp/06-security.md @@ -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. diff --git a/docs/audit-findings-tmp/07-email-integrations.md b/docs/audit-findings-tmp/07-email-integrations.md new file mode 100644 index 00000000..54b4b9ff --- /dev/null +++ b/docs/audit-findings-tmp/07-email-integrations.md @@ -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 ]`; 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')` diff --git a/docs/audit-findings-tmp/08-perf-behavioral.md b/docs/audit-findings-tmp/08-perf-behavioral.md new file mode 100644 index 00000000..b196f30d --- /dev/null +++ b/docs/audit-findings-tmp/08-perf-behavioral.md @@ -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 `{children}` and the desktop `
...{children}...
`. 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. diff --git a/docs/audit-findings-tmp/09-ux-forms.md b/docs/audit-findings-tmp/09-ux-forms.md new file mode 100644 index 00000000..68b99421 --- /dev/null +++ b/docs/audit-findings-tmp/09-ux-forms.md @@ -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 `` 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 `` / `` / `` 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 `` or `` 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 `More folder actions`. +- **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:** `

No audit log entries found.

` rather than ``. +- **Suggested fix:** Replace with ``. + +### 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: `` + `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 `` 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 `
` rather than `` / ``. Lacks focus trap, Escape, role="alertdialog". +- **Suggested fix:** Replace with `` 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 `` 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 `...` 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 `` provides browser calendar + keyboard +- U-033 Combobox keyboard nav inherited from Radix `` 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 diff --git a/docs/email-refactor-deferred.md b/docs/email-refactor-deferred.md new file mode 100644 index 00000000..91ba1840 --- /dev/null +++ b/docs/email-refactor-deferred.md @@ -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. diff --git a/scripts/backfill-eoi-signers.ts b/scripts/backfill-eoi-signers.ts new file mode 100644 index 00000000..70c6a1d9 --- /dev/null +++ b/scripts/backfill-eoi-signers.ts @@ -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`( + 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); +}); diff --git a/scripts/encrypt-plaintext-credentials.ts b/scripts/encrypt-plaintext-credentials.ts new file mode 100644 index 00000000..005ce7f8 --- /dev/null +++ b/scripts/encrypt-plaintext-credentials.ts @@ -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 { + 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); +}); diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index b3d835cf..7f0e1abc 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -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; +/** + * H-02: Validate a redirect target before pushing the user to it. The + * middleware appends `?redirect=` 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 { diff --git a/src/app/(auth)/set-password/page.tsx b/src/app/(auth)/set-password/page.tsx index 72d4e286..28f10a22 100644 --- a/src/app/(auth)/set-password/page.tsx +++ b/src/app/(auth)/set-password/page.tsx @@ -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; +/** + * 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( + 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 ( + +
Loading…
+
+ ); + } + if (!token) { return ( diff --git a/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx b/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx index 402e76ef..5166cfe9 100644 --- a/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx @@ -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" /> - - diff --git a/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx b/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx index 70f16bc3..96bb151f 100644 --- a/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx @@ -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 +// 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// 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 +// `` (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() { - } /> @@ -382,16 +241,17 @@ export default function DocumensoSettingsPage() { fields={V2_FEATURE_FIELDS} /> - - } /> - +
); } diff --git a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx index c940eafa..7a9259ce 100644 --- a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx @@ -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 (
@@ -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." /> - - diff --git a/src/app/(dashboard)/[portSlug]/admin/layout.tsx b/src/app/(dashboard)/[portSlug]/admin/layout.tsx index 82345986..8ceee18e 100644 --- a/src/app/(dashboard)/[portSlug]/admin/layout.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/layout.tsx @@ -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 ( +
+
+ +
+
+

Access denied

+

+ 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. +

+
+ +
+ ); } return <>{children}; diff --git a/src/app/(dashboard)/[portSlug]/admin/pipeline-rules/page.tsx b/src/app/(dashboard)/[portSlug]/admin/pipeline-rules/page.tsx new file mode 100644 index 00000000..9b4257f1 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/pipeline-rules/page.tsx @@ -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>(() => + Object.fromEntries(TRIGGERS.map((t) => [t.key, t.defaultMode])), + ); + + const { data, isLoading } = useQuery<{ + data: { values: Record | null }> }; + }>({ + queryKey: ['admin', 'settings', 'pipeline.auto_advance'], + queryFn: () => + apiFetch<{ + data: { values: Record | 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 ( +
+ + + + + Preset + + +
+ applyPreset('aggressive')} + /> + applyPreset('conservative')} + /> +
+

Custom

+

+ Mix and match — the per-trigger toggles below override the preset. +

+
+
+
+
+ + + + Per-trigger settings + + + {isLoading ? ( +
+ Loading… +
+ ) : ( + TRIGGERS.map((t) => ( +
+
+

{t.label}

+

{t.description}

+
+
+ + +
+
+ )) + )} +
+
+ +
+ +
+
+ ); +} + +function PresetButton({ + name, + label, + description, + active, + onClick, +}: { + name: PresetName; + label: string; + description: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/app/(dashboard)/[portSlug]/invoices/page.tsx b/src/app/(dashboard)/[portSlug]/invoices/page.tsx index aaebe718..c64c6c04 100644 --- a/src/app/(dashboard)/[portSlug]/invoices/page.tsx +++ b/src/app/(dashboard)/[portSlug]/invoices/page.tsx @@ -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 && ( -
-
-

Delete Invoice?

-

+ {/* 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. */} + { + if (!open) setDeleteTarget(null); + }} + > + + + Delete invoice? + This will permanently delete invoice{' '} - {deleteTarget.invoiceNumber}. This + {deleteTarget?.invoiceNumber}. This action cannot be undone. -

-
- - -
-
-
- )} + + + + Cancel + { + if (deleteTarget) deleteMutation.mutate(deleteTarget.id); + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive" + > + + {deleteMutation.isPending ? 'Deleting…' : 'Delete'} + + + +
); } diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index b0eb9992..0f8192dd 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -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 + {/* 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. */} + {/* #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. */} diff --git a/src/app/(portal)/portal/documents/page.tsx b/src/app/(portal)/portal/documents/page.tsx index bb3082fe..871ccdbc 100644 --- a/src/app/(portal)/portal/documents/page.tsx +++ b/src/app/(portal)/portal/documents/page.tsx @@ -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() {

Signers

- {doc.signers.map((signer, idx) => ( -
- - {signer.status === 'signed' - ? '✓' - : signer.status === 'declined' - ? '✗' - : '○'} - - {signer.signerName} - - ({signer.signerRole.replace(/_/g, ' ')}) - -
- ))} + {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 ( +
+ + {signer.signerName} + + ({signer.signerRole.replace(/_/g, ' ')}) + +
+ ); + })} )} diff --git a/src/app/api/auth/sign-in-by-identifier/route.ts b/src/app/api/auth/sign-in-by-identifier/route.ts index 4a618658..ced7492a 100644 --- a/src/app/api/auth/sign-in-by-identifier/route.ts +++ b/src/app/api/auth/sign-in-by-identifier/route.ts @@ -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) }, + }, ); } diff --git a/src/app/api/v1/admin/documenso/sync-template/[templateId]/route.ts b/src/app/api/v1/admin/documenso/sync-template/[templateId]/route.ts new file mode 100644 index 00000000..ad944fba --- /dev/null +++ b/src/app/api/v1/admin/documenso/sync-template/[templateId]/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/admin/documenso/sync-template/report/route.ts b/src/app/api/v1/admin/documenso/sync-template/report/route.ts new file mode 100644 index 00000000..ddc3beda --- /dev/null +++ b/src/app/api/v1/admin/documenso/sync-template/report/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/admin/email/sales-config/test-smtp/route.ts b/src/app/api/v1/admin/email/sales-config/test-smtp/route.ts new file mode 100644 index 00000000..5b5a6e38 --- /dev/null +++ b/src/app/api/v1/admin/email/sales-config/test-smtp/route.ts @@ -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 = `

Hello,

This is a test message sent from your CRM's Sales SMTP configuration. If you received this, your SMTP credentials work.

Timestamp: ${new Date().toISOString()}

`; + 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); + } + }), +); diff --git a/src/app/api/v1/admin/embedded-signing/test/route.ts b/src/app/api/v1/admin/embedded-signing/test/route.ts new file mode 100644 index 00000000..c23e42ef --- /dev/null +++ b/src/app/api/v1/admin/embedded-signing/test/route.ts @@ -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(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); + } + }), +); diff --git a/src/app/api/v1/admin/settings/[key]/copy-from-env/route.ts b/src/app/api/v1/admin/settings/[key]/copy-from-env/route.ts new file mode 100644 index 00000000..e40bd0f0 --- /dev/null +++ b/src/app/api/v1/admin/settings/[key]/copy-from-env/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/admin/settings/[key]/reveal/route.ts b/src/app/api/v1/admin/settings/[key]/reveal/route.ts new file mode 100644 index 00000000..b6ca5edf --- /dev/null +++ b/src/app/api/v1/admin/settings/[key]/reveal/route.ts @@ -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(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); + } + }), +); diff --git a/src/app/api/v1/admin/settings/[key]/route.ts b/src/app/api/v1/admin/settings/[key]/route.ts new file mode 100644 index 00000000..ed069e5d --- /dev/null +++ b/src/app/api/v1/admin/settings/[key]/route.ts @@ -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: } + * + * 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); + } + }), +); diff --git a/src/app/api/v1/admin/settings/resolved/route.ts b/src/app/api/v1/admin/settings/resolved/route.ts new file mode 100644 index 00000000..fe44f10d --- /dev/null +++ b/src/app/api/v1/admin/settings/resolved/route.ts @@ -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 = {}; + for (const [k, r] of resolved.entries()) { + values[k] = r; + } + + return NextResponse.json({ data: { entries: entriesForClient, values } }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/users/picker/route.ts b/src/app/api/v1/admin/users/picker/route.ts new file mode 100644 index 00000000..76aa8544 --- /dev/null +++ b/src/app/api/v1/admin/users/picker/route.ts @@ -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(); + 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); + } + }), +); diff --git a/src/app/api/v1/clients/match-candidates/handlers.ts b/src/app/api/v1/clients/match-candidates/handlers.ts index 07d30510..2b7d0888 100644 --- a/src/app/api/v1/clients/match-candidates/handlers.ts +++ b/src/app/api/v1/clients/match-candidates/handlers.ts @@ -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(); + 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 }); diff --git a/src/app/api/v1/document-templates/[id]/generate-and-sign/route.ts b/src/app/api/v1/document-templates/[id]/generate-and-sign/route.ts index 31472636..2acf21e9 100644 --- a/src/app/api/v1/document-templates/[id]/generate-and-sign/route.ts +++ b/src/app/api/v1/document-templates/[id]/generate-and-sign/route.ts @@ -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) { diff --git a/src/app/api/v1/documents/[id]/cancel/route.ts b/src/app/api/v1/documents/[id]/cancel/route.ts index f4330e4c..623712a2 100644 --- a/src/app/api/v1/documents/[id]/cancel/route.ts +++ b/src/app/api/v1/documents/[id]/cancel/route.ts @@ -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 = 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); diff --git a/src/app/api/v1/documents/[id]/send-invitation/route.ts b/src/app/api/v1/documents/[id]/send-invitation/route.ts index e2ce201c..f9ce966a 100644 --- a/src/app/api/v1/documents/[id]/send-invitation/route.ts +++ b/src/app/api/v1/documents/[id]/send-invitation/route.ts @@ -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.', ); } diff --git a/src/app/api/v1/documents/signing-defaults/route.ts b/src/app/api/v1/documents/signing-defaults/route.ts index d8796bcc..4f00daaa 100644 --- a/src/app/api/v1/documents/signing-defaults/route.ts +++ b/src/app/api/v1/documents/signing-defaults/route.ts @@ -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) { diff --git a/src/app/api/v1/email/accounts/[accountId]/route.ts b/src/app/api/v1/email/accounts/[accountId]/route.ts index 1137720b..4b6a4039 100644 --- a/src/app/api/v1/email/accounts/[accountId]/route.ts +++ b/src/app/api/v1/email/accounts/[accountId]/route.ts @@ -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); diff --git a/src/app/api/v1/internal/dev-flags/route.ts b/src/app/api/v1/internal/dev-flags/route.ts new file mode 100644 index 00000000..cbd054ee --- /dev/null +++ b/src/app/api/v1/internal/dev-flags/route.ts @@ -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', + }, + }); +}); diff --git a/src/components/admin/admin-sections-browser.tsx b/src/components/admin/admin-sections-browser.tsx index 7656edcb..1710ec92 100644 --- a/src/components/admin/admin-sections-browser.tsx +++ b/src/components/admin/admin-sections-browser.tsx @@ -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', diff --git a/src/components/admin/audit/audit-log-card.tsx b/src/components/admin/audit/audit-log-card.tsx index 141ce0ac..51a9e805 100644 --- a/src/components/admin/audit/audit-log-card.tsx +++ b/src/components/admin/audit/audit-log-card.tsx @@ -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 ? ( + + ) : null} + + {expanded && hasDetail ? ( +
+ {entry.oldValue ? ( +
+ + Old value + +
+                    {JSON.stringify(entry.oldValue, null, 2)}
+                  
+
+ ) : null} + {entry.newValue ? ( +
+ + New value + +
+                    {JSON.stringify(entry.newValue, null, 2)}
+                  
+
+ ) : null} + {entry.metadata ? ( +
+ + Metadata + +
+                    {JSON.stringify(entry.metadata, null, 2)}
+                  
+
+ ) : null} + {entry.userAgent || entry.ipAddress ? ( +
+ {entry.ipAddress ? ( + <> +
IP address
+
{entry.ipAddress}
+ + ) : null} + {entry.userAgent ? ( + <> +
User agent
+
+ {entry.userAgent} +
+ + ) : null} +
+ ) : null} +
+ ) : null} diff --git a/src/components/admin/audit/audit-log-list.tsx b/src/components/admin/audit/audit-log-list.tsx index 3537efd8..09a2513e 100644 --- a/src/components/admin/audit/audit-log-list.tsx +++ b/src/components/admin/audit/audit-log-list.tsx @@ -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 = { 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(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(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 ( + + ); + }, + size: 80, + }, ]; return ( @@ -359,7 +402,7 @@ export function AuditLogList() { setSearch(e.target.value)} data-testid="audit-search" @@ -412,6 +455,22 @@ export function AuditLogList() { Webhook retried Job failed Cron run + {/* L-AU02: actions that fire in the code but were missing from + the dropdown — reps couldn't filter on them. */} + Password change + Portal invite + Portal activate + Portal reset req + Portal reset + Revoke invite + Resend invite + GDPR req + GDPR sent + Rule evaluated + Outcome set + Outcome cleared + Logo uploaded + Logo archived @@ -522,9 +581,15 @@ export function AuditLogList() { virtualHeightPx={640} virtualRowHeightPx={56} emptyState={ -
-

No audit log entries found.

-
+ } /> @@ -543,6 +608,73 @@ export function AuditLogList() { ) : null} + + !o && setDetailEntry(null)}> + + {detailEntry ? ( + <> + + + {detailEntry.action.replace(/_/g, ' ')} — {detailEntry.entityType} + + + {new Date(detailEntry.createdAt).toLocaleString()} + {detailEntry.actor ? ` · ${detailEntry.actor.name}` : ''} + + + +
+ {detailEntry.oldValue ? ( +
+ + Old value + +
+                      {JSON.stringify(detailEntry.oldValue, null, 2)}
+                    
+
+ ) : null} + {detailEntry.newValue ? ( +
+ + New value + +
+                      {JSON.stringify(detailEntry.newValue, null, 2)}
+                    
+
+ ) : null} + {detailEntry.metadata ? ( +
+ + Metadata + +
+                      {JSON.stringify(detailEntry.metadata, null, 2)}
+                    
+
+ ) : null} + {detailEntry.ipAddress || detailEntry.userAgent ? ( +
+ {detailEntry.ipAddress ? ( + <> +
IP address
+
{detailEntry.ipAddress}
+ + ) : null} + {detailEntry.userAgent ? ( + <> +
User agent
+
{detailEntry.userAgent}
+ + ) : null} +
+ ) : null} +
+ + ) : null} +
+
); } diff --git a/src/components/admin/documenso/embedded-signing-card.tsx b/src/components/admin/documenso/embedded-signing-card.tsx new file mode 100644 index 00000000..0b91fa9d --- /dev/null +++ b/src/components/admin/documenso/embedded-signing-card.tsx @@ -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// 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(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 ( + <> + + +
+
+ Embedded signing + + Where the public-facing branded signing pages live. The CRM rewrites Documenso + signing URLs to point here when sending invitation and reminder emails. + +
+ +
+
+ + {/* 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. */} + +
+ +

+ Probes / and /sign/success on the configured host. +

+
+ + {result ? ( +
+
+ {result.ok ? ( + + ) : ( + + )} +
+

{result.ok ? 'Connection ok' : 'Connection failed'}

+ {result.host ? ( +

+ Host: {result.host} +

+ ) : null} + {result.error ?

{result.error}

: null} + {result.checks ? ( +
    + {result.checks.map((c) => ( +
  • + {c.path} →{' '} + {c.ok ? ( + {c.status ?? 'ok'} + ) : ( + + {c.status ? `${c.status} fail` : (c.error ?? 'fail')} + + )} +
  • + ))} +
+ ) : null} +

{result.at.toLocaleTimeString()}

+
+
+
+ ) : null} +
+
+ + + + + Set up embedded signing + + How the marketing site has to be wired up so the branded signing flow works. + + + +
+
+

1. Choose the host

+

+ Pick a public host (e.g. https://portnimara.com) and enter it in the + Embedded signing host field above. The CRM rewrites raw Documenso signing URLs into{' '} + {'{host}/sign//'} for every outbound invitation + reminder + email. +

+
+ +
+

2. Implement the signing route

+

+ The marketing site needs to handle /sign/[role]/[token] by forwarding + to the underlying Documenso signing URL (or embedding it in an iframe). Role is one + of client / developer / approver — useful for + tracking which slot the signer is in. +

+

Minimum Next.js example:

+
+                {`// app/sign/[role]/[token]/page.tsx
+export default function SignPage({ params }) {
+  const documenseUrl = \`\${env.DOCUMENSO_URL}/sign/\${params.token}\`;
+  return