Execute reviewed tenant merges safely

This commit is contained in:
2026-08-06 12:34:52 +02:00
parent a2c5ad5dd9
commit e2fdfe9c0a
5 changed files with 440 additions and 11 deletions

View File

@@ -23,13 +23,15 @@ import {
importTenantFullExport,
importTenantFullExportArchive,
readTenantFullExportArchive,
restoreTenantMergeArchiveFiles,
restoreTenantMergeInlineFiles,
} from "../utils/tenantFullExport";
import type { TenantFullExport } from "../utils/tenantFullExport";
import { buildSystemStatus } from "../modules/system-status.service";
import { matrixService } from "../modules/matrix.service";
import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
import { createTenantMergeDryRun } from "../utils/tenantMergeService";
import { createTenantMergeDryRun, executeTenantMerge, sanitizeTenantMergePlan } from "../utils/tenantMergeService";
import type { TenantMergePlan } from "../utils/tenantMergePlan";
export default async function adminRoutes(server: FastifyInstance) {
@@ -401,7 +403,7 @@ export default async function adminRoutes(server: FastifyInstance) {
) => {
try {
const exportData = await parseMergeSource(source, contentType, filename);
const report = await createTenantMergeDryRun(exportData, targetTenantId);
const report = sanitizeTenantMergePlan(await createTenantMergeDryRun(exportData, targetTenantId));
const reportBuffer = Buffer.from(JSON.stringify(report), "utf8");
await s3.send(new PutObjectCommand({
@@ -1558,7 +1560,7 @@ export default async function adminRoutes(server: FastifyInstance) {
status: importJob.status,
filename: importJob.filename,
statusUrl: `/api/admin/tenant-exports/${importJob.id}`,
reviewUrl: `/administration/tenants/${targetTenantId}/imports/${importJob.id}`,
reviewUrl: `/administration/tenant-imports/${importJob.id}`,
});
}
@@ -1590,7 +1592,7 @@ export default async function adminRoutes(server: FastifyInstance) {
status: importJob.status,
filename: importJob.filename,
statusUrl: `/api/admin/tenant-exports/${importJob.id}`,
reviewUrl: `/administration/tenants/${targetTenantId}/imports/${importJob.id}`,
reviewUrl: `/administration/tenant-imports/${importJob.id}`,
});
}
@@ -1656,6 +1658,116 @@ export default async function adminRoutes(server: FastifyInstance) {
}
});
// -------------------------------------------------------------
// POST /admin/tenant-imports/:import_id/execute
// -------------------------------------------------------------
server.post("/admin/tenant-imports/:import_id/execute", async (req, reply) => {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const { import_id } = req.params as { import_id: string };
const body = req.body as { decisions?: Record<string, "source" | "target"> };
const decisions = body?.decisions || {};
const [job] = await server.db
.select()
.from(tenantExportJobs)
.where(eq(tenantExportJobs.id, import_id))
.limit(1);
if (!job || job.operation !== "merge") return reply.code(404).send({ error: "Merge-Import nicht gefunden" });
if (job.status !== "review" || !job.storagePath) {
return reply.code(409).send({ error: "Merge-Import ist nicht zur Ausführung bereit", status: job.status });
}
let locked = false;
try {
const storedReport = JSON.parse((await readS3Buffer(mergeReportPath(job.id))).toString("utf8")) as TenantMergePlan;
const unresolved = storedReport.items.filter((item) =>
item.kind === "conflict" && !["source", "target"].includes(decisions[item.id])
);
if (unresolved.length) {
return reply.code(400).send({
error: `Für ${unresolved.length} Konflikte fehlt eine Entscheidung`,
unresolved: unresolved.map((item) => item.id),
});
}
await lockTenantForJob(job.tenantId, job.id);
locked = true;
const source = await readS3Buffer(job.storagePath);
const exportData = await parseMergeSource(source, job.contentType, job.filename);
const currentReport = sanitizeTenantMergePlan(await createTenantMergeDryRun(exportData, job.tenantId));
const comparable = (report: TenantMergePlan) => report.items.map((item) => ({
id: item.id,
kind: item.kind,
targetRow: item.table === "tenants" && item.targetRow
? Object.fromEntries(Object.entries(item.targetRow).filter(([column]) => ![
"locked",
"locked_by_export_job_id",
"lockedByExportJobId",
"updated_at",
"updatedAt",
].includes(column)))
: item.targetRow,
}));
if (JSON.stringify(comparable(currentReport)) !== JSON.stringify(comparable(storedReport))) {
const reportBuffer = Buffer.from(JSON.stringify(currentReport), "utf8");
await s3.send(new PutObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: mergeReportPath(job.id),
Body: reportBuffer,
ContentType: "application/json",
ContentLength: reportBuffer.length,
}));
return reply.code(409).send({
error: "Der Ziel-Tenant hat sich seit dem Dry-Run geändert. Der Bericht wurde aktualisiert.",
reviewRequired: true,
});
}
await server.db
.update(tenantExportJobs)
.set({ status: "running", error: null, updatedAt: new Date() })
.where(eq(tenantExportJobs.id, job.id));
const result = await executeTenantMerge(exportData, job.tenantId, decisions);
const selectedFileRefs = new Set<string>();
for (const item of currentReport.items) {
const decision = decisions[item.id] || item.defaultDecision;
if (decision !== "source" || item.kind === "existing") continue;
if (item.table === "files" && item.sourceRow.id) selectedFileRefs.add(String(item.sourceRow.id));
if (item.table === "letterheads" && item.sourceRow.id) selectedFileRefs.add(`letterhead:${item.sourceRow.id}`);
}
const files = job.contentType.includes("json") || job.filename.toLowerCase().endsWith(".json")
? await restoreTenantMergeInlineFiles(exportData, job.tenantId, selectedFileRefs)
: await restoreTenantMergeArchiveFiles(source, exportData, job.tenantId, selectedFileRefs);
await completeImportedTenantAccess(currentUser, { tenantId: job.tenantId });
await server.db
.update(tenantExportJobs)
.set({
status: "ready",
filesDone: 1,
filesTotal: 1,
completedAt: new Date(),
updatedAt: new Date(),
error: null,
})
.where(eq(tenantExportJobs.id, job.id));
return { success: true, importId: job.id, tenantId: job.tenantId, result, files };
} catch (err: any) {
server.log.error({ err, importId: job.id }, "Tenant-Merge-Ausführung fehlgeschlagen");
await server.db
.update(tenantExportJobs)
.set({ status: "review", error: err?.message || String(err), updatedAt: new Date() })
.where(eq(tenantExportJobs.id, job.id));
return reply.code(500).send({ error: err?.message || "Merge-Import fehlgeschlagen" });
} finally {
if (locked) await unlockTenantForJob(job.tenantId, job.id);
}
});
// -------------------------------------------------------------
// PUT /admin/users/:user_id/access
// -------------------------------------------------------------