diff --git a/docs/superpowers/audits/2026-05-11-prod-readiness-audit.md b/docs/superpowers/audits/2026-05-11-prod-readiness-audit.md new file mode 100644 index 00000000..d1d64f19 --- /dev/null +++ b/docs/superpowers/audits/2026-05-11-prod-readiness-audit.md @@ -0,0 +1,464 @@ +# Prod-Readiness Audit — feat/documents-folders + +**Date:** 2026-05-11 +**Branch:** `feat/documents-folders` (67 commits ahead of `main`; 34 from this session's documents-hub-split work + 33 from Wave 11.B) +**Scope:** 17 parallel domain audits (data-structure & sales-process completeness appended at bottom) +**Test posture at audit time:** 1287/1287 unit + integration pass. TypeScript clean (4 pre-existing errors: 1 stale `.next/` build artifact, 3 in a Wave 11.B-era `InMemoryBackend` test stub). + +## Headline + +**~28 Critical, ~38 Important, ~36 Minor findings across 17 domains.** (Original 16-domain count was 23/32/30; Audit 17 added 5/6/6.) + +A handful of the Criticals are real bugs in this session's work that need to be fixed on this branch before merging to `main`. A few are long-standing gaps that survived multiple iterations (storage migration script, `.env.example` URL) and should be fixed independently of this branch but before any prod cutover. Several are mobile/a11y issues that were never going to be caught without a running dev server, which the implementation pass didn't have. + +**Recommendation:** fix the 23 Criticals before merging this branch. Triage Importants into "fix-before-prod" vs "follow-up-on-main". Minors → backlog. + +Estimated effort to clear Criticals: 6-10 hours of focused work. + +--- + +## Critical findings + +Grouped by remediation domain. Each entry: brief rationale + file:line ref + fix sketch. + +### A. Core feature regressions in this session's work + +**A1. `handleDocumentCompleted` is not idempotent — Documenso retries duplicate `files` rows + orphan blobs** +`src/lib/services/documents.service.ts:1115` + +`resolveWebhookDocument` returns the doc regardless of `status`. Two webhook deliveries (Documenso retries on 5xx) can both pass through and both insert `files` rows; the second `UPDATE documents SET signedFileId` clobbers the first and the first blob is permanently orphaned in storage with no DB row. + +**Fix:** `if (doc.status === 'completed' && doc.signedFileId) return;` immediately after `resolveWebhookDocument`. Standard idempotency gate for this pattern. + +**A2. Realtime hookup dropped by hub rebuild — multi-rep stale data** +`src/components/documents/hub-root-view.tsx`, `src/components/documents/entity-folder-view.tsx` + +The pre-rebuild hub consumed `document:*` and `file:*` Socket.IO events via `useRealtimeInvalidation`. After the rebuild, both `HubRootView` and `EntityFolderView` have no realtime subscription at all. The remaining hook lives inside `FlatFolderListing`, which is torn down when navigating away. Result: rep A on `Clients/Smith/` will not see rep B's upload until manual refresh; webhook-completed signatures don't appear in the Signing-in-progress section. + +**Fix:** lift `useRealtimeInvalidation` up to `DocumentsHub` with both `document:*` and `file:*` events targeting the prefix keys `['files']` and `['documents']`. TanStack Query prefix matching will invalidate the aggregated keys. + +**A3. LEFT JOIN port_id in ON clause defeats `idx_docs_signed_file_id`** +`src/lib/services/files.ts:544` + +```sql +LEFT JOIN documents d ON d.signed_file_id = f.id AND d.port_id = $portId +``` + +Planner picks `idx_docs_port` and applies `signed_file_id = f.id` as a residual filter. At scale this is 20 × N comparisons per page load instead of 20 point lookups. Same pattern in `documents.service.ts:1915` for the workflow projection. + +**Fix:** drop `AND d.port_id = portId` from the ON clause and add `AND (d.port_id = portId OR d.id IS NULL)` to the outer WHERE. Or add a composite `(signed_file_id, port_id)` index. `files.port_id` is already scoped, so cross-port leak risk is zero. + +**A4. Importer doesn't set `files.folder_id` — imported files invisible to folder queries** +`scripts/import-organized-documents.ts:196-208` + +The `documents` row gets `folderId` correctly (line 216) but the companion `files` row does not. `files.folder_id` is a separate column. The backfill won't rescue these — it only acts on files with entity FKs set, and the importer sets none of those either. + +**Fix:** copy `folderId` into the `files.values(...)` block alongside the document insert. + +**A5. `chk_system_folder_shape` has NULL escape — corrupted system rows persist** +`src/lib/db/migrations/0051_documents_hub_split.sql:22-28` + +`NOT system_managed OR entity_type = 'root' OR (...)` evaluates to `NULL` (not `false`) when `entity_type IS NULL` and `system_managed = true`. Postgres treats NULL as "not false" so the constraint passes. Confirmed by direct insert test. + +**Fix:** add `entity_type IS NOT NULL` to the constraint, or restructure as `CHECK (NOT system_managed OR (entity_type IS NOT NULL AND (entity_type = 'root' OR (entity_type = ANY(...) AND entity_id IS NOT NULL))))`. + +**A6. `document-folders.service.ts` has zero log lines — silent failures across the entire folder service** +`src/lib/services/document-folders.service.ts` (no `logger` import) + +Orphan rows in `listTree` are silently dropped (line 83-84). The 50-attempt suffix-loop exhaustion throws `ConflictError` with no log. `ensureSystemRoots` "missing root after upsert" throws raw `Error`. At 3am you would have no diagnostic for folder-related failures. + +**Fix:** `import { logger } from '@/lib/logger'`. Add `logger.warn` on orphan-detection, retry-exhaustion (both `ensureEntityFolder` and `syncEntityFolderName`), and the missing-root invariant in `ensureSystemRoots`. + +**A7. `demoteSystemFolderOnEntityDelete` is not wired into `client-hard-delete.service.ts`** +`src/lib/services/document-folders.service.ts:650` (exported but zero callers) + +`client-hard-delete.service.ts` exists. It clears entity FKs on `files` and `documents` inside its transaction but never demotes the system folder. After hard-delete: folder retains `system_managed=true` + the dead `entity_id`. The partial unique index `uniq_document_folders_entity` permanently blocks any future client folder that would get the same display name. Also a GDPR right-to-be-forgotten gap. + +**Fix:** call `demoteSystemFolderOnEntityDelete(portId, 'client', clientId)` inside `hardDeleteClient`'s transaction (or as a post-commit hook with audit log). Confirm whether `companies`/`yachts` have analogous hard-delete services that also need wiring. + +### B. Accessibility blockers (WCAG 2.1 AA failures) + +**B1. Unlabeled search input** +`src/components/documents/documents-hub.tsx:265` + +`` — placeholder is not a label. Fails WCAG 1.3.1 / 4.1.2. +**Fix:** `aria-label="Search documents by title"`. + +**B2. No `aria-pressed` on type-filter chips** +`src/components/documents/documents-hub.tsx:276-299` + +Active state is purely visual. Screen readers can't tell which chip is selected. Fails WCAG 4.1.2. +**Fix:** `aria-pressed={typeFilter === t}` on each chip. + +**B3. No `aria-expanded` on tree chevrons; folder-row labels lack context** +`src/components/documents/folder-tree-sidebar.tsx:125, 135-155` + +The expand button has `aria-label="Collapse"` / `"Expand"` with no folder name, so SR users hear "Expand button, Expand button…" with no differentiation. And it lacks `aria-expanded` so the open/closed state is invisible. +**Fix:** `aria-expanded={open}`, `aria-label={\`${open ? 'Collapse' : 'Expand'} ${node.name}\`}`. Same pattern in `documents-hub.tsx:210-217` for the per-row signer expand. + +**B4. `aria-label` on Lock SVG becomes part of button's accessible name** +`src/components/documents/folder-tree-sidebar.tsx:150-154` + +`` inside the folder-select ` + )} @@ -168,30 +174,31 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) { - - - New document - - - } + actions={} variant="gradient" /> ) : isEntityFolder && isEntityType(folderEntityType) ? ( - + > + + ) : ( - + + + )} @@ -373,3 +380,119 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) { ); } + +// --------------------------------------------------------------------------- +// FolderDropZone — wraps the main content panel and accepts file drops onto +// the currently-viewed folder. Files dropped here upload with folder_id + +// entity FKs set so they land where the rep expects. +// +// Renders an overlay while a drag is in progress; the underlying content +// stays interactive. Multiple files at once are supported (Promise.all +// inside the upload loop). Errors surface inline; the toast layer picks +// them up via React Query / fetch error responses. +// --------------------------------------------------------------------------- + +interface FolderDropZoneProps { + folderId: string | null; + entityType?: 'client' | 'company' | 'yacht'; + entityId?: string; + children: React.ReactNode; +} + +function FolderDropZone({ folderId, entityType, entityId, children }: FolderDropZoneProps) { + const [dragActive, setDragActive] = useState(false); + const [uploading, setUploading] = useState(false); + const dragCounter = useMemo(() => ({ count: 0 }), []); + const queryClient = useQueryClient(); + const portId = useUIStore((s) => s.currentPortId); + + const onDragEnter = useCallback( + (e: React.DragEvent) => { + // Only react to drags that carry files. Avoids fighting with + // text-selection / element drags inside the listing. + if (!Array.from(e.dataTransfer.types).includes('Files')) return; + e.preventDefault(); + dragCounter.count += 1; + if (dragCounter.count === 1) setDragActive(true); + }, + [dragCounter], + ); + + const onDragLeave = useCallback( + (e: React.DragEvent) => { + if (!Array.from(e.dataTransfer.types).includes('Files')) return; + dragCounter.count -= 1; + if (dragCounter.count <= 0) { + dragCounter.count = 0; + setDragActive(false); + } + }, + [dragCounter], + ); + + const onDragOver = useCallback((e: React.DragEvent) => { + if (!Array.from(e.dataTransfer.types).includes('Files')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + }, []); + + const onDrop = useCallback( + async (e: React.DragEvent) => { + if (!Array.from(e.dataTransfer.types).includes('Files')) return; + e.preventDefault(); + dragCounter.count = 0; + setDragActive(false); + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + setUploading(true); + try { + await Promise.all( + files.map(async (file) => { + const fd = new FormData(); + fd.append('file', file); + fd.append('filename', file.name); + if (folderId) fd.append('folderId', folderId); + if (entityType) fd.append('entityType', entityType); + if (entityId) fd.append('entityId', entityId); + if (entityType === 'client' && entityId) fd.append('clientId', entityId); + if (entityType === 'company' && entityId) fd.append('companyId', entityId); + if (entityType === 'yacht' && entityId) fd.append('yachtId', entityId); + const headers = new Headers(); + if (portId) headers.set('X-Port-Id', portId); + await fetch('/api/v1/files/upload', { + method: 'POST', + headers, + credentials: 'include', + body: fd, + }); + }), + ); + queryClient.invalidateQueries({ queryKey: ['files'] }); + queryClient.invalidateQueries({ queryKey: ['documents'] }); + } finally { + setUploading(false); + } + }, + [dragCounter, folderId, entityType, entityId, portId, queryClient], + ); + + return ( +
+ {children} + {(dragActive || uploading) && ( +
+
+ + {uploading ? 'Uploading…' : 'Drop to upload to this folder'} +
+
+ )} +
+ ); +} diff --git a/src/components/documents/new-document-menu.tsx b/src/components/documents/new-document-menu.tsx new file mode 100644 index 00000000..4cb13757 --- /dev/null +++ b/src/components/documents/new-document-menu.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useQueryClient } from '@tanstack/react-query'; +import { ChevronDown, FileSignature, Plus, Upload } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { FileUploadZone } from '@/components/files/file-upload-zone'; + +/** + * Dropdown that replaces the bare "+ New document" button on the documents + * hub. Splits the action into the two real flows so reps know which one + * they want before they click: + * - "Upload file" → opens a dialog with FileUploadZone scoped to the + * current folder + entity context. No signing flow attached. + * - "Generate document for signing" → links to /documents/new wizard. + * + * Folder/entity context is passed through so uploaded files land in the + * right place (entity FK + folder_id). Drops do the same via FileUploadZone's + * existing folderId / entity props. + */ +interface NewDocumentMenuProps { + portSlug: string; + /** Selected folderId, or null for the root folder, or undefined when the + * user is on the hub landing page (no folder selected). */ + folderId?: string | null; + /** Entity context for system-managed entity folders. When set, uploaded + * files are wired to the right FK column on the files table. */ + entityType?: 'client' | 'company' | 'yacht'; + entityId?: string; + /** Visual variant: "default" for big banner placement, "sm" for the + * inline-with-breadcrumb placement on folder views. */ + size?: 'default' | 'sm'; +} + +export function NewDocumentMenu({ + portSlug, + folderId, + entityType, + entityId, + size = 'default', +}: NewDocumentMenuProps) { + const [uploadOpen, setUploadOpen] = useState(false); + const queryClient = useQueryClient(); + + return ( + <> + + + + + + setUploadOpen(true)} className="gap-2 py-2.5"> + +
+ Upload file + + Drop or browse — stored in the current folder + +
+
+ + + +
+ Generate document for signing + + EOI, contract, or custom — sent via Documenso + +
+ +
+
+
+ + + + + Upload file + + {folderId === undefined + ? 'File will be added to the root.' + : entityType && entityId + ? `File will be filed under this ${entityType}.` + : 'File will be added to the current folder.'} + + + { + if (!file) { + // Trailing "batch done" call — invalidate hub caches so the + // newly-uploaded file appears in the Recent files / folder + // listings without a manual reload. + queryClient.invalidateQueries({ queryKey: ['files'] }); + queryClient.invalidateQueries({ queryKey: ['documents'] }); + setUploadOpen(false); + } + }} + /> + + + + ); +} diff --git a/src/components/files/file-upload-zone.tsx b/src/components/files/file-upload-zone.tsx index 30c139c9..4ba98faa 100644 --- a/src/components/files/file-upload-zone.tsx +++ b/src/components/files/file-upload-zone.tsx @@ -85,9 +85,9 @@ export function FileUploadZone({ throw new Error('Upload failed'); } - const uploadJson = (await uploadRes - .json() - .catch(() => null)) as { data?: { id?: string; filename?: string } } | null; + const uploadJson = (await uploadRes.json().catch(() => null)) as { + data?: { id?: string; filename?: string }; + } | null; if (uploadJson?.data?.id) { onUploadComplete?.({ id: uploadJson.data.id,