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