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

113 lines
3.8 KiB
TypeScript
Raw Normal View History

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<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);
// 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<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');
return row;
} catch (err) {
if (isSiblingNameConflict(err)) {
throw new ConflictError(`A folder named "${trimmed}" already exists here`);
}
throw err;
}
}