Files
pn-new-crm/src/lib/services/document-folders.service.ts

851 lines
31 KiB
TypeScript
Raw Normal View History

fix(audit-wave-10): concurrency hardening (concurrency-auditor) Close the CRITICAL + HIGH-tractable race conditions the concurrency-auditor flagged. The wide-impact items (BullMQ jobId plumbing — C-2; webhook outbound retry idempotency keys; etc.) span too many call sites for a single contained wave and stay deferred. **C-1 — handleDocumentCompleted concurrent-retry orphan-blob** Wave 1 fixed the compensating-delete on single-process failure but the idempotency gate at line 1110 reads `doc.status` outside any row lock. Two webhook deliveries arriving in parallel both pass the gate, both storage.put + db.insert(files), and the losing files row orphans its blob since documents.signed_file_id only points at one. Now the transaction at line 1176 SELECTs the document `FOR UPDATE` and re-checks the gate; if a concurrent worker already completed, throws a sentinel `DocumentAlreadyCompletedError` which the outer catch recognizes and runs the compensating storage.delete at info level (not error). Net effect: at-most-once signed-PDF persistence even under Documenso 5xx-then-retry storms. **H-1 — moveFolder cycle check race** Two concurrent folder moves (A → B and B → A) in READ COMMITTED can each pass the cycle check against pre-state and both commit, leaving A↔B in the tree. Add a per-port `pg_advisory_xact_lock` at the top of the move transaction so the walk-and-write is atomic per port. Lock auto-releases on tx end; no impact on cross-port folder ops. **H-3 — upsertInterestBerth 23505 → generic 500** Two concurrent `setPrimaryBerth` calls hit `idx_interest_berths_one_primary` and the loser surfaced as a generic 500. Catch the 23505 + constraint name and remap to ConflictError so the UI gets a "Another rep changed the primary berth at the same time. Refresh and try again." toast. **M-2 — username uniqueness 23505 → generic 500** Same TOCTOU shape: pre-check at me/route.ts:132 says "available", the UPDATE then fails at the partial unique index. Catch 23505 + `idx_user_profiles_username_unique` and remap to ConflictError. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:34:23 +02:00
import { and, asc, eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
deps: bump Tier-A patches + react-day-picker 10 + esbuild 0.28 Successfully bumped: - bullmq 5.76.6 → 5.76.8 - @tanstack/react-query 5.100.9 → 5.100.10 - @tanstack/react-query-devtools 5.100.9 → 5.100.10 - better-auth 1.6.9 → 1.6.10 - @playwright/test 1.59.1 → 1.60.0 - libphonenumber-js 1.12.43 → 1.13.1 - tailwind-merge 3.5.0 → 3.6.0 - vitest 4.1.5 → 4.1.6 - @vitest/coverage-v8 4.1.5 → 4.1.6 - lint-staged 17.0.3 → 17.0.4 - esbuild 0.27.7 → 0.28.0 - react-grab 0.1.33 → 0.1.34 - react-day-picker 9.14.0 → 10.0.0 react-day-picker 10 verified safe: probed v10 release notes against src/components/ui/calendar.tsx — we use only v9-canonical APIs that v10 preserves. Removed the `table` className entry from the wrapper (v10 dropped it since the renderer is now CSS-grid, not table-based). Tried + rolled back: - @hookform/resolvers 3 → 5: stricter input/output inference broke every form using <{schema}, any, {schema}> implicit shape. Needs per-form refactor; parked. Verified clean: pnpm audit (prod + dev) = 0 vulnerabilities; pnpm exec tsc --noEmit clean; vitest 1293/1293 pass. Remaining outdated (deliberately deferred — see docs/AUDIT-2026-05-12.md §34): - next/eslint-config-next 15 → 16 (2-4 wk wait) - zod 3 → 4 (couple with @hookform/resolvers 5; codemod-needed) - tailwindcss 3 → 4 (focused-afternoon project) - @types/node ^20.19 stays pinned to match runtime (audit decision) - archiver 7 stays (no @types/archiver@8 published) - eslint 9 stays (locked to eslint-config-next 15) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:33:24 +02:00
import { documentFolders, documents, files, type DocumentFolder } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { companies } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
feat(documents): foundation for nested interest subfolders (phase 1/3) Sets up the schema + service primitives the rest of the nested- document-subfolders feature will build on (master UAT line 728+). This commit is INFRASTRUCTURE ONLY — the upload-zone scope radio, lifecycle hooks for outcome rename, aggregated-projection list query, and backfill script are deferred to follow-up commits. Schema (migration 0078_files_interest_id.sql): - `files.interest_id` text REFERENCES interests(id) ON DELETE SET NULL. Mirrors the existing documents.interest_id; lets file uploads be scoped to a deal while still rolling up to the parent client folder. - idx_files_interest + idx_files_port_interest for the aggregated- projection queries that will surface "This deal" vs "From client" file lists. Service: - EntityType extended to include 'interest'. Interest folders parent under the owning client's entity folder (not at a system root), so the tree reads Clients/Acme/Deal A1-A3/ — nested. - ensureEntityFolder recursively ensures the parent client folder first when given an interest, guaranteeing the deal folder lands inside the right client subfolder even when the first artifact on the deal predates any client-level upload. - resolveEntityDisplayName for interest: "Deal — <mooringNumber>" (when a primary berth is linked) or "Deal <YYYY-MM-DD>" as the stable fallback. Dynamic-import on getPrimaryBerth dodges the circular dep between document-folders.service and interest-berths.service. Aggregated projection (files.ts): - listFilesAggregatedByEntity SELECT now includes the new interest_id column so AggregatedFileRow's structural type matches. Downstream consumers gain access to the deal scope; the actual "From this deal" subheading in InterestDocumentsTab is wired in the follow-up. Remaining work (tracked in master UAT line 728+, parked for next session): - UploadZone `scopeOptions` radio (single-option pickers hide the radio entirely for client/yacht/company surfaces). - Lifecycle hooks for interest outcome → folder rename ("Deal A1-A3 (Won)") via soft-rescue per CLAUDE.md. - listFilesAggregatedByEntity rewrite to surface "This deal" vs "From client" subheadings on InterestDocumentsTab. - Documents Hub tree rendering for nested interest folders. - backfill script: existing files with entity_type='interest' + entity_id but missing interest_id column → populate. Verified: tsc clean, vitest 1448/1448 after dev-DB migration applied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:18:40 +02:00
import { interests } from '@/lib/db/schema/interests';
import { createAuditLog } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
/**
* Returns true if the error is a Postgres unique-violation (SQLSTATE 23505)
* raised specifically on the sibling-name unique index. Narrowing to this
* constraint prevents swallowing unrelated unique violations.
*/
function isSiblingNameConflict(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const e = err as {
code?: unknown;
constraint_name?: unknown;
constraint?: unknown;
cause?: { code?: unknown; constraint_name?: unknown; constraint?: unknown };
};
const code = e.code ?? e.cause?.code;
if (code !== '23505') return false;
const constraint =
e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint;
return constraint === 'uniq_document_folders_sibling_name';
}
/**
* Throws ConflictError if the folder is system-managed. Centralises the
* rejection so rename/move/delete all surface identical error shapes.
*/
async function assertNotSystemManaged(
portId: string,
folderId: string,
action: 'rename' | 'move' | 'delete',
): Promise<DocumentFolder> {
const folder = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
});
if (!folder) throw new NotFoundError('Folder');
if (folder.systemManaged) {
const verb = action === 'rename' ? 'renamed' : action === 'move' ? 'moved' : 'deleted';
throw new ConflictError(`System folders can't be ${verb}`);
}
return folder;
}
export interface FolderNode extends DocumentFolder {
children: FolderNode[];
}
/**
* Returns the entire folder tree for a port, nested under their
* parents. Roots come back at the top level. Order is alphabetical
* (case-insensitive) within each parent matches the sibling-uniqueness
* index ordering and gives reps a stable browsing experience.
*
* Uses a single SELECT + JS nesting rather than a recursive CTE; the
* folder tree is small (UI gates depth; thousands of folders would be
* a misuse) so the in-memory build is cheaper than a CTE round-trip.
*/
export async function listTree(portId: string): Promise<FolderNode[]> {
const rows = await db
.select()
.from(documentFolders)
.where(eq(documentFolders.portId, portId))
.orderBy(asc(documentFolders.name));
const byId = new Map<string, FolderNode>();
for (const row of rows) byId.set(row.id, { ...row, children: [] });
const roots: FolderNode[] = [];
for (const node of byId.values()) {
if (node.parentId === null) {
roots.push(node);
} else {
const parent = byId.get(node.parentId);
if (parent) {
parent.children.push(node);
} else {
// Orphan rows (parentId pointing nowhere) are dropped from the
// tree but stay in the DB. Surface via a separate maintenance
// query if needed; never silently re-parent.
logger.warn(
{ portId, folderId: node.id, parentId: node.parentId, name: node.name },
'listTree: orphan folder row (parentId points to a missing folder); dropped from tree',
);
}
}
}
return roots;
}
interface CreateFolderInput {
name: string;
parentId: string | null;
}
/**
* Creates a folder under the given parent. Throws ConflictError when
* a sibling with the same case-insensitive name already exists (the
* DB unique index is the authoritative guard; this maps the Postgres
* 23505 to the typed error). Throws ValidationError when `parentId`
* doesn't belong to this port (cross-port leakage guard).
*/
export async function createFolder(
portId: string,
userId: string,
data: CreateFolderInput,
): Promise<DocumentFolder> {
const trimmed = data.name.trim();
if (!trimmed) throw new ValidationError('Folder name cannot be empty');
if (trimmed.length > 200) throw new ValidationError('Folder name cannot exceed 200 chars');
if (data.parentId !== null) {
const parent = await db.query.documentFolders.findFirst({
where: and(eq(documentFolders.id, data.parentId), eq(documentFolders.portId, portId)),
});
if (!parent) throw new ValidationError('Invalid parent folder');
}
try {
const [row] = await db
.insert(documentFolders)
.values({
portId,
parentId: data.parentId,
name: trimmed,
createdBy: userId,
})
.returning();
if (!row) throw new NotFoundError('Folder');
void createAuditLog({
userId,
portId,
action: 'create',
entityType: 'document_folder',
entityId: row.id,
newValue: { name: row.name, parentId: row.parentId },
metadata: { type: 'folder_created' },
});
return row;
} catch (err) {
if (isSiblingNameConflict(err)) {
throw new ConflictError(`A folder named "${trimmed}" already exists here`);
}
throw err;
}
}
/**
* Renames a folder. Throws ConflictError if a sibling with the same
* case-insensitive name already exists. Throws NotFoundError if the
* folder doesn't belong to the given port (cross-port leakage guard).
*/
export async function renameFolder(
portId: string,
folderId: string,
newName: string,
userId: string,
): Promise<DocumentFolder> {
const trimmed = newName.trim();
if (!trimmed) throw new ValidationError('Folder name cannot be empty');
if (trimmed.length > 200) throw new ValidationError('Folder name cannot exceed 200 chars');
const existing = await assertNotSystemManaged(portId, folderId, 'rename');
try {
const [updated] = await db
.update(documentFolders)
.set({ name: trimmed, updatedAt: new Date() })
.where(and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)))
.returning();
if (!updated) throw new NotFoundError('Folder');
void createAuditLog({
userId,
portId,
action: 'update',
entityType: 'document_folder',
entityId: folderId,
oldValue: { name: existing.name },
newValue: { name: trimmed },
});
return updated;
} catch (err) {
if (isSiblingNameConflict(err)) {
throw new ConflictError(`A folder named "${trimmed}" already exists here`);
}
throw err;
}
}
/**
* Moves a folder to a new parent (or to root with newParentId=null).
* Walks the destination's ancestor chain to detect cycles before
* writing. Throws ValidationError for cycle/invalid-parent, NotFoundError
* for cross-port access.
*/
export async function moveFolder(
portId: string,
folderId: string,
newParentId: string | null,
userId: string,
): Promise<DocumentFolder> {
if (newParentId === folderId) {
throw new ValidationError('Cannot move a folder under itself (cycle)');
}
const folder = await assertNotSystemManaged(portId, folderId, 'move');
// concurrency-auditor HIGH: the cycle check + the UPDATE used to run
// as separate statements with no shared lock. Two concurrent moves
// (move A → B and move B → A) could each pass the cycle check
// against the pre-move tree, then both write, leaving an A↔B cycle.
// Wrap the whole sequence in a single transaction so the walk-and-
// write is atomic per move attempt.
try {
return await db.transaction(async (tx) => {
fix(audit-wave-10): concurrency hardening (concurrency-auditor) Close the CRITICAL + HIGH-tractable race conditions the concurrency-auditor flagged. The wide-impact items (BullMQ jobId plumbing — C-2; webhook outbound retry idempotency keys; etc.) span too many call sites for a single contained wave and stay deferred. **C-1 — handleDocumentCompleted concurrent-retry orphan-blob** Wave 1 fixed the compensating-delete on single-process failure but the idempotency gate at line 1110 reads `doc.status` outside any row lock. Two webhook deliveries arriving in parallel both pass the gate, both storage.put + db.insert(files), and the losing files row orphans its blob since documents.signed_file_id only points at one. Now the transaction at line 1176 SELECTs the document `FOR UPDATE` and re-checks the gate; if a concurrent worker already completed, throws a sentinel `DocumentAlreadyCompletedError` which the outer catch recognizes and runs the compensating storage.delete at info level (not error). Net effect: at-most-once signed-PDF persistence even under Documenso 5xx-then-retry storms. **H-1 — moveFolder cycle check race** Two concurrent folder moves (A → B and B → A) in READ COMMITTED can each pass the cycle check against pre-state and both commit, leaving A↔B in the tree. Add a per-port `pg_advisory_xact_lock` at the top of the move transaction so the walk-and-write is atomic per port. Lock auto-releases on tx end; no impact on cross-port folder ops. **H-3 — upsertInterestBerth 23505 → generic 500** Two concurrent `setPrimaryBerth` calls hit `idx_interest_berths_one_primary` and the loser surfaced as a generic 500. Catch the 23505 + constraint name and remap to ConflictError so the UI gets a "Another rep changed the primary berth at the same time. Refresh and try again." toast. **M-2 — username uniqueness 23505 → generic 500** Same TOCTOU shape: pre-check at me/route.ts:132 says "available", the UPDATE then fails at the partial unique index. Catch 23505 + `idx_user_profiles_username_unique` and remap to ConflictError. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:34:23 +02:00
// Serialize all folder-move work for this port via a per-port
// advisory lock. The cycle check walks the ancestor chain with
// multiple SELECTs, and READ COMMITTED doesn't see other in-flight
// updates without an explicit lock. Two concurrent moves (A → B
// and B → A) would otherwise each see the pre-state and both
// commit, leaving an A↔B cycle. The lock auto-releases on tx end.
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${portId} || ':folder-move'))`);
if (newParentId !== null) {
const newParent = await tx.query.documentFolders.findFirst({
deps: bump Tier-A patches + react-day-picker 10 + esbuild 0.28 Successfully bumped: - bullmq 5.76.6 → 5.76.8 - @tanstack/react-query 5.100.9 → 5.100.10 - @tanstack/react-query-devtools 5.100.9 → 5.100.10 - better-auth 1.6.9 → 1.6.10 - @playwright/test 1.59.1 → 1.60.0 - libphonenumber-js 1.12.43 → 1.13.1 - tailwind-merge 3.5.0 → 3.6.0 - vitest 4.1.5 → 4.1.6 - @vitest/coverage-v8 4.1.5 → 4.1.6 - lint-staged 17.0.3 → 17.0.4 - esbuild 0.27.7 → 0.28.0 - react-grab 0.1.33 → 0.1.34 - react-day-picker 9.14.0 → 10.0.0 react-day-picker 10 verified safe: probed v10 release notes against src/components/ui/calendar.tsx — we use only v9-canonical APIs that v10 preserves. Removed the `table` className entry from the wrapper (v10 dropped it since the renderer is now CSS-grid, not table-based). Tried + rolled back: - @hookform/resolvers 3 → 5: stricter input/output inference broke every form using <{schema}, any, {schema}> implicit shape. Needs per-form refactor; parked. Verified clean: pnpm audit (prod + dev) = 0 vulnerabilities; pnpm exec tsc --noEmit clean; vitest 1293/1293 pass. Remaining outdated (deliberately deferred — see docs/AUDIT-2026-05-12.md §34): - next/eslint-config-next 15 → 16 (2-4 wk wait) - zod 3 → 4 (couple with @hookform/resolvers 5; codemod-needed) - tailwindcss 3 → 4 (focused-afternoon project) - @types/node ^20.19 stays pinned to match runtime (audit decision) - archiver 7 stays (no @types/archiver@8 published) - eslint 9 stays (locked to eslint-config-next 15) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:33:24 +02:00
where: and(eq(documentFolders.id, newParentId), eq(documentFolders.portId, portId)),
});
if (!newParent) throw new ValidationError('Invalid parent folder');
// Cycle check: walk the destination's ancestor chain. If we hit
// folderId, the destination is a descendant of the folder being
// moved — moving would create a cycle.
let cursor: string | null = newParent.parentId;
const seen = new Set<string>([newParent.id]);
while (cursor) {
if (cursor === folderId) {
deps: bump Tier-A patches + react-day-picker 10 + esbuild 0.28 Successfully bumped: - bullmq 5.76.6 → 5.76.8 - @tanstack/react-query 5.100.9 → 5.100.10 - @tanstack/react-query-devtools 5.100.9 → 5.100.10 - better-auth 1.6.9 → 1.6.10 - @playwright/test 1.59.1 → 1.60.0 - libphonenumber-js 1.12.43 → 1.13.1 - tailwind-merge 3.5.0 → 3.6.0 - vitest 4.1.5 → 4.1.6 - @vitest/coverage-v8 4.1.5 → 4.1.6 - lint-staged 17.0.3 → 17.0.4 - esbuild 0.27.7 → 0.28.0 - react-grab 0.1.33 → 0.1.34 - react-day-picker 9.14.0 → 10.0.0 react-day-picker 10 verified safe: probed v10 release notes against src/components/ui/calendar.tsx — we use only v9-canonical APIs that v10 preserves. Removed the `table` className entry from the wrapper (v10 dropped it since the renderer is now CSS-grid, not table-based). Tried + rolled back: - @hookform/resolvers 3 → 5: stricter input/output inference broke every form using <{schema}, any, {schema}> implicit shape. Needs per-form refactor; parked. Verified clean: pnpm audit (prod + dev) = 0 vulnerabilities; pnpm exec tsc --noEmit clean; vitest 1293/1293 pass. Remaining outdated (deliberately deferred — see docs/AUDIT-2026-05-12.md §34): - next/eslint-config-next 15 → 16 (2-4 wk wait) - zod 3 → 4 (couple with @hookform/resolvers 5; codemod-needed) - tailwindcss 3 → 4 (focused-afternoon project) - @types/node ^20.19 stays pinned to match runtime (audit decision) - archiver 7 stays (no @types/archiver@8 published) - eslint 9 stays (locked to eslint-config-next 15) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:33:24 +02:00
throw new ValidationError('Cannot move a folder under one of its descendants (cycle)');
}
if (seen.has(cursor)) break; // defensive — pre-existing cycle, bail
seen.add(cursor);
const next = await tx.query.documentFolders.findFirst({
where: and(eq(documentFolders.id, cursor), eq(documentFolders.portId, portId)),
columns: { parentId: true },
});
cursor = next?.parentId ?? null;
}
}
const [updated] = await tx
.update(documentFolders)
.set({ parentId: newParentId, updatedAt: new Date() })
.where(and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)))
.returning();
if (!updated) throw new NotFoundError('Folder');
void createAuditLog({
userId,
portId,
action: 'update',
entityType: 'document_folder',
entityId: folderId,
oldValue: { parentId: folder.parentId },
newValue: { parentId: newParentId },
metadata: { type: 'folder_move' },
});
return updated;
});
} catch (err) {
if (isSiblingNameConflict(err)) {
throw new ConflictError('A folder with that name already exists in the destination');
}
throw err;
}
}
/**
* Soft-rescue delete: re-parent every child folder + every linked
* document to the deleted folder's parent (or to root if the deleted
* folder is at root). Audit-logged. Wrapped in a transaction so
* partial failures don't leave dangling rows.
*/
export async function deleteFolderSoftRescue(
portId: string,
folderId: string,
userId: string,
): Promise<void> {
const folder = await assertNotSystemManaged(portId, folderId, 'delete');
const newParent = folder.parentId; // null = re-parent to root
await db.transaction(async (tx) => {
await tx
.update(documentFolders)
.set({ parentId: newParent, updatedAt: new Date() })
.where(and(eq(documentFolders.parentId, folderId), eq(documentFolders.portId, portId)));
await tx
.update(documents)
.set({ folderId: newParent })
.where(and(eq(documents.folderId, folderId), eq(documents.portId, portId)));
// G-C1: files.folder_id is ON DELETE SET NULL — without this UPDATE,
// files in the deleted folder would scatter to root while documents
// in the same folder land at the deleted folder's parent. Re-parent
// files explicitly so the soft-rescue is symmetric across both.
await tx
.update(files)
.set({ folderId: newParent })
.where(and(eq(files.folderId, folderId), eq(files.portId, portId)));
await tx
.delete(documentFolders)
.where(and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)));
});
void createAuditLog({
userId,
portId,
action: 'delete',
entityType: 'document_folder',
entityId: folderId,
oldValue: { name: folder.name, parentId: folder.parentId },
metadata: { rescuedTo: newParent },
});
}
/**
* Walk a folder tree and return the IDs of every descendant of `rootId`
* (NOT including rootId itself). Used by `listDocuments` when
* `includeDescendants=true` so a port-wide tree fetch is reused
* instead of a recursive CTE.
*/
export function collectDescendantIds(tree: FolderNode[], rootId: string): string[] {
const out: string[] = [];
function visit(nodes: FolderNode[], inside: boolean) {
for (const n of nodes) {
if (inside || n.id === rootId) {
if (n.id !== rootId) out.push(n.id);
visit(n.children, true);
} else {
visit(n.children, false);
}
}
}
visit(tree, false);
return out;
}
const SYSTEM_ROOT_NAMES = ['Clients', 'Companies', 'Yachts'] as const;
type SystemRootName = (typeof SYSTEM_ROOT_NAMES)[number];
/**
* Idempotently create the three system root folders for a port
* (`Clients/`, `Companies/`, `Yachts/`). Returns the rows in stable
* order. Safe to call on every port-init and on every backfill run.
*
* Uses INSERT ON CONFLICT DO NOTHING via the sibling-name unique
* index (`uniq_document_folders_sibling_name`) so a concurrent caller
* can't race two inserts of the same root. Re-SELECTs on conflict so
* the return shape is always populated.
*/
export async function ensureSystemRoots(portId: string, userId: string): Promise<DocumentFolder[]> {
const values = SYSTEM_ROOT_NAMES.map((name) => ({
portId,
parentId: null,
name,
systemManaged: true,
entityType: 'root' as const,
entityId: null,
createdBy: userId,
}));
// ON CONFLICT DO NOTHING with no target is safe here because root
// inserts can only collide on `uniq_document_folders_sibling_name`
// (entityId is null on roots, so the partial index
// `uniq_document_folders_entity` is excluded). Do not copy this
// pattern into helpers that insert per-entity subfolders — they
// need an explicit target to avoid masking real conflicts.
await db.insert(documentFolders).values(values).onConflictDoNothing();
const rows = await db
.select()
.from(documentFolders)
.where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')));
return SYSTEM_ROOT_NAMES.map((name: SystemRootName) => {
const row = rows.find((r) => r.name === name);
if (!row) {
logger.error(
{ portId, missingRoot: name, foundNames: rows.map((r) => r.name) },
'ensureSystemRoots: invariant violated — system root missing after upsert',
);
throw new Error(`ensureSystemRoots: missing root ${name} after upsert`);
}
return row;
});
}
// ─── ensureEntityFolder ──────────────────────────────────────────────────────
feat(documents): foundation for nested interest subfolders (phase 1/3) Sets up the schema + service primitives the rest of the nested- document-subfolders feature will build on (master UAT line 728+). This commit is INFRASTRUCTURE ONLY — the upload-zone scope radio, lifecycle hooks for outcome rename, aggregated-projection list query, and backfill script are deferred to follow-up commits. Schema (migration 0078_files_interest_id.sql): - `files.interest_id` text REFERENCES interests(id) ON DELETE SET NULL. Mirrors the existing documents.interest_id; lets file uploads be scoped to a deal while still rolling up to the parent client folder. - idx_files_interest + idx_files_port_interest for the aggregated- projection queries that will surface "This deal" vs "From client" file lists. Service: - EntityType extended to include 'interest'. Interest folders parent under the owning client's entity folder (not at a system root), so the tree reads Clients/Acme/Deal A1-A3/ — nested. - ensureEntityFolder recursively ensures the parent client folder first when given an interest, guaranteeing the deal folder lands inside the right client subfolder even when the first artifact on the deal predates any client-level upload. - resolveEntityDisplayName for interest: "Deal — <mooringNumber>" (when a primary berth is linked) or "Deal <YYYY-MM-DD>" as the stable fallback. Dynamic-import on getPrimaryBerth dodges the circular dep between document-folders.service and interest-berths.service. Aggregated projection (files.ts): - listFilesAggregatedByEntity SELECT now includes the new interest_id column so AggregatedFileRow's structural type matches. Downstream consumers gain access to the deal scope; the actual "From this deal" subheading in InterestDocumentsTab is wired in the follow-up. Remaining work (tracked in master UAT line 728+, parked for next session): - UploadZone `scopeOptions` radio (single-option pickers hide the radio entirely for client/yacht/company surfaces). - Lifecycle hooks for interest outcome → folder rename ("Deal A1-A3 (Won)") via soft-rescue per CLAUDE.md. - listFilesAggregatedByEntity rewrite to surface "This deal" vs "From client" subheadings on InterestDocumentsTab. - Documents Hub tree rendering for nested interest folders. - backfill script: existing files with entity_type='interest' + entity_id but missing interest_id column → populate. Verified: tsc clean, vitest 1448/1448 after dev-DB migration applied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:18:40 +02:00
export type EntityType = 'client' | 'company' | 'yacht' | 'interest';
const ENTITY_TYPES = new Set<EntityType>(['client', 'company', 'yacht', 'interest']);
/**
* Returns the display name for an entity used to label its subfolder.
* Clients use `fullName` verbatim (matching rep-facing list views).
feat(documents): foundation for nested interest subfolders (phase 1/3) Sets up the schema + service primitives the rest of the nested- document-subfolders feature will build on (master UAT line 728+). This commit is INFRASTRUCTURE ONLY — the upload-zone scope radio, lifecycle hooks for outcome rename, aggregated-projection list query, and backfill script are deferred to follow-up commits. Schema (migration 0078_files_interest_id.sql): - `files.interest_id` text REFERENCES interests(id) ON DELETE SET NULL. Mirrors the existing documents.interest_id; lets file uploads be scoped to a deal while still rolling up to the parent client folder. - idx_files_interest + idx_files_port_interest for the aggregated- projection queries that will surface "This deal" vs "From client" file lists. Service: - EntityType extended to include 'interest'. Interest folders parent under the owning client's entity folder (not at a system root), so the tree reads Clients/Acme/Deal A1-A3/ — nested. - ensureEntityFolder recursively ensures the parent client folder first when given an interest, guaranteeing the deal folder lands inside the right client subfolder even when the first artifact on the deal predates any client-level upload. - resolveEntityDisplayName for interest: "Deal — <mooringNumber>" (when a primary berth is linked) or "Deal <YYYY-MM-DD>" as the stable fallback. Dynamic-import on getPrimaryBerth dodges the circular dep between document-folders.service and interest-berths.service. Aggregated projection (files.ts): - listFilesAggregatedByEntity SELECT now includes the new interest_id column so AggregatedFileRow's structural type matches. Downstream consumers gain access to the deal scope; the actual "From this deal" subheading in InterestDocumentsTab is wired in the follow-up. Remaining work (tracked in master UAT line 728+, parked for next session): - UploadZone `scopeOptions` radio (single-option pickers hide the radio entirely for client/yacht/company surfaces). - Lifecycle hooks for interest outcome → folder rename ("Deal A1-A3 (Won)") via soft-rescue per CLAUDE.md. - listFilesAggregatedByEntity rewrite to surface "This deal" vs "From client" subheadings on InterestDocumentsTab. - Documents Hub tree rendering for nested interest folders. - backfill script: existing files with entity_type='interest' + entity_id but missing interest_id column → populate. Verified: tsc clean, vitest 1448/1448 after dev-DB migration applied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:18:40 +02:00
* Companies and yachts use their `name` column verbatim. Interest
* folders are derived from their primary berth (when set) so the
* tree reads "Acme Corp / A1-A3" rather than a meaningless interest
* UUID; falls back to the createdAt date as a stable label when no
* primary berth is linked yet.
*/
async function resolveEntityDisplayName(
portId: string,
entityType: EntityType,
entityId: string,
): Promise<string> {
if (entityType === 'client') {
const c = await db.query.clients.findFirst({
where: and(eq(clients.id, entityId), eq(clients.portId, portId)),
columns: { fullName: true },
});
if (!c) throw new NotFoundError('Client');
return c.fullName;
}
if (entityType === 'company') {
const co = await db.query.companies.findFirst({
where: and(eq(companies.id, entityId), eq(companies.portId, portId)),
columns: { name: true },
});
if (!co) throw new NotFoundError('Company');
return co.name;
}
feat(documents): foundation for nested interest subfolders (phase 1/3) Sets up the schema + service primitives the rest of the nested- document-subfolders feature will build on (master UAT line 728+). This commit is INFRASTRUCTURE ONLY — the upload-zone scope radio, lifecycle hooks for outcome rename, aggregated-projection list query, and backfill script are deferred to follow-up commits. Schema (migration 0078_files_interest_id.sql): - `files.interest_id` text REFERENCES interests(id) ON DELETE SET NULL. Mirrors the existing documents.interest_id; lets file uploads be scoped to a deal while still rolling up to the parent client folder. - idx_files_interest + idx_files_port_interest for the aggregated- projection queries that will surface "This deal" vs "From client" file lists. Service: - EntityType extended to include 'interest'. Interest folders parent under the owning client's entity folder (not at a system root), so the tree reads Clients/Acme/Deal A1-A3/ — nested. - ensureEntityFolder recursively ensures the parent client folder first when given an interest, guaranteeing the deal folder lands inside the right client subfolder even when the first artifact on the deal predates any client-level upload. - resolveEntityDisplayName for interest: "Deal — <mooringNumber>" (when a primary berth is linked) or "Deal <YYYY-MM-DD>" as the stable fallback. Dynamic-import on getPrimaryBerth dodges the circular dep between document-folders.service and interest-berths.service. Aggregated projection (files.ts): - listFilesAggregatedByEntity SELECT now includes the new interest_id column so AggregatedFileRow's structural type matches. Downstream consumers gain access to the deal scope; the actual "From this deal" subheading in InterestDocumentsTab is wired in the follow-up. Remaining work (tracked in master UAT line 728+, parked for next session): - UploadZone `scopeOptions` radio (single-option pickers hide the radio entirely for client/yacht/company surfaces). - Lifecycle hooks for interest outcome → folder rename ("Deal A1-A3 (Won)") via soft-rescue per CLAUDE.md. - listFilesAggregatedByEntity rewrite to surface "This deal" vs "From client" subheadings on InterestDocumentsTab. - Documents Hub tree rendering for nested interest folders. - backfill script: existing files with entity_type='interest' + entity_id but missing interest_id column → populate. Verified: tsc clean, vitest 1448/1448 after dev-DB migration applied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:18:40 +02:00
if (entityType === 'interest') {
const i = await db.query.interests.findFirst({
where: and(eq(interests.id, entityId), eq(interests.portId, portId)),
columns: { createdAt: true },
});
if (!i) throw new NotFoundError('Interest');
// Defer to the interest-berths service for the primary berth label
// — circular-dep avoidance via dynamic import. Falls back to the
// ISO date slice ("Deal 2026-05-12") when no berth is linked yet.
const { getPrimaryBerth } = await import('@/lib/services/interest-berths.service');
const primary = await getPrimaryBerth(entityId).catch(() => null);
if (primary?.mooringNumber) return `Deal — ${primary.mooringNumber}`;
const dateSlice = i.createdAt.toISOString().slice(0, 10);
return `Deal ${dateSlice}`;
}
// yacht
const y = await db.query.yachts.findFirst({
where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)),
columns: { name: true },
});
if (!y) throw new NotFoundError('Yacht');
return y.name;
}
/**
* Returns true if the error is a Postgres unique-violation (SQLSTATE 23505)
* on the per-entity unique index (`uniq_document_folders_entity`). Narrowing
* to this specific constraint lets callers distinguish a concurrent-winner
* race (same entity_id inserted twice) from a sibling-name collision.
*/
function isEntityFolderConflict(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const e = err as {
code?: unknown;
constraint_name?: unknown;
constraint?: unknown;
cause?: { code?: unknown; constraint_name?: unknown; constraint?: unknown };
};
const code = e.code ?? e.cause?.code;
if (code !== '23505') return false;
const constraint =
e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint;
return constraint === 'uniq_document_folders_entity';
}
/**
* Idempotently create the per-entity subfolder under the matching system
* root (`Clients/` / `Companies/` / `Yachts/`). Returns the folder row
* regardless of whether it was newly created or already existed.
*
* Concurrent callers race safely via the partial unique index
* `uniq_document_folders_entity` the loser INSERT errors and the
* re-SELECT returns the winner's row.
*
* On sibling-name collision (two entities want the same display name),
* appends a numeric suffix `(2)`, `(3)`, , until the insert succeeds.
* The `system_managed` flag stays true on the suffixed folder.
*
* Note: this helper deliberately does NOT use the no-args
* onConflictDoNothing() pattern from ensureSystemRoots. Entity inserts
* can conflict on EITHER uniq_document_folders_entity (race winner
* already inserted the same entity_id) OR uniq_document_folders_sibling_name
* (different entity, same display name). We need to discriminate so we
* can retry-with-suffix on the name collision and re-SELECT-and-return
* on the entity-id collision.
*/
export async function ensureEntityFolder(
portId: string,
entityType: EntityType,
entityId: string,
userId: string,
): Promise<DocumentFolder> {
if (!ENTITY_TYPES.has(entityType)) {
throw new ValidationError(`Unknown entity type: ${entityType}`);
}
// Fast path: row already exists.
const existing = await db.query.documentFolders.findFirst({
where: and(
eq(documentFolders.portId, portId),
eq(documentFolders.entityType, entityType),
eq(documentFolders.entityId, entityId),
),
});
if (existing) return existing;
feat(documents): foundation for nested interest subfolders (phase 1/3) Sets up the schema + service primitives the rest of the nested- document-subfolders feature will build on (master UAT line 728+). This commit is INFRASTRUCTURE ONLY — the upload-zone scope radio, lifecycle hooks for outcome rename, aggregated-projection list query, and backfill script are deferred to follow-up commits. Schema (migration 0078_files_interest_id.sql): - `files.interest_id` text REFERENCES interests(id) ON DELETE SET NULL. Mirrors the existing documents.interest_id; lets file uploads be scoped to a deal while still rolling up to the parent client folder. - idx_files_interest + idx_files_port_interest for the aggregated- projection queries that will surface "This deal" vs "From client" file lists. Service: - EntityType extended to include 'interest'. Interest folders parent under the owning client's entity folder (not at a system root), so the tree reads Clients/Acme/Deal A1-A3/ — nested. - ensureEntityFolder recursively ensures the parent client folder first when given an interest, guaranteeing the deal folder lands inside the right client subfolder even when the first artifact on the deal predates any client-level upload. - resolveEntityDisplayName for interest: "Deal — <mooringNumber>" (when a primary berth is linked) or "Deal <YYYY-MM-DD>" as the stable fallback. Dynamic-import on getPrimaryBerth dodges the circular dep between document-folders.service and interest-berths.service. Aggregated projection (files.ts): - listFilesAggregatedByEntity SELECT now includes the new interest_id column so AggregatedFileRow's structural type matches. Downstream consumers gain access to the deal scope; the actual "From this deal" subheading in InterestDocumentsTab is wired in the follow-up. Remaining work (tracked in master UAT line 728+, parked for next session): - UploadZone `scopeOptions` radio (single-option pickers hide the radio entirely for client/yacht/company surfaces). - Lifecycle hooks for interest outcome → folder rename ("Deal A1-A3 (Won)") via soft-rescue per CLAUDE.md. - listFilesAggregatedByEntity rewrite to surface "This deal" vs "From client" subheadings on InterestDocumentsTab. - Documents Hub tree rendering for nested interest folders. - backfill script: existing files with entity_type='interest' + entity_id but missing interest_id column → populate. Verified: tsc clean, vitest 1448/1448 after dev-DB migration applied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:18:40 +02:00
// Resolve the parent folder. For client / company / yacht this is the
// matching system root. For interest, parent is the owning client's
// entity folder so the tree renders nested: Clients/Acme/Deal A1-A3/.
let parent: DocumentFolder | undefined;
if (entityType === 'interest') {
const interestRow = await db.query.interests.findFirst({
where: and(eq(interests.id, entityId), eq(interests.portId, portId)),
columns: { clientId: true },
});
if (!interestRow) throw new NotFoundError('Interest');
// Recursively ensure the parent client's folder first — guarantees
// we always land inside the existing Clients/<Name>/ subfolder even
// when the deal's first artifact predates any client-level upload.
parent = await ensureEntityFolder(portId, 'client', interestRow.clientId, userId);
} else {
const rootName: SystemRootName =
entityType === 'client' ? 'Clients' : entityType === 'company' ? 'Companies' : 'Yachts';
parent = await db.query.documentFolders.findFirst({
where: and(
eq(documentFolders.portId, portId),
eq(documentFolders.entityType, 'root'),
eq(documentFolders.name, rootName),
),
});
if (!parent) {
// Self-heal: the port-init hook may have been skipped (legacy port).
await ensureSystemRoots(portId, userId);
return ensureEntityFolder(portId, entityType, entityId, userId);
}
}
feat(documents): foundation for nested interest subfolders (phase 1/3) Sets up the schema + service primitives the rest of the nested- document-subfolders feature will build on (master UAT line 728+). This commit is INFRASTRUCTURE ONLY — the upload-zone scope radio, lifecycle hooks for outcome rename, aggregated-projection list query, and backfill script are deferred to follow-up commits. Schema (migration 0078_files_interest_id.sql): - `files.interest_id` text REFERENCES interests(id) ON DELETE SET NULL. Mirrors the existing documents.interest_id; lets file uploads be scoped to a deal while still rolling up to the parent client folder. - idx_files_interest + idx_files_port_interest for the aggregated- projection queries that will surface "This deal" vs "From client" file lists. Service: - EntityType extended to include 'interest'. Interest folders parent under the owning client's entity folder (not at a system root), so the tree reads Clients/Acme/Deal A1-A3/ — nested. - ensureEntityFolder recursively ensures the parent client folder first when given an interest, guaranteeing the deal folder lands inside the right client subfolder even when the first artifact on the deal predates any client-level upload. - resolveEntityDisplayName for interest: "Deal — <mooringNumber>" (when a primary berth is linked) or "Deal <YYYY-MM-DD>" as the stable fallback. Dynamic-import on getPrimaryBerth dodges the circular dep between document-folders.service and interest-berths.service. Aggregated projection (files.ts): - listFilesAggregatedByEntity SELECT now includes the new interest_id column so AggregatedFileRow's structural type matches. Downstream consumers gain access to the deal scope; the actual "From this deal" subheading in InterestDocumentsTab is wired in the follow-up. Remaining work (tracked in master UAT line 728+, parked for next session): - UploadZone `scopeOptions` radio (single-option pickers hide the radio entirely for client/yacht/company surfaces). - Lifecycle hooks for interest outcome → folder rename ("Deal A1-A3 (Won)") via soft-rescue per CLAUDE.md. - listFilesAggregatedByEntity rewrite to surface "This deal" vs "From client" subheadings on InterestDocumentsTab. - Documents Hub tree rendering for nested interest folders. - backfill script: existing files with entity_type='interest' + entity_id but missing interest_id column → populate. Verified: tsc clean, vitest 1448/1448 after dev-DB migration applied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:18:40 +02:00
const root = parent;
const baseName = await resolveEntityDisplayName(portId, entityType, entityId);
// Try the base name first; on sibling-name collision, append (2), (3)...
for (let attempt = 0; attempt < 50; attempt += 1) {
const candidate = attempt === 0 ? baseName : `${baseName} (${attempt + 1})`;
try {
const [row] = await db
.insert(documentFolders)
.values({
portId,
parentId: root.id,
name: candidate,
systemManaged: true,
entityType,
entityId,
createdBy: userId,
})
.returning();
if (!row) throw new Error('ensureEntityFolder: insert returned no row');
return row;
} catch (err) {
// If another caller won the entity-id race, re-SELECT and return their row.
if (isEntityFolderConflict(err)) {
const winner = await db.query.documentFolders.findFirst({
where: and(
eq(documentFolders.portId, portId),
eq(documentFolders.entityType, entityType),
eq(documentFolders.entityId, entityId),
),
});
if (winner) return winner;
}
// Sibling-name collision (different entity, same name) → bump suffix and retry.
if (isSiblingNameConflict(err)) continue;
throw err;
}
}
logger.warn(
{ portId, entityType, entityId, baseName, attempts: 50 },
'ensureEntityFolder: exhausted 50 suffix attempts without finding a unique name',
);
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
}
/**
* Rename the per-entity subfolder to match the entity's current display
* name. Called from the entity rename services (`updateClient`,
* `updateCompany`, `updateYacht`). No-op when the folder does not exist
* (lazy creation entities without a folder skip the sync entirely).
*
* Sibling-name collision is resolved by suffix bump (matches
* `ensureEntityFolder` semantics).
*
* Intentionally does NOT call `assertNotSystemManaged` this helper
* is the legitimate path for renaming a system folder.
*/
export async function syncEntityFolderName(
portId: string,
entityType: EntityType,
entityId: string,
userId: string,
): Promise<void> {
if (!ENTITY_TYPES.has(entityType)) return;
const folder = await db.query.documentFolders.findFirst({
where: and(
eq(documentFolders.portId, portId),
eq(documentFolders.entityType, entityType),
eq(documentFolders.entityId, entityId),
),
});
if (!folder) return; // Lazy creation — nothing to sync yet.
// Preserve archived suffix if present.
const isArchived = folder.name.endsWith(' (archived)');
const isDeleted = folder.name.endsWith(' (deleted)');
if (isDeleted) return; // Demoted; rep owns the name now.
const baseName = await resolveEntityDisplayName(portId, entityType, entityId);
const targetSuffix = isArchived ? ' (archived)' : '';
for (let attempt = 0; attempt < 50; attempt += 1) {
const candidate =
attempt === 0 ? `${baseName}${targetSuffix}` : `${baseName} (${attempt + 1})${targetSuffix}`;
if (candidate === folder.name) return; // No-op rename.
try {
const [updated] = await db
.update(documentFolders)
.set({ name: candidate, updatedAt: new Date() })
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)))
.returning();
if (updated) {
void createAuditLog({
userId,
portId,
action: 'update',
entityType: 'document_folder',
entityId: folder.id,
oldValue: { name: folder.name },
newValue: { name: candidate },
deps: bump Tier-A patches + react-day-picker 10 + esbuild 0.28 Successfully bumped: - bullmq 5.76.6 → 5.76.8 - @tanstack/react-query 5.100.9 → 5.100.10 - @tanstack/react-query-devtools 5.100.9 → 5.100.10 - better-auth 1.6.9 → 1.6.10 - @playwright/test 1.59.1 → 1.60.0 - libphonenumber-js 1.12.43 → 1.13.1 - tailwind-merge 3.5.0 → 3.6.0 - vitest 4.1.5 → 4.1.6 - @vitest/coverage-v8 4.1.5 → 4.1.6 - lint-staged 17.0.3 → 17.0.4 - esbuild 0.27.7 → 0.28.0 - react-grab 0.1.33 → 0.1.34 - react-day-picker 9.14.0 → 10.0.0 react-day-picker 10 verified safe: probed v10 release notes against src/components/ui/calendar.tsx — we use only v9-canonical APIs that v10 preserves. Removed the `table` className entry from the wrapper (v10 dropped it since the renderer is now CSS-grid, not table-based). Tried + rolled back: - @hookform/resolvers 3 → 5: stricter input/output inference broke every form using <{schema}, any, {schema}> implicit shape. Needs per-form refactor; parked. Verified clean: pnpm audit (prod + dev) = 0 vulnerabilities; pnpm exec tsc --noEmit clean; vitest 1293/1293 pass. Remaining outdated (deliberately deferred — see docs/AUDIT-2026-05-12.md §34): - next/eslint-config-next 15 → 16 (2-4 wk wait) - zod 3 → 4 (couple with @hookform/resolvers 5; codemod-needed) - tailwindcss 3 → 4 (focused-afternoon project) - @types/node ^20.19 stays pinned to match runtime (audit decision) - archiver 7 stays (no @types/archiver@8 published) - eslint 9 stays (locked to eslint-config-next 15) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:33:24 +02:00
metadata: {
type: 'folder_entity_rename_sync',
entity: entityType,
sourceEntityId: entityId,
},
});
return;
}
} catch (err) {
if (isSiblingNameConflict(err)) continue;
throw err;
}
}
logger.warn(
{ portId, entityType, entityId, baseName, attempts: 50 },
'syncEntityFolderName: exhausted 50 suffix attempts without finding a unique name',
);
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
}
// ─── Archive / Restore / Demote helpers ──────────────────────────────────────
const ARCHIVED_SUFFIX = ' (archived)';
const DELETED_SUFFIX = ' (deleted)';
/**
* Stamp an entity's subfolder as archived: append " (archived)" to the
* name (idempotent won't double-append) and set archived_at. No-op
* when the folder does not exist (lazy creation). Used by the entity
* archive paths in clients / companies / yachts services.
*/
export async function applyEntityArchivedSuffix(
portId: string,
entityType: EntityType,
entityId: string,
userId?: string,
): Promise<void> {
if (!ENTITY_TYPES.has(entityType)) return;
const folder = await db.query.documentFolders.findFirst({
where: and(
eq(documentFolders.portId, portId),
eq(documentFolders.entityType, entityType),
eq(documentFolders.entityId, entityId),
),
});
if (!folder) return;
const newName = folder.name.endsWith(ARCHIVED_SUFFIX)
? folder.name
: `${folder.name}${ARCHIVED_SUFFIX}`;
if (newName === folder.name && folder.archivedAt) return; // Already archived, no-op.
await db
.update(documentFolders)
.set({ name: newName, archivedAt: new Date(), updatedAt: new Date() })
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));
void createAuditLog({
userId: userId ?? 'system',
portId,
action: 'archive',
entityType: 'document_folder',
entityId: folder.id,
oldValue: { name: folder.name, archivedAt: folder.archivedAt },
newValue: { name: newName, archivedAt: new Date() },
metadata: { type: 'folder_entity_archived', entity: entityType, sourceEntityId: entityId },
});
}
/**
* Inverse of `applyEntityArchivedSuffix` strip " (archived)" from
* the name and clear archived_at. No-op when the folder does not
* exist or wasn't archived.
*/
export async function applyEntityRestoredSuffix(
portId: string,
entityType: EntityType,
entityId: string,
userId?: string,
): Promise<void> {
if (!ENTITY_TYPES.has(entityType)) return;
const folder = await db.query.documentFolders.findFirst({
where: and(
eq(documentFolders.portId, portId),
eq(documentFolders.entityType, entityType),
eq(documentFolders.entityId, entityId),
),
});
if (!folder) return;
const newName = folder.name.endsWith(ARCHIVED_SUFFIX)
? folder.name.slice(0, -ARCHIVED_SUFFIX.length)
: folder.name;
if (newName === folder.name && !folder.archivedAt) return; // Wasn't archived, no-op.
await db
.update(documentFolders)
.set({ name: newName, archivedAt: null, updatedAt: new Date() })
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));
void createAuditLog({
userId: userId ?? 'system',
portId,
action: 'restore',
entityType: 'document_folder',
entityId: folder.id,
oldValue: { name: folder.name, archivedAt: folder.archivedAt },
newValue: { name: newName, archivedAt: null },
metadata: { type: 'folder_entity_restored', entity: entityType, sourceEntityId: entityId },
});
}
/**
* Entity has been hard-deleted: demote the folder to a regular user
* folder by clearing `system_managed`, appending " (deleted)" to the
* name, and dropping the entity FK so the partial unique index no
* longer constrains it. Files still inside the folder retain their
* snapshotted entity FKs (orphaned they appear in the root-view
* Files section once the rep cleans up).
*
* Idempotent: re-demoting an already-demoted folder is a no-op because
* the entityType + entityId FK is cleared on first demotion.
*/
export async function demoteSystemFolderOnEntityDelete(
portId: string,
entityType: EntityType,
entityId: string,
): Promise<void> {
if (!ENTITY_TYPES.has(entityType)) return;
const folder = await db.query.documentFolders.findFirst({
where: and(
eq(documentFolders.portId, portId),
eq(documentFolders.entityType, entityType),
eq(documentFolders.entityId, entityId),
),
});
if (!folder) return;
const stripped = folder.name.endsWith(ARCHIVED_SUFFIX)
? folder.name.slice(0, -ARCHIVED_SUFFIX.length)
: folder.name;
const newName = stripped.endsWith(DELETED_SUFFIX) ? stripped : `${stripped}${DELETED_SUFFIX}`;
await db
.update(documentFolders)
.set({
name: newName,
systemManaged: false,
entityType: null,
entityId: null,
archivedAt: null,
updatedAt: new Date(),
})
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));
}
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
/**
* 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)));
}