feat(uat-batch): Group P — nested document subfolders phases 2/3
P56 from the 2026-05-21 plan. Foundation (phase 1) shipped in e91055f.
Shipped:
- **UploadZone scope radio.** <FileUploadZone> accepts an optional
`interestId` prop. When set (currently passed from
InterestDocumentsTab) the upload-zone surfaces a small fieldset:
"File at: ⦿ This deal | ◯ Client-level (all deals)". Default is
deal-scope so reps don't accidentally surface deal-specific docs
across every historical interest of the client. The interest FK
is forwarded to /api/v1/files/upload only when "This deal" is
selected; client-level uploads omit it and land at the client
folder.
- **Outcome → folder rename lifecycle hook.** New
`renameInterestFolderForOutcome(interestId, portId, outcome)` in
document-folders.service. Strips any prior outcome suffix from
the folder name (so re-running on a lost→won flip doesn't
accumulate parens) and appends `(Won)` / `(Lost)` / `(Cancelled)`.
Fired fire-and-forget from interests.service.setInterestOutcome
via dynamic import to dodge the circular dep with this module's
primary-berth label resolver. No-op when the folder hasn't been
created yet (first upload happens later).
- **Backfill script.** scripts/backfill-nested-document-folders.ts
iterates every (port_id, interest_id) pair in `files` that has
a non-null interest_id and calls ensureEntityFolder so the
nested `Clients/<Name>/Deal …/` folder exists. Idempotent —
`ensureEntityFolder` short-circuits when the folder is already
there. Per-port advisory lock (FNV-1a of port_id) keeps two
operators from racing. Dry-run by default; `--apply` to commit.
Deferred:
- listFilesAggregatedByEntity rewrite to show "This deal" vs "From
client" subheadings — UI polish; the per-row filing already
happens correctly via the upload-zone scope radio.
- Documents Hub tree rendering for nested interest folders — the
folder rows already exist with `parent_id` set; the tree
component picks them up automatically.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
83
scripts/backfill-nested-document-folders.ts
Normal file
83
scripts/backfill-nested-document-folders.ts
Normal file
@@ -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/<Name>/<Interest folder>/` 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);
|
||||||
|
});
|
||||||
@@ -18,6 +18,15 @@ interface FileUploadZoneProps {
|
|||||||
clientId?: string;
|
clientId?: string;
|
||||||
yachtId?: string;
|
yachtId?: string;
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
/**
|
||||||
|
* Optional interest FK. When supplied alongside `clientId`, the rep
|
||||||
|
* picks whether to file the upload at the deal level
|
||||||
|
* (`Clients/<Name>/<Interest folder>/`) or the client level
|
||||||
|
* (`Clients/<Name>/`) 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
|
* Optional folder to deposit the file into. Hub uploads pass the
|
||||||
* currently-selected folderId so files land where the user expects.
|
* currently-selected folderId so files land where the user expects.
|
||||||
@@ -37,12 +46,20 @@ export function FileUploadZone({
|
|||||||
clientId,
|
clientId,
|
||||||
yachtId,
|
yachtId,
|
||||||
companyId,
|
companyId,
|
||||||
|
interestId,
|
||||||
folderId,
|
folderId,
|
||||||
onUploadComplete,
|
onUploadComplete,
|
||||||
}: FileUploadZoneProps) {
|
}: FileUploadZoneProps) {
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [uploading, setUploading] = useState<UploadingFile[]>([]);
|
const [uploading, setUploading] = useState<UploadingFile[]>([]);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(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(
|
const uploadFiles = useCallback(
|
||||||
async (fileList: FileList) => {
|
async (fileList: FileList) => {
|
||||||
@@ -63,6 +80,13 @@ export function FileUploadZone({
|
|||||||
if (clientId) formData.append('clientId', clientId);
|
if (clientId) formData.append('clientId', clientId);
|
||||||
if (yachtId) formData.append('yachtId', yachtId);
|
if (yachtId) formData.append('yachtId', yachtId);
|
||||||
if (companyId) formData.append('companyId', companyId);
|
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 (entityType) formData.append('entityType', entityType);
|
||||||
if (entityId) formData.append('entityId', entityId);
|
if (entityId) formData.append('entityId', entityId);
|
||||||
if (folderId) formData.append('folderId', folderId);
|
if (folderId) formData.append('folderId', folderId);
|
||||||
@@ -138,6 +162,37 @@ export function FileUploadZone({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* Scope radio — only renders when an interest FK is present. The
|
||||||
|
rep picks whether the upload files at the deal level (nested
|
||||||
|
under Clients/<Name>/<Interest>) or the client level
|
||||||
|
(Clients/<Name>). Default = deal, since that's the narrower
|
||||||
|
filing path and reps almost always upload deal-specific docs
|
||||||
|
here. */}
|
||||||
|
{interestId ? (
|
||||||
|
<fieldset className="flex flex-wrap items-center gap-3 rounded-md border bg-muted/30 px-3 py-2 text-xs">
|
||||||
|
<legend className="px-1 font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
File at
|
||||||
|
</legend>
|
||||||
|
<label className="inline-flex cursor-pointer items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="upload-scope"
|
||||||
|
checked={scope === 'interest'}
|
||||||
|
onChange={() => setScope('interest')}
|
||||||
|
/>
|
||||||
|
<span>This deal</span>
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex cursor-pointer items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="upload-scope"
|
||||||
|
checked={scope === 'client'}
|
||||||
|
onChange={() => setScope('client')}
|
||||||
|
/>
|
||||||
|
<span>Client-level (all deals)</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
|
|||||||
entityType="client"
|
entityType="client"
|
||||||
entityId={interest.clientId}
|
entityId={interest.clientId}
|
||||||
clientId={interest.clientId}
|
clientId={interest.clientId}
|
||||||
|
// Pass the interest FK so the upload-zone's scope radio
|
||||||
|
// surfaces "This deal" (default) vs "Client-level". The
|
||||||
|
// server-side service routes the file to the nested
|
||||||
|
// interest folder when the rep picks "This deal", or
|
||||||
|
// leaves it at the client folder when they pick
|
||||||
|
// client-level.
|
||||||
|
interestId={interestId}
|
||||||
onUploadComplete={() => {
|
onUploadComplete={() => {
|
||||||
queryClient.invalidateQueries({ queryKey: filesQueryKey });
|
queryClient.invalidateQueries({ queryKey: filesQueryKey });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -811,3 +811,40 @@ export async function demoteSystemFolderOnEntityDelete(
|
|||||||
})
|
})
|
||||||
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));
|
.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<void> {
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1244,6 +1244,20 @@ export async function setInterestOutcome(
|
|||||||
// via system_settings.berth_rules.
|
// via system_settings.berth_rules.
|
||||||
void evaluateRule('interest_completed', id, portId, meta);
|
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
|
// Phase 6 — CRM → Umami attribution. Fire a custom Umami event so
|
||||||
// marketing can correlate inbound website traffic with the resulting
|
// marketing can correlate inbound website traffic with the resulting
|
||||||
// deal outcome. Dynamic import to avoid a circular service dep at
|
// deal outcome. Dynamic import to avoid a circular service dep at
|
||||||
|
|||||||
Reference in New Issue
Block a user