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:
@@ -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) => {
|
||||
|
||||
@@ -204,6 +204,7 @@ async function main(): Promise<void> {
|
||||
storagePath: entry.key,
|
||||
uploadedBy: uploadedById,
|
||||
category: 'misc',
|
||||
folderId,
|
||||
})
|
||||
.returning();
|
||||
const [docRow] = await tx
|
||||
|
||||
Reference in New Issue
Block a user