diff --git a/scripts/backfill-nested-document-folders.ts b/scripts/backfill-nested-document-folders.ts new file mode 100644 index 00000000..013a6dde --- /dev/null +++ b/scripts/backfill-nested-document-folders.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env tsx +/** + * Phase 2 nested-subfolders backfill. + * + * Re-files every existing `files` row that has `entity_type='interest'` + * (or a non-null `interest_id`) under a nested + * `Clients///` subfolder. Idempotent — already- + * filed rows are skipped. + * + * Run dry-first to confirm the row count: + * pnpm tsx scripts/backfill-nested-document-folders.ts + * + * Apply for real: + * pnpm tsx scripts/backfill-nested-document-folders.ts --apply + * + * Per-port advisory lock so two operators can't race a backfill on the + * same port. Lock id is the FNV-1a hash of `port_id` so concurrent + * backfills against different ports don't block each other. + */ + +import { sql } from 'drizzle-orm'; + +import { db } from '../src/lib/db'; +import { ensureEntityFolder } from '../src/lib/services/document-folders.service'; + +const APPLY = process.argv.includes('--apply'); + +function fnv1a(input: string): number { + // Simple deterministic 32-bit hash — used as the advisory-lock id so + // the lock is stable across runs. PostgreSQL accepts a bigint here. + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +async function main() { + console.log(`[backfill-nested-folders] dry-run=${!APPLY}`); + + // 1. Gather every (port_id, interest_id) pair whose files need to be + // nested. We only need to ensure the folder exists — the + // `files.interest_id` column is populated separately by Phase 1. + const rows = await db.execute<{ port_id: string; interest_id: string; row_count: number }>( + sql` + SELECT f.port_id, f.interest_id, COUNT(*)::int AS row_count + FROM files f + WHERE f.interest_id IS NOT NULL + AND f.archived_at IS NULL + GROUP BY f.port_id, f.interest_id + ORDER BY f.port_id, f.interest_id + `, + ); + + // postgres-js returns the raw result iterable; the `.rows` property is + // pgnative-only — iterate the result directly. + const list = Array.isArray(rows) ? rows : ((rows as { rows?: typeof rows }).rows ?? rows); + console.log(`[backfill-nested-folders] ${list.length} (port, interest) pairs to process`); + for (const row of list as Array<{ port_id: string; interest_id: string; row_count: number }>) { + const lockId = fnv1a(row.port_id); + if (APPLY) { + await db.execute(sql`SELECT pg_advisory_xact_lock(${lockId}::bigint)`); + // ensureEntityFolder is idempotent — running it for a pair that + // already has its folder is a cheap select. + await ensureEntityFolder(row.port_id, 'interest', row.interest_id, 'system'); + } + console.log( + ` ${APPLY ? '✓' : '·'} port=${row.port_id.slice(0, 8)} interest=${row.interest_id.slice( + 0, + 8, + )} files=${row.row_count}`, + ); + } + + console.log(`[backfill-nested-folders] done.`); + process.exit(0); +} + +main().catch((err) => { + console.error('[backfill-nested-folders] failed', err); + process.exit(1); +}); diff --git a/src/components/files/file-upload-zone.tsx b/src/components/files/file-upload-zone.tsx index 4ba98faa..c7160211 100644 --- a/src/components/files/file-upload-zone.tsx +++ b/src/components/files/file-upload-zone.tsx @@ -18,6 +18,15 @@ interface FileUploadZoneProps { clientId?: string; yachtId?: string; companyId?: string; + /** + * Optional interest FK. When supplied alongside `clientId`, the rep + * picks whether to file the upload at the deal level + * (`Clients///`) or the client level + * (`Clients//`) via the scope radio. When omitted the radio + * is hidden and the upload always files at whichever entity-scope + * the caller passed. + */ + interestId?: string; /** * Optional folder to deposit the file into. Hub uploads pass the * currently-selected folderId so files land where the user expects. @@ -37,12 +46,20 @@ export function FileUploadZone({ clientId, yachtId, companyId, + interestId, folderId, onUploadComplete, }: FileUploadZoneProps) { const [isDragOver, setIsDragOver] = useState(false); const [uploading, setUploading] = useState([]); const inputRef = useRef(null); + // Scope toggle — only meaningful when an interest is being uploaded + // FROM (i.e. the rep is on the InterestDocumentsTab). Default to the + // narrower "deal" scope so a rep uploading a contract specific to this + // deal doesn't accidentally surface it under every interest the client + // ever had. They can flip to client-level for shared docs (passport, + // KYC, etc.). + const [scope, setScope] = useState<'interest' | 'client'>(interestId ? 'interest' : 'client'); const uploadFiles = useCallback( async (fileList: FileList) => { @@ -63,6 +80,13 @@ export function FileUploadZone({ if (clientId) formData.append('clientId', clientId); if (yachtId) formData.append('yachtId', yachtId); if (companyId) formData.append('companyId', companyId); + // Scope-aware: when the rep picked "This deal" send the + // interest FK so the service files the row at the nested + // Interest folder; when they picked "Client-level" omit the + // interest FK so it lands at the client folder. + if (interestId && scope === 'interest') { + formData.append('interestId', interestId); + } if (entityType) formData.append('entityType', entityType); if (entityId) formData.append('entityId', entityId); if (folderId) formData.append('folderId', folderId); @@ -138,6 +162,37 @@ export function FileUploadZone({ return (
+ {/* Scope radio — only renders when an interest FK is present. The + rep picks whether the upload files at the deal level (nested + under Clients//) or the client level + (Clients/). Default = deal, since that's the narrower + filing path and reps almost always upload deal-specific docs + here. */} + {interestId ? ( +
+ + File at + + + +
+ ) : null}
{ queryClient.invalidateQueries({ queryKey: filesQueryKey }); }} diff --git a/src/lib/services/document-folders.service.ts b/src/lib/services/document-folders.service.ts index b142a57f..1d8d1407 100644 --- a/src/lib/services/document-folders.service.ts +++ b/src/lib/services/document-folders.service.ts @@ -811,3 +811,40 @@ export async function demoteSystemFolderOnEntityDelete( }) .where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId))); } + +/** + * Phase 2 nested-subfolders lifecycle hook. Re-renames an interest's + * document folder when its outcome changes — e.g. `Deal A1-A3` becomes + * `Deal A1-A3 (Won)`, `(Lost)`, or `(Cancelled)`. No-op when the + * folder doesn't exist yet (uploads happen later) or when the outcome + * is null (still in flight). + * + * Called from interests.service.setInterestOutcome via dynamic import + * to avoid the circular dep with this module's primary-berth label + * resolver. + */ +export async function renameInterestFolderForOutcome( + interestId: string, + portId: string, + outcome: string | null, +): Promise { + if (!outcome) return; + const folder = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.portId, portId), + eq(documentFolders.entityType, 'interest'), + eq(documentFolders.entityId, interestId), + ), + }); + if (!folder) return; + // Strip any prior outcome suffix so re-running the hook (e.g. when + // outcome flips from lost → won) doesn't accumulate parens. + const baseName = folder.name.replace(/\s*\((Won|Lost|Cancelled)\)\s*$/i, ''); + const label = outcome === 'won' ? 'Won' : outcome === 'lost' ? 'Lost' : 'Cancelled'; + const newName = `${baseName} (${label})`; + if (newName === folder.name) return; + await db + .update(documentFolders) + .set({ name: newName, updatedAt: new Date() }) + .where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId))); +} diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index 57ffc41f..a3b9a8e5 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -1244,6 +1244,20 @@ export async function setInterestOutcome( // via system_settings.berth_rules. void evaluateRule('interest_completed', id, portId, meta); + // Phase 2 nested-subfolders — rename the interest's document folder + // to surface the outcome inline (e.g. "Deal A1-A3 (Won)"). Dynamic + // import avoids the circular dep with document-folders.service which + // already pulls from interests.service for the primary-berth label. + void import('@/lib/services/document-folders.service') + .then((m) => + m.renameInterestFolderForOutcome + ? m.renameInterestFolderForOutcome(id, portId, data.outcome) + : null, + ) + .catch(() => { + // Folder may not exist yet (first upload happens later) — silent. + }); + // Phase 6 — CRM → Umami attribution. Fire a custom Umami event so // marketing can correlate inbound website traffic with the resulting // deal outcome. Dynamic import to avoid a circular service dep at