fix(documents): folder service · audit + portId + audit-log placement

Code-review followups on e9251a3:
- Move createAuditLog OUT of the deleteFolderSoftRescue transaction
  callback so a rolled-back transaction can't leave a phantom audit
  row. Pattern matches clients.service.ts, expense-dedup.service.ts.
- Add portId filter to the moveFolder ancestor-walk findFirst —
  defense-in-depth so corrupted parentId pointing at another port
  short-circuits the walk instead of silently traversing it.
- Drop updatedAt bump on rescued documents — folder rescue is an
  administrative storage op, not a content change; bumping made
  every rescued doc appear "recently modified" in list views.
- Add userId param + audit-log emission on renameFolder and
  moveFolder for parity with createFolder + deleteFolderSoftRescue.
  Tests updated to pass TEST_USER_ID as the new 4th arg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 19:50:51 +02:00
parent 9f3e739c76
commit 4ec0004867
2 changed files with 51 additions and 31 deletions

View File

@@ -121,6 +121,7 @@ export async function renameFolder(
portId: string,
folderId: string,
newName: string,
userId: string,
): Promise<DocumentFolder> {
const trimmed = newName.trim();
if (!trimmed) throw new ValidationError('Folder name cannot be empty');
@@ -138,6 +139,17 @@ export async function renameFolder(
.where(eq(documentFolders.id, folderId))
.returning();
if (!updated) throw new NotFoundError('Folder');
void createAuditLog({
userId,
portId,
action: 'update',
entityType: 'document_folder',
entityId: folderId,
oldValue: { name: existing.name },
newValue: { name: trimmed },
});
return updated;
} catch (err) {
if (isSiblingNameConflict(err)) {
@@ -157,6 +169,7 @@ export async function moveFolder(
portId: string,
folderId: string,
newParentId: string | null,
userId: string,
): Promise<DocumentFolder> {
if (newParentId === folderId) {
throw new ValidationError('Cannot move a folder under itself (cycle)');
@@ -185,7 +198,7 @@ export async function moveFolder(
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),
where: and(eq(documentFolders.id, cursor), eq(documentFolders.portId, portId)),
columns: { parentId: true },
});
cursor = next?.parentId ?? null;
@@ -199,6 +212,18 @@ export async function moveFolder(
.where(eq(documentFolders.id, folderId))
.returning();
if (!updated) throw new NotFoundError('Folder');
void createAuditLog({
userId,
portId,
action: 'update',
entityType: 'document_folder',
entityId: folderId,
oldValue: { parentId: folder.parentId },
newValue: { parentId: newParentId },
metadata: { type: 'folder_move' },
});
return updated;
} catch (err) {
if (isSiblingNameConflict(err)) {
@@ -219,39 +244,34 @@ export async function deleteFolderSoftRescue(
folderId: string,
userId: string,
): Promise<void> {
const folder = await db.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 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),
),
);
.where(and(eq(documentFolders.parentId, folderId), eq(documentFolders.portId, portId)));
await tx
.update(documents)
.set({ folderId: newParent, updatedAt: new Date() })
.set({ folderId: newParent })
.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 },
});
void createAuditLog({
userId,
portId,
action: 'delete',
entityType: 'document_folder',
entityId: folderId,
oldValue: { name: folder.name, parentId: folder.parentId },
metadata: { rescuedTo: newParent },
});
}