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:
@@ -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<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.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user