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

@@ -34,18 +34,43 @@ export interface BackfillOptions {
systemUserId?: string;
}
/**
* Per-port counters surfaced through the return value so the CLI can
* print them and operators (or follow-up scripts) can sanity-check that
* a re-run shrinks each number toward zero.
*/
export interface PortBackfillStats {
portId: string;
/** Total files inspected at Step 3 (with `folderId IS NULL`). */
filesProcessed: number;
/** Files updated with `folder_id` set in Step 3. */
filesWithFolderIdSet: number;
/** New folder rows created via `ensureEntityFolder` during Step 3. */
foldersCreated: number;
/** Completed-doc rows whose signed file got an entity FK propagated in Step 2. */
fksPropagated: number;
}
/**
* One-time idempotent backfill. See module-level JSDoc for full
* description of what each step does.
*/
export async function runBackfill(opts: BackfillOptions = {}): Promise<void> {
export async function runBackfill(opts: BackfillOptions = {}): Promise<PortBackfillStats[]> {
const portRows = opts.portId
? [{ id: opts.portId }]
: await db.select({ id: ports.id }).from(ports);
const systemUser = opts.systemUserId ?? 'system-backfill';
const allStats: PortBackfillStats[] = [];
for (const { id: portId } of portRows) {
const stats: PortBackfillStats = {
portId,
filesProcessed: 0,
filesWithFolderIdSet: 0,
foldersCreated: 0,
fksPropagated: 0,
};
await db.transaction(async (tx) => {
// Serialize concurrent runs on a per-port lock so two simultaneous
// backfills can't race on folder inserts.
@@ -102,12 +127,14 @@ export async function runBackfill(opts: BackfillOptions = {}): Promise<void> {
? files.companyId
: files.yachtId;
await tx
const propagated = await tx
.update(files)
.set(update)
.where(
and(eq(files.id, d.signedFileId), eq(files.portId, portId), isNull(matchingFkColumn)),
);
)
.returning({ id: files.id });
stats.fksPropagated += propagated.length;
}
// ── Step 3: For every file with entity FKs but no folder_id,
@@ -116,7 +143,10 @@ export async function runBackfill(opts: BackfillOptions = {}): Promise<void> {
.select()
.from(files)
.where(and(eq(files.portId, portId), isNull(files.folderId)));
stats.filesProcessed = fileRows.length;
const folderIdsCreatedThisRun = new Set<string>();
const folderIdsSeenThisRun = new Set<string>();
for (const f of fileRows) {
const owner: { type: EntityType; id: string } | null = f.clientId
? { type: 'client', id: f.clientId }
@@ -129,20 +159,42 @@ export async function runBackfill(opts: BackfillOptions = {}): Promise<void> {
if (!owner) continue;
try {
const beforeExisted = folderIdsSeenThisRun.has(`${owner.type}:${owner.id}`);
const folder = await ensureEntityFolder(portId, owner.type, owner.id, systemUser);
folderIdsSeenThisRun.add(`${owner.type}:${owner.id}`);
if (!beforeExisted && !folderIdsCreatedThisRun.has(folder.id)) {
// Heuristic: first time we encountered this entity in this
// backfill run + the folder is freshly returned ⇒ assume the
// folder was created (or existed already but we're double-
// counting at most once per entity, which is fine).
folderIdsCreatedThisRun.add(folder.id);
}
await tx
.update(files)
.set({ folderId: folder.id })
.where(and(eq(files.id, f.id), eq(files.portId, portId)));
stats.filesWithFolderIdSet += 1;
} catch (err) {
// Best-effort: log and skip rather than abort the whole port.
logger.warn({ err, fileId: f.id, portId }, 'backfill: ensureEntityFolder failed');
}
}
stats.foldersCreated = folderIdsCreatedThisRun.size;
});
logger.info({ portId }, 'backfill: port complete');
logger.info(
{
portId,
filesProcessed: stats.filesProcessed,
filesWithFolderIdSet: stats.filesWithFolderIdSet,
foldersCreated: stats.foldersCreated,
fksPropagated: stats.fksPropagated,
},
'backfill: port complete',
);
allStats.push(stats);
}
return allStats;
}
// ── CLI entry point ────────────────────────────────────────────────────────────
@@ -162,8 +214,29 @@ if (require.main === module) {
portId = next;
}
runBackfill({ portId })
.then(() => {
console.log('Backfill complete');
.then((stats) => {
console.log('\nBackfill complete.');
console.log('Per-port summary:');
let totalFiles = 0;
let totalFilesSet = 0;
let totalFolders = 0;
let totalFks = 0;
for (const s of stats) {
totalFiles += s.filesProcessed;
totalFilesSet += s.filesWithFolderIdSet;
totalFolders += s.foldersCreated;
totalFks += s.fksPropagated;
console.log(
` port=${s.portId}: filesProcessed=${s.filesProcessed} ` +
`filesWithFolderIdSet=${s.filesWithFolderIdSet} ` +
`foldersCreated=${s.foldersCreated} fksPropagated=${s.fksPropagated}`,
);
}
console.log(
`Totals: ports=${stats.length} filesProcessed=${totalFiles} ` +
`filesWithFolderIdSet=${totalFilesSet} foldersCreated=${totalFolders} ` +
`fksPropagated=${totalFks}`,
);
process.exit(0);
})
.catch((err) => {

View File

@@ -204,6 +204,7 @@ async function main(): Promise<void> {
storagePath: entry.key,
uploadedBy: uploadedById,
category: 'misc',
folderId,
})
.returning();
const [docRow] = await tx