import { and, asc, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { documentFolders, documents, 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'; import { createAuditLog } from '@/lib/audit'; import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; /** * 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 { 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 { const rows = await db .select() .from(documentFolders) .where(eq(documentFolders.portId, portId)) .orderBy(asc(documentFolders.name)); const byId = new Map(); 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); // 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. } } 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 { 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'); 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 { 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 { if (newParentId === folderId) { throw new ValidationError('Cannot move a folder under itself (cycle)'); } const folder = await assertNotSystemManaged(portId, folderId, 'move'); if (newParentId !== null) { const newParent = await db.query.documentFolders.findFirst({ 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([newParent.id]); while (cursor) { if (cursor === folderId) { 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 db.query.documentFolders.findFirst({ where: and(eq(documentFolders.id, cursor), eq(documentFolders.portId, portId)), columns: { parentId: true }, }); cursor = next?.parentId ?? null; } } try { const [updated] = await db .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 { 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))); 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 { 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) throw new Error(`ensureSystemRoots: missing root ${name} after upsert`); return row; }); } // ─── ensureEntityFolder ────────────────────────────────────────────────────── export type EntityType = 'client' | 'company' | 'yacht'; const ENTITY_TYPES = new Set(['client', 'company', 'yacht']); /** * Returns the display name for an entity used to label its subfolder. * Clients use `fullName` verbatim (matching rep-facing list views). * Companies and yachts use their `name` column verbatim. */ async function resolveEntityDisplayName( portId: string, entityType: EntityType, entityId: string, ): Promise { 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; } // 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 { 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; // Locate the system root for this entity type. const rootName: SystemRootName = entityType === 'client' ? 'Clients' : entityType === 'company' ? 'Companies' : 'Yachts'; const root = await db.query.documentFolders.findFirst({ where: and( eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root'), eq(documentFolders.name, rootName), ), }); if (!root) { // Self-heal: the port-init hook may have been skipped (legacy port). await ensureSystemRoots(portId, userId); return ensureEntityFolder(portId, entityType, entityId, userId); } 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; } } 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 { 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) return; } catch (err) { if (isSiblingNameConflict(err)) continue; throw err; } } 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, ): Promise { 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}`; await db .update(documentFolders) .set({ name: newName, archivedAt: new Date(), updatedAt: new Date() }) .where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId))); } /** * 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, ): Promise { 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; await db .update(documentFolders) .set({ name: newName, archivedAt: null, updatedAt: new Date() }) .where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId))); } /** * 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 { 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))); }