Merge pull request #596 from Luca-Timo/bugfix/crm-backup

Backup & Restore hardening — close the silent files-only data-loss class
This commit is contained in:
Paul Nothaft
2026-06-02 09:10:57 +02:00
committed by GitHub
38 changed files with 6042 additions and 251 deletions
+119
View File
@@ -201,6 +201,102 @@ export interface Activity {
createdAt: string;
}
// Backup-integrity verifier — diagnostic endpoint that walks every
// CRM document-artefact path column and confirms files exist on disk
// (plus SHA-256 match where the schema stores one). Mirrors the
// shape returned by backupIntegrityService.verifyDocumentArtefacts.
export type BackupIntegrityScope =
| 'quote'
| 'contract'
| 'contract-signature'
| 'invoice';
export interface BackupIntegrityMissingRow {
table: string;
rowId: number;
column: string;
expectedPath: string;
}
export interface BackupIntegrityHashMismatchRow extends BackupIntegrityMissingRow {
expectedSha: string;
actualSha: string;
}
export interface BackupIntegrityExistsButNoHashRow {
table: string;
rowId: number;
column: string;
path: string;
}
export interface BackupIntegrityReport {
scannedAt: string;
scopes: BackupIntegrityScope[];
summary: {
totalRows: number;
verifiedOk: number;
missingFiles: number;
hashMismatches: number;
existsButNoHash: number;
};
missing: BackupIntegrityMissingRow[];
hashMismatches: BackupIntegrityHashMismatchRow[];
existsButNoHash: BackupIntegrityExistsButNoHashRow[];
}
// ---- Backup-coverage (Stage C of backup-hardening plan) -----------------
export type BackupPathCoverage =
| 'will-scan'
| 'skipped-by-toggle'
| 'skipped-by-feature-flag'
| 'missing-on-disk';
export interface BackupCoveragePath {
path: string;
includeInDefault: boolean;
featureFlag: string | null;
featureFlagValue: boolean | null;
displayOrder: number;
description: string | null;
existsOnDisk: boolean;
coverage: BackupPathCoverage;
}
export interface BackupCoverageDatabase {
mode: 'inline' | 'scheduled-only';
inlineDumpExplicitlyDisabled: boolean;
lastDumpAt: string | null;
lastDumpType: string | null;
lastDumpSizeBytes: number;
lastDumpFilePath: string | null;
lastDumpAgeMs: number | null;
lastDumpStale: boolean | null;
ok: boolean;
}
export interface BackupCoverageReport {
generatedAt: string;
database: BackupCoverageDatabase;
paths: BackupCoveragePath[];
drift: {
unconfiguredOnDisk: string[];
expectedNonBackupDirs: string[];
};
summary: {
configuredCount: number;
willScanCount: number;
skippedByToggleCount: number;
skippedByFeatureFlagCount: number;
missingOnDiskCount: number;
driftCount: number;
tableMissingFallbackInUse: boolean;
databaseOk: boolean;
overallOk: boolean;
};
}
export interface AdminProfile {
id: number;
username: string;
@@ -262,6 +358,29 @@ export const adminService = {
return response.data;
},
// Backup-integrity verifier (read-only diagnostic). `scope` filters
// which document classes to walk; omit for a full scan.
async getBackupIntegrity(
scope?: BackupIntegrityScope[],
): Promise<BackupIntegrityReport> {
const params = scope && scope.length > 0 ? { scope: scope.join(',') } : undefined;
const response = await api.get<{ report: BackupIntegrityReport }>(
'/admin/system-health/backup-integrity',
{ params },
);
return response.data.report;
},
// Backup-coverage diagnostic (Stage C). Answers "what will the
// next backup actually include / skip / silently miss?" — read-only,
// no parameters. See backupCoverageService.js for the full report shape.
async getBackupCoverage(): Promise<BackupCoverageReport> {
const response = await api.get<{ report: BackupCoverageReport }>(
'/admin/system-health/backup-coverage',
);
return response.data.report;
},
// Format activity message
formatActivityMessage(activity: Activity): string {
// Feature-flag toggles carry a `changed` diff in metadata. Render