import { and, asc, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { documentFolders, documents, type DocumentFolder } from '@/lib/db/schema/documents'; 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'; } 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, ): 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 db.query.documentFolders.findFirst({ where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)), }); if (!existing) throw new NotFoundError('Folder'); try { const [updated] = await db .update(documentFolders) .set({ name: trimmed, updatedAt: new Date() }) .where(eq(documentFolders.id, folderId)) .returning(); if (!updated) throw new NotFoundError('Folder'); 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, ): Promise { if (newParentId === folderId) { throw new ValidationError('Cannot move a folder under itself (cycle)'); } const folder = await db.query.documentFolders.findFirst({ where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)), }); if (!folder) throw new NotFoundError('Folder'); 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: eq(documentFolders.id, cursor), columns: { parentId: true }, }); cursor = next?.parentId ?? null; } } try { const [updated] = await db .update(documentFolders) .set({ parentId: newParentId, updatedAt: new Date() }) .where(eq(documentFolders.id, folderId)) .returning(); if (!updated) throw new NotFoundError('Folder'); 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 { await db.transaction(async (tx) => { const folder = await tx.query.documentFolders.findFirst({ where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)), }); if (!folder) throw new NotFoundError('Folder'); const newParent = folder.parentId; // null = re-parent to root 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, updatedAt: new Date() }) .where(and(eq(documents.folderId, folderId), eq(documents.portId, portId))); await tx.delete(documentFolders).where(eq(documentFolders.id, folderId)); void createAuditLog({ userId, portId, action: 'delete', entityType: 'document_folder', entityId: folderId, oldValue: { name: folder.name, parentId: folder.parentId }, metadata: { rescuedTo: newParent }, }); }); }