2026-05-09 19:30:56 +02:00
|
|
|
import { and, asc, eq } from 'drizzle-orm';
|
|
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
2026-05-09 19:41:25 +02:00
|
|
|
import { documentFolders, documents, type DocumentFolder } from '@/lib/db/schema/documents';
|
|
|
|
|
import { createAuditLog } from '@/lib/audit';
|
2026-05-09 19:30:56 +02:00
|
|
|
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)),
|
|
|
|
|
});
|
2026-05-09 19:36:31 +02:00
|
|
|
if (!parent) throw new ValidationError('Invalid parent folder');
|
2026-05-09 19:30:56 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-09 19:41:25 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
2026-05-09 19:50:51 +02:00
|
|
|
userId: string,
|
2026-05-09 19:41:25 +02:00
|
|
|
): 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 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');
|
2026-05-09 19:50:51 +02:00
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'document_folder',
|
|
|
|
|
entityId: folderId,
|
|
|
|
|
oldValue: { name: existing.name },
|
|
|
|
|
newValue: { name: trimmed },
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 19:41:25 +02:00
|
|
|
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,
|
2026-05-09 19:50:51 +02:00
|
|
|
userId: string,
|
2026-05-09 19:41:25 +02:00
|
|
|
): Promise<DocumentFolder> {
|
|
|
|
|
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<string>([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({
|
2026-05-09 19:50:51 +02:00
|
|
|
where: and(eq(documentFolders.id, cursor), eq(documentFolders.portId, portId)),
|
2026-05-09 19:41:25 +02:00
|
|
|
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');
|
2026-05-09 19:50:51 +02:00
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'document_folder',
|
|
|
|
|
entityId: folderId,
|
|
|
|
|
oldValue: { parentId: folder.parentId },
|
|
|
|
|
newValue: { parentId: newParentId },
|
|
|
|
|
metadata: { type: 'folder_move' },
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 19:41:25 +02:00
|
|
|
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> {
|
2026-05-09 19:50:51 +02:00
|
|
|
const folder = await db.query.documentFolders.findFirst({
|
|
|
|
|
where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!folder) throw new NotFoundError('Folder');
|
2026-05-09 19:41:25 +02:00
|
|
|
|
2026-05-09 19:50:51 +02:00
|
|
|
const newParent = folder.parentId; // null = re-parent to root
|
2026-05-09 19:41:25 +02:00
|
|
|
|
2026-05-09 19:50:51 +02:00
|
|
|
await db.transaction(async (tx) => {
|
2026-05-09 19:41:25 +02:00
|
|
|
await tx
|
|
|
|
|
.update(documentFolders)
|
|
|
|
|
.set({ parentId: newParent, updatedAt: new Date() })
|
2026-05-09 19:50:51 +02:00
|
|
|
.where(and(eq(documentFolders.parentId, folderId), eq(documentFolders.portId, portId)));
|
2026-05-09 19:41:25 +02:00
|
|
|
|
|
|
|
|
await tx
|
|
|
|
|
.update(documents)
|
2026-05-09 19:50:51 +02:00
|
|
|
.set({ folderId: newParent })
|
2026-05-09 19:41:25 +02:00
|
|
|
.where(and(eq(documents.folderId, folderId), eq(documents.portId, portId)));
|
|
|
|
|
|
|
|
|
|
await tx.delete(documentFolders).where(eq(documentFolders.id, folderId));
|
2026-05-09 19:50:51 +02:00
|
|
|
});
|
2026-05-09 19:41:25 +02:00
|
|
|
|
2026-05-09 19:50:51 +02:00
|
|
|
void createAuditLog({
|
|
|
|
|
userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'delete',
|
|
|
|
|
entityType: 'document_folder',
|
|
|
|
|
entityId: folderId,
|
|
|
|
|
oldValue: { name: folder.name, parentId: folder.parentId },
|
|
|
|
|
metadata: { rescuedTo: newParent },
|
2026-05-09 19:41:25 +02:00
|
|
|
});
|
|
|
|
|
}
|
2026-05-09 20:03:25 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|