fix(folders): logging, files-rescue, hard-delete wiring, audit logs

- A6: logger import + warn calls in document-folders.service.ts
- G-C1: re-parent files (not just documents) in deleteFolderSoftRescue
- A4: importer sets files.folder_id (was only setting documents.folder_id)
- A7 + G-C3: demote system folder + nullify scratchpadNotes in client-hard-delete
- Defense-in-depth portId on folder-move UPDATE
- Audit logs for createFolder, syncEntityFolderName, archive/restore suffix
- portId in companies/yachts archive log context
- Row-count telemetry in backfill CLI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 13:57:42 +02:00
parent b5ebed9c36
commit 955911302b
8 changed files with 213 additions and 25 deletions

View File

@@ -1,12 +1,18 @@
import { and, asc, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentFolders, documents, type DocumentFolder } from '@/lib/db/schema/documents';
import {
documentFolders,
documents,
files,
type DocumentFolder,
} from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { companies } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { createAuditLog } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
/**
* Returns true if the error is a Postgres unique-violation (SQLSTATE 23505)
@@ -78,10 +84,17 @@ export async function listTree(portId: string): Promise<FolderNode[]> {
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.
if (parent) {
parent.children.push(node);
} else {
// 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.
logger.warn(
{ portId, folderId: node.id, parentId: node.parentId, name: node.name },
'listTree: orphan folder row (parentId points to a missing folder); dropped from tree',
);
}
}
}
return roots;
@@ -126,6 +139,17 @@ export async function createFolder(
})
.returning();
if (!row) throw new NotFoundError('Folder');
void createAuditLog({
userId,
portId,
action: 'create',
entityType: 'document_folder',
entityId: row.id,
newValue: { name: row.name, parentId: row.parentId },
metadata: { type: 'folder_created' },
});
return row;
} catch (err) {
if (isSiblingNameConflict(err)) {
@@ -276,6 +300,15 @@ export async function deleteFolderSoftRescue(
.set({ folderId: newParent })
.where(and(eq(documents.folderId, folderId), eq(documents.portId, portId)));
// G-C1: files.folder_id is ON DELETE SET NULL — without this UPDATE,
// files in the deleted folder would scatter to root while documents
// in the same folder land at the deleted folder's parent. Re-parent
// files explicitly so the soft-rescue is symmetric across both.
await tx
.update(files)
.set({ folderId: newParent })
.where(and(eq(files.folderId, folderId), eq(files.portId, portId)));
await tx
.delete(documentFolders)
.where(and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)));
@@ -353,7 +386,13 @@ export async function ensureSystemRoots(portId: string, userId: string): Promise
return SYSTEM_ROOT_NAMES.map((name: SystemRootName) => {
const row = rows.find((r) => r.name === name);
if (!row) throw new Error(`ensureSystemRoots: missing root ${name} after upsert`);
if (!row) {
logger.error(
{ portId, missingRoot: name, foundNames: rows.map((r) => r.name) },
'ensureSystemRoots: invariant violated — system root missing after upsert',
);
throw new Error(`ensureSystemRoots: missing root ${name} after upsert`);
}
return row;
});
}
@@ -513,6 +552,10 @@ export async function ensureEntityFolder(
throw err;
}
}
logger.warn(
{ portId, entityType, entityId, baseName, attempts: 50 },
'ensureEntityFolder: exhausted 50 suffix attempts without finding a unique name',
);
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
}
@@ -532,7 +575,7 @@ export async function syncEntityFolderName(
portId: string,
entityType: EntityType,
entityId: string,
_userId: string,
userId: string,
): Promise<void> {
if (!ENTITY_TYPES.has(entityType)) return;
@@ -563,12 +606,28 @@ export async function syncEntityFolderName(
.set({ name: candidate, updatedAt: new Date() })
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)))
.returning();
if (updated) return;
if (updated) {
void createAuditLog({
userId,
portId,
action: 'update',
entityType: 'document_folder',
entityId: folder.id,
oldValue: { name: folder.name },
newValue: { name: candidate },
metadata: { type: 'folder_entity_rename_sync', entity: entityType, sourceEntityId: entityId },
});
return;
}
} catch (err) {
if (isSiblingNameConflict(err)) continue;
throw err;
}
}
logger.warn(
{ portId, entityType, entityId, baseName, attempts: 50 },
'syncEntityFolderName: exhausted 50 suffix attempts without finding a unique name',
);
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
}
@@ -587,6 +646,7 @@ export async function applyEntityArchivedSuffix(
portId: string,
entityType: EntityType,
entityId: string,
userId?: string,
): Promise<void> {
if (!ENTITY_TYPES.has(entityType)) return;
const folder = await db.query.documentFolders.findFirst({
@@ -605,6 +665,17 @@ export async function applyEntityArchivedSuffix(
.update(documentFolders)
.set({ name: newName, archivedAt: new Date(), updatedAt: new Date() })
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));
void createAuditLog({
userId: userId ?? 'system',
portId,
action: 'archive',
entityType: 'document_folder',
entityId: folder.id,
oldValue: { name: folder.name, archivedAt: folder.archivedAt },
newValue: { name: newName, archivedAt: new Date() },
metadata: { type: 'folder_entity_archived', entity: entityType, sourceEntityId: entityId },
});
}
/**
@@ -616,6 +687,7 @@ export async function applyEntityRestoredSuffix(
portId: string,
entityType: EntityType,
entityId: string,
userId?: string,
): Promise<void> {
if (!ENTITY_TYPES.has(entityType)) return;
const folder = await db.query.documentFolders.findFirst({
@@ -634,6 +706,17 @@ export async function applyEntityRestoredSuffix(
.update(documentFolders)
.set({ name: newName, archivedAt: null, updatedAt: new Date() })
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));
void createAuditLog({
userId: userId ?? 'system',
portId,
action: 'restore',
entityType: 'document_folder',
entityId: folder.id,
oldValue: { name: folder.name, archivedAt: folder.archivedAt },
newValue: { name: newName, archivedAt: null },
metadata: { type: 'folder_entity_restored', entity: entityType, sourceEntityId: entityId },
});
}
/**