` not under a sectioning element** (`aggregated-section.tsx:92`) — wrong landmark scope; use `
` or `
`.
+- **`h3` → `h5` jump in SigningDetailsDialog** (skipped heading level).
+- **`renameFolder` `updatedAt` test uses 10ms `setTimeout`** — fragile but `toBeGreaterThan` is OK; can drop the sleep entirely.
+- **`MINIO_AUTO_CREATE_BUCKET`** bypasses zod env schema; undocumented in `.env.example`.
+- **`DOCUMENSO_TEMPLATE_ID_EOI` + recipient ID vars absent from `.env.example`** with Port-Nimara-specific hardcoded defaults.
+- **`voidDocument` raw `FetchTimeoutError` propagation** — no `CodedError('DOCUMENSO_TIMEOUT')` wrap. Both call sites handle gracefully; cosmetic.
+
+---
+
+## Audit-by-audit completion log
+
+| # | Audit | Status | Critical | Important | Minor |
+|---|---|---|---|---|---|
+| 1 | Security & multi-tenant isolation | ✓ | 0 | 3 | 0 |
+| 2 | Database & migration safety | ✓ | 1 | 3 | 3 |
+| 3 | Concurrency, idempotency, error paths | ✓ | 1 | 3 | 3 |
+| 4 | Performance & query plans | ✓ | 1 | 3 | 3 |
+| 5 | Data migration from old system | ✓ | 1 | 1 | 3 |
+| 6 | Production observability | ✓ | 2 | 4 | 3 |
+| 7 | UI/UX | ✓ | 0 | 5 | 4 |
+| 8 | Integration conformance (Context7) | ✓ | 0 | 0 | 3 |
+| 9 | Dependency audit | ✓ | 0 | 0 | ~10 |
+| 10 | Accessibility (WCAG 2.1 AA) | ✓ | 4 | 5 | 4 |
+| 11 | Test quality & coverage | ✓ | 2 | 6 | 3 |
+| 12 | Realtime / Socket.IO | ✓ | 3 | 2 | 1 |
+| 13 | Audit log completeness | ✓ | 0 | 4 | 4 |
+| 14 | Type-safety | ✓ | 0 | 3 | 3 |
+| 15 | Mobile / responsive | ✓ | 6 | 5 | 3 |
+| 16 | Integration holes (MinIO + Documenso) | ✓ | 2 | 5 | 5 |
+| 17 | Data structure & sales process completeness | ✓ | 5 | 6 | 6 |
+
+---
+
+## Suggested remediation order
+
+**Pre-merge (block this branch):**
+1. A1 (concurrency idempotency) — 1 line, 5 minutes.
+2. A2 (realtime hookup) — ~30 min: lift one hook up two layers in component tree.
+3. A4 (importer folder_id) — 1 line in scripts/import-organized-documents.ts.
+4. A5 (CHECK NULL escape) — 1-line migration patch + re-apply.
+5. A6 (folder service logger) — add `import { logger }` + 3 warn calls.
+6. A7 (demote on hard-delete) — 1 line in client-hard-delete.service.ts.
+7. B1-B4 (a11y) — ~30 min combined: aria attributes only.
+8. C1-C7 (mobile) — ~1-2 hours: Sheet wrap + tap-target padding.
+9. E1 (test theatre) — convert skips to fails.
+10. F1 (DOCUMENT_DECLINED) — add case to switch.
+
+**Pre-prod cutover (independent of branch):**
+- A3 (LEFT JOIN port_id) — performance fix.
+- D1 (storage migration table list) — populate TABLES_WITH_STORAGE_KEYS.
+- D2 (.env.example URL) — strip `/api/v1`.
+- All Important security findings.
+- 0-byte signed PDF check.
+- MinIO socket timeout wrapper.
+- DOCUMENSO_API_VERSION documentation + v2 event audit.
+
+**Post-prod (backlog on main):**
+- Important UI/UX (em-dashes, loading state consistency, status pill on HubRootView).
+- Important audit log completeness.
+- Important type-safety tightening.
+- All Minor.
+
+---
+
+## Notes on session vs. pre-existing findings
+
+Several Criticals (D1 storage migration script, D2 .env.example, A3 LEFT JOIN port_id, parts of the audit-log gaps and observability gaps) are long-standing — they survived multiple iterations of the codebase, sometimes since Phase 6a. Fixing them on this branch is fine but they're not regressions introduced by this session.
+
+The session's actual regressions are: A1 (idempotency), A2 (realtime), A5 (CHECK NULL), A6 (folder service has no logger), A7 (demote not wired), B1-B4 (a11y missed during the UI rebuild), C1-C7 (mobile never tested), E1 (test theatre).
+
+The dependency, integration-conformance (Context7), and type-safety audits are clean of Critical findings — your dep posture is solid and the implementation follows published specs.
+
+---
+
+## Audit 17 — Data structure & sales process completeness
+
+**5 Critical, 6 Important, 6 Minor.** This audit walked the entire entity graph and the sales-process pipeline end-to-end. Most findings are not regressions from this session — they are gaps in the sales-process plumbing that pre-date the documents-hub-split work but matter for prod cutover. C-1 and C-3 are session-introduced; C-2, C-4, C-5 are long-standing.
+
+### Critical (data graph + sales pipeline)
+
+**G-C1. `deleteFolderSoftRescue` re-parents documents but not files — split delete behavior**
+`src/lib/services/document-folders.service.ts:268-282`
+
+The soft-rescue transaction `UPDATE`s `documents.folderId = newParent`, then deletes the folder row. The schema cascade on `files.folderId` is `ON DELETE SET NULL` (not `SET DEFAULT newParent`) — so any files in the deleted folder land at **root**, while documents in the same folder correctly land at the deleted folder's **parent**. A folder containing both will scatter on delete.
+
+Fix: inside the transaction, between the documents UPDATE and the folder DELETE:
+```ts
+await tx
+ .update(files)
+ .set({ folderId: newParent })
+ .where(and(eq(files.folderId, folderId), eq(files.portId, portId)));
+```
+
+**G-C2. Client hard-delete blocked by `scratchpadNotes.linkedClientId` RESTRICT FK**
+`src/lib/services/client-hard-delete.service.ts:190-218` + `src/lib/db/schema/system.ts:180`
+
+`scratchpadNotes.linkedClientId references clients.id` with no `onDelete` → defaults to RESTRICT. The hard-delete service nullifies six nullable FKs (files, documents, formSubmissions, emailThreads, reminders, documentSends) but skips `scratchpadNotes`. Any rep who scratchpad-linked a note to a client → hard-delete throws an FK violation and aborts the transaction.
+
+Fix: add to the nullification block:
+```ts
+await tx
+ .update(scratchpadNotes)
+ .set({ linkedClientId: null })
+ .where(eq(scratchpadNotes.linkedClientId, args.clientId));
+```
+
+**G-C3. Client hard-delete leaves ghost system folder with stale `entityId`**
+`src/lib/services/client-hard-delete.service.ts:214-218`
+
+The unique index `uniq_document_folders_entity` on `(portId, entityType, entityId)` enforces a singleton system folder per entity. Hard-delete removes the client row but does not call `demoteSystemFolderOnEntityDelete`. The folder persists with `systemManaged=true, entityType='client', entityId=` — invisible in the sidebar but holding the unique slot.
+
+Fix: after the client delete, fire-and-forget the demote:
+```ts
+void demoteSystemFolderOnEntityDelete(args.portId, 'client', args.clientId).catch(logger.error);
+```
+(This is the same wire-up A7 in the main report flagged — confirmed missing on the hard-delete pathway specifically.)
+
+**G-C4. Five of seven berth-rule triggers are defined but never called**
+`src/lib/services/berth-rules-engine.ts:37-44` vs `src/lib/services/documents.service.ts:798,894,1234`
+
+`DEFAULT_RULES` defines triggers for `eoi_sent`, `eoi_signed`, `deposit_received`, `contract_signed`, `interest_archived`, `interest_completed`, `berth_unlinked`. Only `eoi_sent` and `eoi_signed` are passed to `evaluateRule` anywhere in the codebase.
+
+Concrete consequences:
+- Deposit received (invoice paid) → no berth state change. Should auto-mark berth as Sold.
+- Contract signed → no berth state change.
+- Interest archived → no "berth available" suggestion fires.
+- Interest marked Won/Lost → no rule trigger.
+- Interest unlinked from berth → no rule trigger (off-by-default, but configurable and silently dead).
+
+Fix sketches:
+- `invoices.ts:741` (after `advanceStageIfBehind('deposit_10pct')`):
+ ```ts
+ const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
+ void evaluateRule('deposit_received', updated.interestId, portId, meta);
+ ```
+- `interests.service.ts:archiveInterest` after `softDelete`: fetch primary berth via `getPrimaryBerth`, then `void evaluateRule('interest_archived', ...)`.
+- `interests.service.ts:setInterestOutcome` after the outcome write: `void evaluateRule('interest_completed', ...)`.
+- `interest-berths.service.ts:removeInterestBerth` after delete: `void evaluateRule('berth_unlinked', ...)`.
+
+**G-C5. `contract_sent` and `contract_signed` pipeline stages have zero auto-advancement triggers**
+`src/lib/services/documents.service.ts` (absent)
+
+`STAGE_TRANSITIONS` defines `contract_sent` and `contract_signed` and they render in the Kanban/funnel UI, but no code path calls `advanceStageIfBehind(..., 'contract_sent')` or `advanceStageIfBehind(..., 'contract_signed')`. Sending a reservation agreement → no stage advance. Completing one (signed PDF arrives, `contractFileId` set in `handleDocumentCompleted` ~line 887) → no stage advance.
+
+Effect: deals stall at whatever stage they hit when the reservation agreement was sent, until a rep manually drags them in the Kanban.
+
+Fix: in `documents.service.ts`:
+- `sendDocument` pathway (~line 798): if `doc.documentType === 'reservation_agreement'`, fire `advanceStageIfBehind(..., 'contract_sent', meta, 'Reservation agreement sent')`.
+- `handleDocumentCompleted` (~line 887, where `contractFileId` is set): fire `advanceStageIfBehind(..., 'contract_signed', meta, 'Reservation agreement signed')` and `evaluateRule('contract_signed', ...)`.
+
+### Important (cross-entity gaps)
+
+**G-I1. Portal email uniqueness is global, not per-port**
+`src/lib/db/schema/portal.ts:40` — `uniqueIndex('idx_portal_users_email_unique').on(table.email)`
+
+A client who has dealt with two ports under this deployment can only ever have one portal account. The second `createPortalUser` will throw a unique-constraint violation. Make per-port (`.on(table.email, table.portId)`) if multi-port is a real deployment scenario, or document as single-port-only.
+
+**G-I2. `archiveInterest` skips `interest_archived` rule and `notifyNextInLine`**
+`src/lib/services/interests.service.ts:985-1014`
+
+Archive does the audit log + socket emit but does not (a) trigger the berth-availability rule, (b) notify the waiting list for the primary berth. The waiting-list code is only fired when the **client** is archived, not the **interest**.
+
+Fix after `softDelete`: fetch primary berth → `evaluateRule('interest_archived', ...)` + `notifyNextInLine(primaryBerth.berthId, portId, meta.userId)`.
+
+**G-I3. Yacht/company `restore` paths missing `applyEntityRestoredSuffix`**
+`src/lib/services/yachts.service.ts:178` + `src/lib/services/companies.service.ts:200`
+
+Archive sides call `applyEntityArchivedSuffix`. Restore paths do not exist for yachts/companies at all today — but when they are added (or if the entity-restoration logic moves to the `clients/archive` parity routes), `applyEntityRestoredSuffix` must be wired. `clients.service.ts:596` already does this correctly.
+
+**G-I4. `berthRecommendations.interestId` has no FK constraint**
+`src/lib/db/schema/berths.ts:134` — column comment says "references interests.id" but `.references()` is omitted.
+
+If an interest is hard-deleted (currently only possible via `db:studio` or future migrations), stale `berthRecommendations` rows persist and skew the recommender's tier aggregates. Add `.references(() => interests.id, { onDelete: 'cascade' })` and generate a migration.
+
+**G-I5. Portal invoices invisible for company-billed deals**
+`src/lib/services/portal.service.ts:232`
+
+`getClientInvoices` matches on `billingEmail in client.emails`. Invoices with `billingEntityType='company'` (the most common B2B pattern: client is an individual buying through their company) are not surfaced even when the client is the company's director. Extend the query to OR-in invoices where `billingEntityType='company' AND company.directorClientId = portalUser.clientId`.
+
+**G-I6. `hub-counts` API endpoint is orphaned**
+`src/app/api/v1/documents/hub-counts/route.ts:5-10` + `getHubTabCounts` in `documents.service.ts:397`
+
+The hub rebuild on this branch removed the component that called this endpoint. Service function + route are dead code. Either wire a KPI strip back into `HubRootView` (the spec does call for this) or delete the route + service function.
+
+### Minor
+
+- **G-M1.** Website inquiry → client conversion is fully manual; `prefill_*` query params are hints only. `inquiry-inbox.tsx:119`.
+- **G-M2.** Polymorphic array columns (`photoFileIds`, `attachmentFileIds`) have no FK protection. Files deleted via any future hard-purge path silently orphan these arrays.
+- **G-M3.** `berthReservations.interestId` RESTRICT default (notNull, no `onDelete`) — intent (preserve history vs oversight) undocumented.
+- **G-M4.** `setInterestOutcome` to `won` does not fire berth-sold; downstream of G-C4.
+- **G-M5.** `advanceStageIfBehind` silently no-ops when `yachtId` is null at `open` stage. Walk-in EOIs (vessel not yet identified) stall invisibly at `open`.
+- **G-M6.** `removeInterestBerth` emits socket + webhook but skips `evaluateRule('berth_unlinked')`. Downstream of G-C4.
+
+### Impact on cutover gate
+
+- **G-C2** is the most pressing for cutover: it is a hard error on a foreseeable action (any rep deleting a client with a linked scratchpad note → 500). Fix before any team testing.
+- **G-C4 + G-C5** mean the berth-map status and Kanban columns will drift visually for every deal that progresses past EOI. This is not data corruption, but it will erode rep trust quickly during initial team testing. Fix before cutover.
+- **G-C1** is a UX correctness issue; will surprise reps but won't lose data. Same-branch fix.
+- **G-C3** is data-integrity hygiene; no immediate user-visible effect but pollutes the unique-folder slot. Same-branch fix.
+
+### Updated headline
+
+With Audit 17 folded in, the corrected count is **~28 Critical, ~38 Important, ~36 Minor** across 17 domains. The new Criticals (G-C2, G-C4, G-C5) are long-standing pre-existing gaps in the sales pipeline — they don't block this branch's merge to `main`, but they block prod cutover. G-C1 and G-C3 are this-branch issues and should be folded into the same fix pass as A1-A7.
+
+### Suggested remediation order — addendum
+
+After the A/B/C/D/E/F block from the main report:
+1. **G-C1** — files folder UPDATE in `deleteFolderSoftRescue` transaction (1-line addition).
+2. **G-C2** — nullify `scratchpadNotes.linkedClientId` in `clientHardDelete` (1-line addition).
+3. **G-C3** — call `demoteSystemFolderOnEntityDelete` after client hard-delete (1-line addition).
+4. **G-C4 + G-C5** — wire 6 missing berth-rule + pipeline-advance triggers (~30 min total, spread across invoices.ts, interests.service.ts, interest-berths.service.ts, documents.service.ts).
+
+Total addendum effort: ~1 hour for G-C1/G-C2/G-C3, ~30 min for G-C4/G-C5, plus 1 migration regen for I-4 if you choose to fix it now.
diff --git a/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx b/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx
index 694740bc..ef82577f 100644
--- a/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx
@@ -250,11 +250,12 @@ export default function DocumensoSettingsPage() {
/>
Envelope CRUD endpoints. GET, DELETE,
- POST /envelope/create (multipart), POST /envelope/distribute,{' '}
- POST /envelope/redistribute, GET /envelope/{'{id}'}/download{' '}
- — all routed through /api/v2/envelope/... when v2 is selected. The
- template-generate path is intentionally still v1 (relies on Documenso 2.x's
- backward-compat window — see the deferred-roadmap below).
+ POST /envelope/create (multipart),{' '}
+ POST /envelope/distribute, POST /envelope/redistribute,{' '}
+ GET /envelope/{'{id}'}/download — all routed through{' '}
+ /api/v2/envelope/... when v2 is selected. The template-generate path
+ is intentionally still v1 (relies on Documenso 2.x's backward-compat window —
+ see the deferred-roadmap below).
@@ -263,8 +264,8 @@ export default function DocumensoSettingsPage() {
aria-hidden="true"
/>
- One-call send. v2's /envelope/distribute returns
- per-recipient signingUrl in the same response — v1 requires a
+ One-call send. v2's /envelope/distribute{' '}
+ returns per-recipient signingUrl in the same response — v1 requires a
separate GET to fetch them. Faster send flow on the rep side.
@@ -288,10 +289,9 @@ export default function DocumensoSettingsPage() {
Post-signing redirect URL. Set in the "v2 signing
behaviour" card; Documenso redirects the signer to that URL after they
- complete signing. Use to land clients on the marketing site's success page
- or back in the portal instead of Documenso's default thank-you page. (v1
- honours this too — listed here because the admin setting was added with the v2
- work.)
+ complete signing. Use to land clients on the marketing site's success page or
+ back in the portal instead of Documenso's default thank-you page. (v1 honours
+ this too — listed here because the admin setting was added with the v2 work.)
@@ -320,14 +320,14 @@ export default function DocumensoSettingsPage() {
re-generating.
- Non-SIGNER recipient roles (CC / VIEWER) — APPROVER role is
- already used by the EOI template; CC + VIEWER not yet exposed in the recipient
- builder. Useful for sales managers who want a copy without a signature slot.
+ Non-SIGNER recipient roles (CC / VIEWER) — APPROVER role is already
+ used by the EOI template; CC + VIEWER not yet exposed in the recipient builder.
+ Useful for sales managers who want a copy without a signature slot.
- Sequential signing and post-signing redirect URL are now wired —
- see the new "v2 signing behaviour" card below to configure them.
+ Sequential signing and post-signing redirect URL are now wired — see
+ the new "v2 signing behaviour" card below to configure them.
diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx
index 8d588878..5e2419b3 100644
--- a/src/components/dashboard/dashboard-shell.tsx
+++ b/src/components/dashboard/dashboard-shell.tsx
@@ -73,9 +73,7 @@ export function DashboardShell() {
const firstName = me.data?.data?.firstName?.trim();
// Time-aware greeting line, falls back to a generic "Welcome back" when
// we don't know the user's first name yet (e.g. profile not filled out).
- const greeting = firstName
- ? `${timeOfDayGreeting()}, ${firstName}`
- : 'Welcome back';
+ const greeting = firstName ? `${timeOfDayGreeting()}, ${firstName}` : 'Welcome back';
// Use a partial query-key prefix (no range segment) for invalidations.
// Reading: "any cached analytics result, regardless of range, please
diff --git a/src/components/documents/create-document-wizard.tsx b/src/components/documents/create-document-wizard.tsx
index e87f116f..0b9b0a68 100644
--- a/src/components/documents/create-document-wizard.tsx
+++ b/src/components/documents/create-document-wizard.tsx
@@ -265,8 +265,8 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
/>
)}
- Drop a PDF or click to browse. The file is stored, then the wizard wires it as
- the source for signing.
+ Drop a PDF or click to browse. The file is stored, then the wizard wires it as the
+ source for signing.
)}
diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx
index f816d3ba..794950ac 100644
--- a/src/components/documents/documents-hub.tsx
+++ b/src/components/documents/documents-hub.tsx
@@ -1,8 +1,9 @@
'use client';
-import { useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
-import { ChevronDown, ChevronRight, FileText, Plus } from 'lucide-react';
+import { useQueryClient } from '@tanstack/react-query';
+import { ChevronDown, ChevronRight, FileText, Plus, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -15,11 +16,13 @@ import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
+import { useUIStore } from '@/stores/ui-store';
import { FolderActionsMenu } from './folder-actions-menu';
import { FolderBreadcrumb } from './folder-breadcrumb';
import { FolderTreeSidebar } from './folder-tree-sidebar';
import { HubRootView } from './hub-root-view';
import { EntityFolderView } from './entity-folder-view';
+import { NewDocumentMenu } from './new-document-menu';
interface HubDoc {
id: string;
@@ -154,12 +157,15 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {