Files
pn-new-crm/scripts/backfill-nested-document-folders.ts

84 lines
2.9 KiB
TypeScript
Raw Normal View History

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>
2026-05-21 23:43:55 +02:00
#!/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);
});