import { and, asc, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { documentFolders, type DocumentFolder } from '@/lib/db/schema/documents'; 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; } }