feat(documents): folder service · rename + move + soft-rescue delete
renameFolder + moveFolder enforce sibling-name uniqueness via the shared isSiblingNameConflict helper and reject cross-port leakage at the service boundary. moveFolder walks the destination's ancestor chain to refuse cycles before the write. deleteFolderSoftRescue re-parents every child folder and document up to the deleted folder's parent (or to root) inside a transaction, then drops the folder row. Children never disappear silently — a wrong click moves work up the tree, never deletes it. Audit-logged with rescuedTo metadata. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { documentFolders, type DocumentFolder } from '@/lib/db/schema/documents';
|
||||
import { documentFolders, documents, type DocumentFolder } from '@/lib/db/schema/documents';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
|
||||
/**
|
||||
@@ -110,3 +111,147 @@ export async function createFolder(
|
||||
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<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');
|
||||
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<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({
|
||||
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<void> {
|
||||
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 },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user