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:
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user