diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 944616c..40451c3 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,7 +1,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { and, eq, inArray, isNull } from "drizzle-orm"; import multipart from "@fastify/multipart"; -import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; +import { GetObjectCommand } from "@aws-sdk/client-s3"; import { authTenantUsers, @@ -22,17 +22,12 @@ import { createTenantFullExportArchive, 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, executeTenantMerge, sanitizeTenantMergePlan } from "../utils/tenantMergeService"; -import type { TenantMergePlan } from "../utils/tenantMergePlan"; export default async function adminRoutes(server: FastifyInstance) { await server.register(multipart, { @@ -327,132 +322,6 @@ export default async function adminRoutes(server: FastifyInstance) { )); }; - const createTenantImportJob = async (tenantId: number, currentUserId: string, filename: string) => { - const [job] = await server.db - .insert(tenantExportJobs) - .values({ - tenantId, - createdBy: currentUserId, - operation: "import", - status: "running", - filename, - updatedAt: new Date(), - }) - .returning(); - - await lockTenantForJob(tenantId, job.id); - - return job; - }; - - const mergeArchivePath = (jobId: string) => `tenant-import-reviews/${jobId}/source`; - const mergeReportPath = (jobId: string) => `tenant-import-reviews/${jobId}/report.json`; - - const createTenantMergeReviewJob = async ( - tenantId: number, - currentUserId: string, - filename: string, - contentType: string, - source: Buffer - ) => { - const [job] = await server.db - .insert(tenantExportJobs) - .values({ - tenantId, - createdBy: currentUserId, - operation: "merge", - status: "running", - filename, - contentType, - storagePath: "pending", - fileSize: source.length, - filesDone: 0, - filesTotal: 1, - updatedAt: new Date(), - }) - .returning(); - const storagePath = mergeArchivePath(job.id); - - await s3.send(new PutObjectCommand({ - Bucket: secrets.S3_BUCKET, - Key: storagePath, - Body: source, - ContentType: contentType, - ContentLength: source.length, - })); - await server.db - .update(tenantExportJobs) - .set({ storagePath, updatedAt: new Date() }) - .where(eq(tenantExportJobs.id, job.id)); - - return { ...job, storagePath }; - }; - - const parseMergeSource = async (source: Buffer, contentType: string, filename: string) => { - const isJson = contentType.includes("json") || filename.toLowerCase().endsWith(".json"); - if (isJson) return JSON.parse(source.toString("utf8")) as TenantFullExport; - return await readTenantFullExportArchive(source); - }; - - const startTenantMergeReviewJob = async ( - jobId: string, - targetTenantId: number, - source: Buffer, - contentType: string, - filename: string - ) => { - const heartbeat = setInterval(() => { - void server.db - .update(tenantExportJobs) - .set({ updatedAt: new Date() }) - .where(eq(tenantExportJobs.id, jobId)); - }, 15_000); - try { - const exportData = await parseMergeSource(source, contentType, filename); - const report = sanitizeTenantMergePlan(await createTenantMergeDryRun(exportData, targetTenantId)); - const reportBuffer = Buffer.from(JSON.stringify(report), "utf8"); - - await s3.send(new PutObjectCommand({ - Bucket: secrets.S3_BUCKET, - Key: mergeReportPath(jobId), - Body: reportBuffer, - ContentType: "application/json", - ContentLength: reportBuffer.length, - })); - await server.db - .update(tenantExportJobs) - .set({ - status: "review", - filesDone: 1, - filesTotal: 1, - updatedAt: new Date(), - error: null, - }) - .where(eq(tenantExportJobs.id, jobId)); - } catch (err: any) { - server.log.error({ err, jobId }, "Tenant-Merge-Dry-Run fehlgeschlagen"); - await server.db - .update(tenantExportJobs) - .set({ - status: "failed", - error: err?.message || String(err), - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(tenantExportJobs.id, jobId)); - } finally { - clearInterval(heartbeat); - } - }; - - const readS3Buffer = async (key: string) => { - const { Body } = await s3.send(new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: key })); - if (!Body) throw new Error("Gespeicherte Importdatei ist leer"); - const chunks: Buffer[] = []; - for await (const chunk of Body as any) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - return Buffer.concat(chunks); - }; - const completeImportedTenantAccess = async ( currentUser: { id: string; email: string }, result: { tenantId: number } @@ -513,67 +382,6 @@ export default async function adminRoutes(server: FastifyInstance) { }; }; - const startTenantImportJob = async ( - jobId: string, - targetTenantId: number, - currentUser: { id: string; email: string }, - importData: { type: "archive"; archiveBuffer: Buffer } | { type: "json"; exportData: TenantFullExport } - ) => { - try { - await server.db - .update(tenantExportJobs) - .set({ - status: "running", - filesDone: 0, - filesTotal: 1, - updatedAt: new Date(), - }) - .where(eq(tenantExportJobs.id, jobId)); - - const onProgress = async ({ done, total }: { done: number; total: number }) => { - await server.db - .update(tenantExportJobs) - .set({ - filesDone: done, - filesTotal: total, - updatedAt: new Date(), - }) - .where(eq(tenantExportJobs.id, jobId)); - }; - - const result = importData.type === "archive" - ? await importTenantFullExportArchive(server, importData.archiveBuffer, { targetTenantId, onProgress }) - : await importTenantFullExport(server, importData.exportData, { targetTenantId, onProgress }); - - await completeImportedTenantAccess(currentUser, result); - - await server.db - .update(tenantExportJobs) - .set({ - status: "ready", - filesDone: 1, - filesTotal: 1, - completedAt: new Date(), - updatedAt: new Date(), - error: null, - }) - .where(eq(tenantExportJobs.id, jobId)); - } catch (err: any) { - console.error("ERROR tenant import job:", err); - await server.db - .update(tenantExportJobs) - .set({ - status: "failed", - error: err?.message || String(err), - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(tenantExportJobs.id, jobId)); - } finally { - await unlockTenantForJob(targetTenantId, jobId); - } - }; - const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => { try { await lockTenantForJob(tenantId, jobId); @@ -1474,25 +1282,11 @@ export default async function adminRoutes(server: FastifyInstance) { if (!job) return reply.code(404).send({ error: "Export not found" }); - let returnedStatus = job.status; - const staleMergeReview = job.operation === "merge" - && ["running", "recovering"].includes(job.status) - && (!job.updatedAt || Date.now() - new Date(job.updatedAt).getTime() > 60_000); - if (staleMergeReview && job.storagePath) { - const source = await readS3Buffer(job.storagePath); - await server.db - .update(tenantExportJobs) - .set({ status: "recovering", updatedAt: new Date(), error: null }) - .where(eq(tenantExportJobs.id, job.id)); - void startTenantMergeReviewJob(job.id, job.tenantId, source, job.contentType, job.filename); - returnedStatus = "recovering"; - } - return { exportId: job.id, tenantId: job.tenantId, operation: job.operation, - status: returnedStatus, + status: job.status, filename: job.filename, fileSize: job.fileSize, filesDone: job.filesDone, @@ -1562,31 +1356,13 @@ export default async function adminRoutes(server: FastifyInstance) { const archiveBuffer = await data.toBuffer(); targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null; - if (targetTenantId) { - const filename = data.filename || "tenant-import.zip"; - const contentType = data.mimetype || "application/zip"; - const importJob = await createTenantMergeReviewJob( - targetTenantId, - currentUser.id, - filename, - contentType, - archiveBuffer - ); - void startTenantMergeReviewJob(importJob.id, targetTenantId, archiveBuffer, contentType, filename); - - return reply.code(202).send({ - importId: importJob.id, - exportId: importJob.id, - tenantId: targetTenantId, - status: importJob.status, - filename: importJob.filename, - statusUrl: `/api/admin/tenant-exports/${importJob.id}`, - reviewUrl: `/administration/tenant-imports/${importJob.id}`, + return reply.code(409).send({ + error: "Ein Tenant-Export kann nur als neuer Tenant importiert werden. Der Import in einen bestehenden Tenant ist deaktiviert.", }); } - result = await importTenantFullExportArchive(server, archiveBuffer, { targetTenantId }); + result = await importTenantFullExportArchive(server, archiveBuffer); } else { const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number }; const exportData = "format" in body ? body : body.exportData; @@ -1597,28 +1373,12 @@ export default async function adminRoutes(server: FastifyInstance) { } if (targetTenantId) { - const source = Buffer.from(JSON.stringify(exportData), "utf8"); - const importJob = await createTenantMergeReviewJob( - targetTenantId, - currentUser.id, - "tenant-import.json", - "application/json", - source - ); - void startTenantMergeReviewJob(importJob.id, targetTenantId, source, "application/json", "tenant-import.json"); - - return reply.code(202).send({ - importId: importJob.id, - exportId: importJob.id, - tenantId: targetTenantId, - status: importJob.status, - filename: importJob.filename, - statusUrl: `/api/admin/tenant-exports/${importJob.id}`, - reviewUrl: `/administration/tenant-imports/${importJob.id}`, + return reply.code(409).send({ + error: "Ein Tenant-Export kann nur als neuer Tenant importiert werden. Der Import in einen bestehenden Tenant ist deaktiviert.", }); } - result = await importTenantFullExport(server, exportData, { targetTenantId }); + result = await importTenantFullExport(server, exportData); } const { matrixProvisioned, matrixProvisioningError } = await completeImportedTenantAccess(currentUser, result); @@ -1631,162 +1391,9 @@ export default async function adminRoutes(server: FastifyInstance) { }; } catch (err: any) { console.error("ERROR /admin/tenant-imports:", err); - return reply.code(500).send({ error: err?.message || "Internal Server Error" }); - } - }); - - // ------------------------------------------------------------- - // GET /admin/tenant-imports/:import_id/review - // ------------------------------------------------------------- - server.get("/admin/tenant-imports/:import_id/review", async (req, reply) => { - try { - const currentUser = await requireAdmin(req, reply); - if (!currentUser) return; - const { import_id } = req.params as { import_id: string }; - const query = req.query as { kind?: string; table?: string; offset?: string; limit?: string }; - 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") { - return reply.code(409).send({ error: "Dry-Run ist noch nicht verfügbar", status: job.status }); - } - - const report = JSON.parse((await readS3Buffer(mergeReportPath(job.id))).toString("utf8")) as TenantMergePlan; - const offset = Math.max(0, Number(query.offset || 0) || 0); - const limit = Math.min(500, Math.max(1, Number(query.limit || 100) || 100)); - const filtered = report.items.filter((item) => - (!query.kind || item.kind === query.kind) && (!query.table || item.table === query.table) - ); - - return { - importId: job.id, - tenantId: job.tenantId, - status: job.status, - filename: job.filename, - summary: report.summary, - tables: Array.from(new Set(report.items.map((item) => item.table))).sort(), - total: filtered.length, - offset, - limit, - items: filtered.slice(offset, offset + limit), - }; - } catch (err: any) { - console.error("ERROR /admin/tenant-imports/:import_id/review:", err); - return reply.code(500).send({ error: err?.message || "Internal Server Error" }); - } - }); - - // ------------------------------------------------------------- - // 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 }; - 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(); - 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); + const message = err?.message || "Internal Server Error"; + const statusCode = message.includes("Tenant mit dieser ID existiert bereits") ? 409 : 500; + return reply.code(statusCode).send({ error: message }); } }); diff --git a/backend/src/utils/tenantExportGlobalResources.ts b/backend/src/utils/tenantExportGlobalResources.ts deleted file mode 100644 index ce1d388..0000000 --- a/backend/src/utils/tenantExportGlobalResources.ts +++ /dev/null @@ -1,36 +0,0 @@ -type TableRows = Record[]> - -type LoadRows = ( - table: string, - whereSql: string, - params?: any[] -) => Promise[]> - -type AddRows = ( - tables: TableRows, - table: string, - rows: Record[] -) => void - -export const TENANT_EXPORT_GLOBAL_TABLES = ["units", "citys", "countrys"] as const - -export const addTenantExportGlobalResources = async ( - tables: TableRows, - availableTables: ReadonlySet, - tenant: Record, - loadRows: LoadRows, - addRows: AddRows -) => { - for (const table of TENANT_EXPORT_GLOBAL_TABLES) { - if (!availableTables.has(table)) continue - addRows(tables, table, await loadRows(table, "true")) - } - - if (availableTables.has("accounts")) { - addRows( - tables, - "accounts", - await loadRows("accounts", `"accountChart" = $1`, [tenant.accountChart || "skr03"]) - ) - } -} diff --git a/backend/src/utils/tenantFullExport.ts b/backend/src/utils/tenantFullExport.ts index f62fce6..c543534 100644 --- a/backend/src/utils/tenantFullExport.ts +++ b/backend/src/utils/tenantFullExport.ts @@ -11,8 +11,6 @@ import { pool } from "../../db" import { s3 } from "./s3" import { secrets } from "./secrets" import { decrypt, encrypt } from "./crypt" -import { addTenantExportGlobalResources } from "./tenantExportGlobalResources" -import { restoreImportedTenantNumberRanges } from "./tenantImportNumberRanges" type TableRows = Record[]> type TableMetadata = { @@ -46,7 +44,6 @@ type ImportResult = { } type ImportOptions = { - targetTenantId?: number | null onProgress?: (progress: { done: number; total: number; message?: string }) => Promise | void } @@ -84,6 +81,10 @@ const ENTITY_BANKACCOUNT_PLAIN_FIELDS = { bankName: "__plainBankName", } +// Diese globalen Stammdaten werden installationsweit per SQL-Migration gepflegt +// und duerfen auch aus aelteren Tenant-Archiven nicht importiert werden. +const GLOBAL_MIGRATION_TABLES = new Set(["accounts", "units", "citys", "countrys"]) + const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"` const matrixServerName = () => process.env.MATRIX_SERVER_NAME || @@ -301,14 +302,6 @@ export const buildTenantFullExport = async ( if (!tenantRows.length) throw new Error("Tenant nicht gefunden") addRows(tables, "tenants", tenantRows) - await addTenantExportGlobalResources( - tables, - new Set(columnsByTable.keys()), - tenantRows[0], - (table, whereSql, params) => loadRows(client, table, whereSql, params), - addRows - ) - for (const [table, metadata] of columnsByTable.entries()) { if (table === "tenants") continue const { columns } = metadata @@ -612,58 +605,6 @@ const restoreArchiveFiles = async ( return { restored, skipped } } -const remapTenantScopedExport = ( - exportData: TenantFullExport, - targetTenantId?: number | null -): TenantFullExport => { - if (!targetTenantId || targetTenantId === exportData.tenantId) return exportData - - const sourceTenantId = exportData.tenantId - const sourcePathPrefix = `${sourceTenantId}/` - const targetPathPrefix = `${targetTenantId}/` - const tables: TableRows = {} - - for (const [table, rows] of Object.entries(exportData.tables || {})) { - tables[table] = rows.map((row) => { - const nextRow = { ...row } - - if (table === "tenants" && nextRow.id === sourceTenantId) { - nextRow.id = targetTenantId - } - - if (nextRow.tenant === sourceTenantId) { - nextRow.tenant = targetTenantId - } - - if (nextRow.tenant_id === sourceTenantId) { - nextRow.tenant_id = targetTenantId - } - - if (table === "files" && typeof nextRow.path === "string" && nextRow.path.startsWith(sourcePathPrefix)) { - nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}` - } - - if (table === "letterheads" && typeof nextRow.path === "string" && nextRow.path.startsWith(sourcePathPrefix)) { - nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}` - } - - return nextRow - }) - } - - return { - ...exportData, - tenantId: targetTenantId, - tables, - files: (exportData.files || []).map((file) => ({ - ...file, - path: file.path?.startsWith(sourcePathPrefix) - ? `${targetPathPrefix}${file.path.slice(sourcePathPrefix.length)}` - : file.path, - })), - } -} - const encryptEntityBankAccountRowsForImport = (exportData: TenantFullExport) => { const rows = exportData.tables.entitybankaccounts || [] @@ -712,12 +653,6 @@ const prepareCommunicationRoomsForImport = (exportData: TenantFullExport) => { } } -export const prepareTenantFullExportRowsForImport = (exportData: TenantFullExport) => { - encryptEntityBankAccountRowsForImport(exportData) - prepareCommunicationRoomsForImport(exportData) - return exportData -} - const cleanupImportedCommunicationRooms = async (client: any, exportData: TenantFullExport) => { const rows = exportData.tables.communication_rooms || [] if (!rows.length) return 0 @@ -905,8 +840,9 @@ export const importTenantFullExport = async ( throw new Error("Ungültiges FEDEO Mandantenexport-Format") } - const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId) - prepareTenantFullExportRowsForImport(exportData) + const exportData = rawExportData + encryptEntityBankAccountRowsForImport(exportData) + prepareCommunicationRoomsForImport(exportData) const client = await pool.connect() const importOrder = [ "tenants", @@ -921,11 +857,21 @@ export const importTenantFullExport = async ( ] try { + const existingTenant = await client.query( + `select 1 from ${quoteIdent("tenants")} where ${quoteIdent("id")} = $1 limit 1`, + [exportData.tenantId] + ) + if (existingTenant.rows.length) { + throw new Error("Ein Tenant mit dieser ID existiert bereits. Tenant-Exporte können nur als neuer Tenant importiert werden.") + } + const columnsByTable = await tableColumns(client) const tableNames = [ ...importOrder, ...Object.keys(exportData.tables).filter((table) => !importOrder.includes(table)).sort(), - ].filter((table, index, all) => all.indexOf(table) === index) + ].filter((table, index, all) => + all.indexOf(table) === index && !GLOBAL_MIGRATION_TABLES.has(table) + ) const specialTables = [ columnsByTable.has("auth_tenant_users") ? "auth_tenant_users" : null, columnsByTable.has("bankaccounts") ? "bankaccounts" : null, @@ -1005,8 +951,6 @@ export const importTenantFullExport = async ( progressDone += 1 await reportProgress("Kommunikationsräume bereinigt") - await restoreImportedTenantNumberRanges(client, exportData) - await refreshSequences(client, columnsByTable) progressDone = progressTotal await reportProgress("Import abgeschlossen") @@ -1030,42 +974,6 @@ export const importTenantFullExportArchive = async ( archiveBuffer: Buffer, options: ImportOptions = {} ): Promise => { - const rawExportData = await readTenantFullExportArchive(archiveBuffer) - const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId) - const reader = new ZipReader(new BlobReader(new Blob([archiveBuffer]))) - - try { - const entries = await reader.getEntries() - const entriesByName = new Map(entries.map((entry: any) => [entry.filename, entry])) - const manifest = JSON.parse(await readZipTextEntry(entriesByName, "manifest.json")) as TenantArchiveManifest - const sourceTenantId = rawExportData.tenantId - const targetTenantId = exportData.tenantId - const sourcePrefix = `${sourceTenantId}/` - const targetPrefix = `${targetTenantId}/` - const remappedManifest: TenantArchiveManifest = { - ...manifest, - tenantId: targetTenantId, - files: (manifest.files || []).map((file) => ({ - ...file, - path: file.path?.startsWith(sourcePrefix) - ? `${targetPrefix}${file.path.slice(sourcePrefix.length)}` - : file.path, - })), - } - - const result = await importTenantFullExport(server, exportData, { targetTenantId: null }) - const files = await restoreArchiveFiles(entriesByName, exportData, remappedManifest) - - return { - ...result, - files, - } - } finally { - await reader.close() - } -} - -export const readTenantFullExportArchive = async (archiveBuffer: Buffer): Promise => { const reader = new ZipReader(new BlobReader(new Blob([archiveBuffer]))) try { @@ -1082,7 +990,7 @@ export const readTenantFullExportArchive = async (archiveBuffer: Buffer): Promis tables[table.name] = JSON.parse(await readZipTextEntry(entriesByName, table.path)) } - return { + const rawExportData: TenantFullExport = { format: "fedeo.tenant-full-export", version: 1, exportedAt: manifest.exportedAt, @@ -1100,57 +1008,14 @@ export const readTenantFullExportArchive = async (archiveBuffer: Buffer): Promis })), } + const result = await importTenantFullExport(server, rawExportData) + const files = await restoreArchiveFiles(entriesByName, rawExportData, manifest) + + return { + ...result, + files, + } } finally { await reader.close() } } - -export const restoreTenantMergeArchiveFiles = async ( - archiveBuffer: Buffer, - rawExportData: TenantFullExport, - targetTenantId: number, - selectedFileRefs: ReadonlySet -) => { - const reader = new ZipReader(new BlobReader(new Blob([archiveBuffer]))) - try { - const entries = await reader.getEntries() - const entriesByName = new Map(entries.map((entry: any) => [entry.filename, entry])) - const manifest = JSON.parse(await readZipTextEntry(entriesByName, "manifest.json")) as TenantArchiveManifest - const exportData = remapTenantScopedExport(rawExportData, targetTenantId) - const sourcePrefix = `${rawExportData.tenantId}/` - const targetPrefix = `${targetTenantId}/` - const filteredFiles = (manifest.files || []) - .filter((file) => selectedFileRefs.has(String(file.id))) - .map((file) => ({ - ...file, - path: file.path?.startsWith(sourcePrefix) - ? `${targetPrefix}${file.path.slice(sourcePrefix.length)}` - : file.path, - })) - const filteredManifest: TenantArchiveManifest = { - ...manifest, - tenantId: targetTenantId, - files: filteredFiles, - } - const filteredExportData: TenantFullExport = { - ...exportData, - files: exportData.files.filter((file) => selectedFileRefs.has(String(file.id))), - } - - return await restoreArchiveFiles(entriesByName, filteredExportData, filteredManifest) - } finally { - await reader.close() - } -} - -export const restoreTenantMergeInlineFiles = async ( - rawExportData: TenantFullExport, - targetTenantId: number, - selectedFileRefs: ReadonlySet -) => { - const exportData = remapTenantScopedExport(rawExportData, targetTenantId) - return await restoreFiles({ - ...exportData, - files: exportData.files.filter((file) => selectedFileRefs.has(String(file.id))), - }) -} diff --git a/backend/src/utils/tenantImportNumberRanges.ts b/backend/src/utils/tenantImportNumberRanges.ts deleted file mode 100644 index fe348b5..0000000 --- a/backend/src/utils/tenantImportNumberRanges.ts +++ /dev/null @@ -1,28 +0,0 @@ -type QueryClient = { - query: (query: string, values: unknown[]) => Promise<{ rowCount?: number | null }> -} - -type TenantImportData = { - tenantId: number - tables: Record[]> -} - -export const restoreImportedTenantNumberRanges = async ( - client: QueryClient, - exportData: TenantImportData -) => { - const tenantRow = (exportData.tables.tenants || []).find( - (row) => Number(row.id) === Number(exportData.tenantId) - ) - - if (!tenantRow || tenantRow.numberRanges === null || typeof tenantRow.numberRanges === "undefined") { - return 0 - } - - const result = await client.query( - `update "tenants" set "numberRanges" = $1::jsonb where "id" = $2`, - [JSON.stringify(tenantRow.numberRanges), exportData.tenantId] - ) - - return result.rowCount || 0 -} diff --git a/backend/src/utils/tenantMergePlan.ts b/backend/src/utils/tenantMergePlan.ts deleted file mode 100644 index bcc0dd4..0000000 --- a/backend/src/utils/tenantMergePlan.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { createHash } from "crypto" - -export type TenantMergeKind = "import" | "existing" | "conflict" | "id_collision" -export type TenantMergeDecision = "source" | "target" - -export type TenantMergeTableMetadata = { - primaryKey: string[] -} - -export type TenantMergeItem = { - id: string - table: string - kind: TenantMergeKind - sourceKey: string - targetKey: string | null - label: string - defaultDecision: TenantMergeDecision - allowedDecisions: TenantMergeDecision[] - differences: string[] - sourceRow: Record - targetRow: Record | null -} - -export type TenantMergePlan = { - items: TenantMergeItem[] - summary: Record -} - -const ignoredComparisonColumns = new Set([ - "created_at", - "createdAt", - "updated_at", - "updatedAt", - "updated_by", - "updatedBy", - "locked", - "locked_by_export_job_id", - "lockedByExportJobId", -]) - -const normalizeText = (value: unknown) => String(value ?? "").trim().toLocaleLowerCase("de") - -const naturalKeyColumns: Record = { - accounts: ["accountChart", "number"], - units: ["name"], - countrys: ["name"], - citys: ["zip", "short", "districtCode"], -} - -const comparableRow = (row: Record) => Object.fromEntries( - Object.entries(row) - .filter(([column]) => !ignoredComparisonColumns.has(column)) - .sort(([left], [right]) => left.localeCompare(right)) -) - -const rowDifferences = (table: string, source: Record, target: Record) => { - const sourceComparable = comparableRow(source) - const targetComparable = comparableRow(target) - if (table === "citys") { - delete sourceComparable.geometry - delete targetComparable.geometry - } - const columns = Array.from(new Set([ - ...Object.keys(sourceComparable), - ...Object.keys(targetComparable), - ])).sort() - - return columns.filter((column) => - JSON.stringify(sourceComparable[column]) !== JSON.stringify(targetComparable[column]) - ) -} - -const rowKey = (row: Record, columns: string[]) => - columns.map((column) => `${column}=${JSON.stringify(row[column] ?? null)}`).join("|") - -const naturalKey = (table: string, row: Record) => { - const columns = naturalKeyColumns[table] - if (!columns) return null - - const values = columns.map((column) => row[column]) - if (values.every((value) => value === null || typeof value === "undefined" || value === "")) return null - - return columns.map((column, index) => `${column}=${normalizeText(values[index])}`).join("|") -} - -const itemId = (table: string, sourceKey: string) => - createHash("sha256").update(`${table}\0${sourceKey}`).digest("hex").slice(0, 24) - -const itemLabel = (table: string, row: Record, fallback: string) => { - const descriptive = row.name ?? row.label ?? row.title ?? row.number ?? row.email ?? row.filename - return descriptive ? `${descriptive}` : `${table}: ${fallback}` -} - -export const buildTenantMergePlan = ( - sourceTables: Record[]>, - targetTables: Record[]>, - metadata: Record -): TenantMergePlan => { - const items: TenantMergeItem[] = [] - - for (const table of Object.keys(sourceTables).sort()) { - const primaryKey = metadata[table]?.primaryKey?.length ? metadata[table].primaryKey : ["id"] - const targetRows = targetTables[table] || [] - const targetByPrimaryKey = new Map(targetRows.map((row) => [rowKey(row, primaryKey), row])) - const targetByNaturalKey = new Map>() - - for (const targetRow of targetRows) { - const key = naturalKey(table, targetRow) - if (key) targetByNaturalKey.set(key, targetRow) - } - - for (const sourceRow of sourceTables[table] || []) { - const sourceKey = rowKey(sourceRow, primaryKey) - const sourceNaturalKey = naturalKey(table, sourceRow) - const naturalTarget = sourceNaturalKey ? targetByNaturalKey.get(sourceNaturalKey) : undefined - const primaryTarget = targetByPrimaryKey.get(sourceKey) - const targetRow = naturalTarget || primaryTarget || null - const differences = targetRow ? rowDifferences(table, sourceRow, targetRow) : [] - let kind: TenantMergeKind - - if (!targetRow) { - kind = "import" - } else if (!differences.length) { - kind = "existing" - } else if (primaryTarget && sourceNaturalKey && naturalKey(table, primaryTarget) !== sourceNaturalKey) { - kind = "id_collision" - } else { - kind = "conflict" - } - - items.push({ - id: itemId(table, sourceKey), - table, - kind, - sourceKey, - targetKey: targetRow ? rowKey(targetRow, primaryKey) : null, - label: itemLabel(table, sourceRow, sourceKey), - defaultDecision: kind === "import" || kind === "id_collision" ? "source" : "target", - allowedDecisions: kind === "conflict" ? ["target", "source"] : [kind === "existing" ? "target" : "source"], - differences, - sourceRow, - targetRow, - }) - } - } - - return { - items, - summary: { - import: items.filter((item) => item.kind === "import").length, - existing: items.filter((item) => item.kind === "existing").length, - conflict: items.filter((item) => item.kind === "conflict").length, - id_collision: items.filter((item) => item.kind === "id_collision").length, - }, - } -} diff --git a/backend/src/utils/tenantMergeService.ts b/backend/src/utils/tenantMergeService.ts deleted file mode 100644 index a0b091c..0000000 --- a/backend/src/utils/tenantMergeService.ts +++ /dev/null @@ -1,438 +0,0 @@ -import { pool } from "../../db" -import { prepareTenantFullExportRowsForImport, type TenantFullExport } from "./tenantFullExport" -import { buildTenantMergePlan, type TenantMergeDecision, type TenantMergePlan, type TenantMergeTableMetadata } from "./tenantMergePlan" - -type MergeDatabaseMetadata = TenantMergeTableMetadata & { - columns: string[] - jsonColumns: Set - generatedColumns: Set - foreignKeys: { column: string, referencedTable: string, referencedColumn: string }[] -} - -const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"` -const globalNaturalKeyTables = new Set(["accounts", "units", "countrys", "citys"]) - -const loadMergeMetadata = async (client: any) => { - const columnsResult = await client.query(` - select table_name, column_name, data_type, is_generated - from information_schema.columns - where table_schema = 'public' - order by table_name, ordinal_position - `) - const foreignKeyResult = await client.query(` - select tc.table_name, kcu.column_name, ccu.table_name as referenced_table, - ccu.column_name as referenced_column - from information_schema.table_constraints tc - join information_schema.key_column_usage kcu - on tc.constraint_name = kcu.constraint_name - and tc.constraint_schema = kcu.constraint_schema - join information_schema.constraint_column_usage ccu - on tc.constraint_name = ccu.constraint_name - and tc.constraint_schema = ccu.constraint_schema - where tc.table_schema = 'public' and tc.constraint_type = 'FOREIGN KEY' - `) - const primaryKeyResult = await client.query(` - select tc.table_name, kcu.column_name - from information_schema.table_constraints tc - join information_schema.key_column_usage kcu - on tc.constraint_name = kcu.constraint_name - and tc.constraint_schema = kcu.constraint_schema - where tc.table_schema = 'public' and tc.constraint_type = 'PRIMARY KEY' - order by tc.table_name, kcu.ordinal_position - `) - const metadata: Record = {} - - for (const row of columnsResult.rows) { - metadata[row.table_name] ||= { columns: [], primaryKey: [], jsonColumns: new Set(), generatedColumns: new Set(), foreignKeys: [] } - metadata[row.table_name].columns.push(row.column_name) - if (row.data_type === "json" || row.data_type === "jsonb") metadata[row.table_name].jsonColumns.add(row.column_name) - if (row.is_generated === "ALWAYS") metadata[row.table_name].generatedColumns.add(row.column_name) - } - for (const row of primaryKeyResult.rows) { - metadata[row.table_name] ||= { columns: [], primaryKey: [], jsonColumns: new Set(), generatedColumns: new Set(), foreignKeys: [] } - metadata[row.table_name].primaryKey.push(row.column_name) - } - for (const row of foreignKeyResult.rows) { - metadata[row.table_name] ||= { columns: [], primaryKey: [], jsonColumns: new Set(), generatedColumns: new Set(), foreignKeys: [] } - metadata[row.table_name].foreignKeys.push({ - column: row.column_name, - referencedTable: row.referenced_table, - referencedColumn: row.referenced_column, - }) - } - - return metadata -} - -const remapSourceTenant = (exportData: TenantFullExport, targetTenantId: number) => { - const tables: Record[]> = {} - - for (const [table, rows] of Object.entries(exportData.tables || {})) { - tables[table] = rows.map((row) => { - const next = { ...row } - if (table === "tenants" && Number(next.id) === Number(exportData.tenantId)) next.id = targetTenantId - if (Number(next.tenant) === Number(exportData.tenantId)) next.tenant = targetTenantId - if (Number(next.tenant_id) === Number(exportData.tenantId)) next.tenant_id = targetTenantId - return next - }) - } - - return tables -} - -const splitSourceTablesByAvailability = ( - sourceTables: Record[]>, - metadata: Record -) => { - const available: Record[]> = {} - const unavailable = Object.keys(sourceTables).filter((table) => { - if (metadata[table]) { - available[table] = sourceTables[table] - return false - } - return sourceTables[table].length > 0 - }) - - return { available, unavailable } -} - -const appendUnavailableTableConflicts = (plan: TenantMergePlan, unavailableTables: string[]) => { - for (const table of unavailableTables.sort()) { - plan.items.push({ - id: `missing-target-table:${table}`, - table, - kind: "conflict", - sourceKey: "Zieltabelle fehlt", - targetKey: null, - label: `Tabelle ${table} ist im Zielsystem nicht vorhanden`, - defaultDecision: "target", - allowedDecisions: ["target"], - differences: ["Die erforderliche Tabelle fehlt im Zielschema und muss zuerst per Migration angelegt werden."], - sourceRow: {}, - targetRow: null, - }) - plan.summary.conflict += 1 - } - return plan -} - -const loadTargetRows = async ( - client: any, - table: string, - sourceRows: Record[], - metadata: MergeDatabaseMetadata, - targetTenantId: number -) => { - if (!sourceRows.length) return [] - if (table === "tenants") { - return (await client.query(`select * from ${quoteIdent(table)} where ${quoteIdent("id")} = $1`, [targetTenantId])).rows - } - - const tenantColumn = metadata.columns.includes("tenant") - ? "tenant" - : metadata.columns.includes("tenant_id") ? "tenant_id" : null - if (tenantColumn) { - return (await client.query( - `select * from ${quoteIdent(table)} where ${quoteIdent(tenantColumn)} = $1`, - [targetTenantId] - )).rows - } - if (globalNaturalKeyTables.has(table)) { - return (await client.query(`select * from ${quoteIdent(table)}`)).rows - } - - if (metadata.primaryKey.length === 1) { - const key = metadata.primaryKey[0] - const values = Array.from(new Set(sourceRows.map((row) => row[key]).filter((value) => value !== null && typeof value !== "undefined").map(String))) - if (!values.length) return [] - return (await client.query( - `select * from ${quoteIdent(table)} where ${quoteIdent(key)}::text = any($1::text[])`, - [values] - )).rows - } - - return (await client.query(`select * from ${quoteIdent(table)}`)).rows -} - -export const createTenantMergeDryRunWithClient = async ( - client: any, - exportData: TenantFullExport, - targetTenantId: number -): Promise => { - const metadata = await loadMergeMetadata(client) - const remappedSourceTables = remapSourceTenant(exportData, targetTenantId) - const { available: sourceTables, unavailable } = splitSourceTablesByAvailability(remappedSourceTables, metadata) - const targetTables: Record[]> = {} - - for (const [table, sourceRows] of Object.entries(sourceTables)) { - const tableMetadata = metadata[table] - if (!tableMetadata) continue - targetTables[table] = await loadTargetRows(client, table, sourceRows, tableMetadata, targetTenantId) - } - - return appendUnavailableTableConflicts(buildTenantMergePlan(sourceTables, targetTables, metadata), unavailable) -} - -export const createTenantMergeDryRun = async ( - exportData: TenantFullExport, - targetTenantId: number -) => { - const client = await pool.connect() - try { - return await createTenantMergeDryRunWithClient(client, exportData, targetTenantId) - } finally { - client.release() - } -} - -const sensitiveColumns = new Set([ - "iban_encrypted", - "bic_encrypted", - "bank_name_encrypted", - "__plainIban", - "__plainBic", - "__plainBankName", - "password_hash", - "passwordHash", - "token_hash", - "tokenHash", -]) - -const compactValue = (value: any) => { - if (value === null || typeof value !== "object") return value - const serialized = JSON.stringify(value) - return `[Struktur mit ${serialized.length} Zeichen]` -} - -const compactReviewRow = (row: Record | null, differences: string[]) => { - if (!row) return null - const displayColumns = new Set([ - "id", - "tenant", - "tenant_id", - "name", - "label", - "title", - "number", - "email", - "zip", - "short", - "accountChart", - ...differences, - ]) - - return Object.fromEntries(Object.entries(row) - .filter(([column]) => displayColumns.has(column)) - .map(([column, value]) => [ - column, - sensitiveColumns.has(column) && value ? "***" : compactValue(value), - ])) -} - -export const sanitizeTenantMergePlan = (plan: TenantMergePlan): TenantMergePlan => ({ - ...plan, - items: plan.items.filter((item) => item.kind !== "existing").map((item) => ({ - ...item, - sourceRow: compactReviewRow(item.sourceRow, item.differences) || {}, - targetRow: compactReviewRow(item.targetRow, item.differences), - })), -}) - -const prepareValue = (value: any, isJson: boolean) => { - if (!isJson || value === null || typeof value === "undefined" || typeof value === "string") return value - return JSON.stringify(value) -} - -const rowIdentity = (row: Record, columns: string[]) => - columns.map((column) => row[column]).join("\0") - -const topologicalTableOrder = (tables: string[], metadata: Record) => { - const remaining = new Set(tables) - const ordered: string[] = [] - while (remaining.size) { - const ready = Array.from(remaining).filter((table) => - (metadata[table]?.foreignKeys || []).every((foreignKey) => - !remaining.has(foreignKey.referencedTable) || foreignKey.referencedTable === table - ) - ) - const next = ready.length ? ready.sort() : [Array.from(remaining).sort()[0]] - for (const table of next) { - remaining.delete(table) - ordered.push(table) - } - } - return ordered -} - -export type TenantMergeExecutionResult = { - imported: number - updated: number - retained: number - remappedIds: number - tables: Record -} - -export const executeTenantMergeWithClient = async ( - client: any, - rawExportData: TenantFullExport, - targetTenantId: number, - decisions: Record -): Promise => { - const metadata = await loadMergeMetadata(client) - const remappedSourceTables = remapSourceTenant(rawExportData, targetTenantId) - const { available: sourceTables } = splitSourceTablesByAvailability(remappedSourceTables, metadata) - const preparedExport: TenantFullExport = prepareTenantFullExportRowsForImport({ - ...rawExportData, - tenantId: targetTenantId, - tables: Object.fromEntries(Object.entries(sourceTables).map(([table, rows]) => [table, rows.map((row) => ({ ...row }))])), - }) - const targetTables: Record[]> = {} - for (const [table, rows] of Object.entries(preparedExport.tables)) { - if (metadata[table]) targetTables[table] = await loadTargetRows(client, table, rows, metadata[table], targetTenantId) - } - const plan = buildTenantMergePlan(preparedExport.tables, targetTables, metadata) - const idMaps = new Map>() - const selected = plan.items.filter((item) => { - const decision = decisions[item.id] || item.defaultDecision - return decision === "source" && item.kind !== "existing" - }) - - for (const item of plan.items) { - const tableMetadata = metadata[item.table] - if (tableMetadata?.primaryKey.length !== 1 || !item.targetRow) continue - const key = tableMetadata.primaryKey[0] - const sourceId = item.sourceRow[key] - const targetId = item.targetRow[key] - if (sourceId !== null && typeof sourceId !== "undefined" && targetId !== null && typeof targetId !== "undefined") { - if (!idMaps.has(item.table)) idMaps.set(item.table, new Map()) - idMaps.get(item.table)!.set(sourceId, targetId) - } - } - - await client.query("begin") - await client.query("set local session_replication_role = replica") - try { - for (const item of selected.filter((entry) => entry.kind === "id_collision")) { - const tableMetadata = metadata[item.table] - if (tableMetadata.primaryKey.length !== 1) throw new Error(`ID-Kollision in ${item.table} kann nicht automatisch aufgelöst werden`) - const key = tableMetadata.primaryKey[0] - const sequenceResult = await client.query("select pg_get_serial_sequence($1, $2) as sequence_name", [`public.${item.table}`, key]) - const sequenceName = sequenceResult.rows[0]?.sequence_name - if (!sequenceName) throw new Error(`Keine Sequenz für ID-Kollision in ${item.table}.${key} gefunden`) - const allocated = await client.query("select nextval($1::regclass) as id", [sequenceName]) - const sourceId = item.sourceRow[key] - const targetId = allocated.rows[0].id - item.sourceRow[key] = targetId - if (!idMaps.has(item.table)) idMaps.set(item.table, new Map()) - idMaps.get(item.table)!.set(sourceId, targetId) - } - - for (const item of selected) { - const tableMetadata = metadata[item.table] - for (const foreignKey of tableMetadata.foreignKeys) { - const mapping = idMaps.get(foreignKey.referencedTable) - if (mapping?.has(item.sourceRow[foreignKey.column])) { - item.sourceRow[foreignKey.column] = mapping.get(item.sourceRow[foreignKey.column]) - } - } - } - - const selectedByTable = new Map() - for (const item of selected) { - const rows = selectedByTable.get(item.table) || [] - rows.push(item) - selectedByTable.set(item.table, rows) - } - const result: TenantMergeExecutionResult = { - imported: 0, - updated: 0, - retained: plan.items.length - selected.length, - remappedIds: Array.from(idMaps.values()).reduce((sum, map) => sum + Array.from(map).filter(([source, target]) => source !== target).length, 0), - tables: {}, - } - - for (const table of topologicalTableOrder(Array.from(selectedByTable.keys()), metadata)) { - const tableMetadata = metadata[table] - result.tables[table] ||= { imported: 0, updated: 0, retained: plan.items.filter((item) => item.table === table && !selected.includes(item)).length } - for (const item of selectedByTable.get(table) || []) { - const row = { ...item.sourceRow } - if (table === "tenants") { - delete row.locked - delete row.locked_by_export_job_id - delete row.lockedByExportJobId - } - const columns = Object.keys(row).filter((column) => - tableMetadata.columns.includes(column) && !tableMetadata.generatedColumns.has(column) - ) - const values = columns.map((column) => prepareValue(row[column], tableMetadata.jsonColumns.has(column))) - const placeholders = columns.map((_, index) => `$${index + 1}`).join(", ") - - if (item.kind === "conflict" && item.targetRow) { - const primaryKey = tableMetadata.primaryKey - const updateColumns = columns.filter((column) => !primaryKey.includes(column)) - if (!primaryKey.length || !updateColumns.length) continue - const whereValues = primaryKey.map((column) => item.targetRow![column]) - const assignments = updateColumns.map((column) => `${quoteIdent(column)} = $${columns.indexOf(column) + 1}`).join(", ") - const where = primaryKey.map((column, index) => `${quoteIdent(column)} = $${columns.length + index + 1}`).join(" and ") - await client.query(`update ${quoteIdent(table)} set ${assignments} where ${where}`, [...values, ...whereValues]) - result.updated += 1 - result.tables[table].updated += 1 - } else { - const inserted = await client.query( - `insert into ${quoteIdent(table)} (${columns.map(quoteIdent).join(", ")}) values (${placeholders}) on conflict do nothing`, - values - ) - if (!inserted.rowCount) throw new Error(`Datensatz in ${table} konnte wegen eines neuen Konflikts nicht importiert werden`) - result.imported += 1 - result.tables[table].imported += 1 - } - } - } - - const sourceTenant = preparedExport.tables.tenants?.find((row) => Number(row.id) === targetTenantId) - if (sourceTenant?.numberRanges) { - const targetTenantResult = await client.query(`select "numberRanges" from "tenants" where "id" = $1`, [targetTenantId]) - const targetRanges = targetTenantResult.rows[0]?.numberRanges || {} - const mergedRanges = { ...targetRanges } - for (const [key, sourceRange] of Object.entries(sourceTenant.numberRanges as Record)) { - const targetRange = targetRanges[key] - mergedRanges[key] = targetRange - ? { - ...sourceRange, - ...targetRange, - nextNumber: Math.max(Number(sourceRange?.nextNumber || 0), Number(targetRange?.nextNumber || 0)), - } - : sourceRange - } - await client.query(`update "tenants" set "numberRanges" = $1::jsonb where "id" = $2`, [JSON.stringify(mergedRanges), targetTenantId]) - } - - for (const table of selectedByTable.keys()) { - const tableMetadata = metadata[table] - if (!tableMetadata.columns.includes("id")) continue - const sequenceResult = await client.query("select pg_get_serial_sequence($1, $2) as sequence_name", [`public.${table}`, "id"]) - const sequenceName = sequenceResult.rows[0]?.sequence_name - if (!sequenceName) continue - await client.query(`select setval($1::regclass, greatest(coalesce((select max(id) from ${quoteIdent(table)}), 1), 1), true)`, [sequenceName]) - } - - await client.query("commit") - return result - } catch (err) { - await client.query("rollback") - throw err - } -} - -export const executeTenantMerge = async ( - exportData: TenantFullExport, - targetTenantId: number, - decisions: Record -) => { - const client = await pool.connect() - try { - return await executeTenantMergeWithClient(client, exportData, targetTenantId, decisions) - } finally { - client.release() - } -} diff --git a/backend/tests/tenantExportGlobalResources.test.ts b/backend/tests/tenantExportGlobalResources.test.ts deleted file mode 100644 index c9ae9fd..0000000 --- a/backend/tests/tenantExportGlobalResources.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" - -import { addTenantExportGlobalResources } from "../src/utils/tenantExportGlobalResources" - -test("adds global resources and the tenant account chart to a tenant export", async () => { - const tables: Record[]> = {} - const calls: { table: string, whereSql: string, params?: any[] }[] = [] - const rowsByTable: Record[]> = { - units: [{ id: 1, name: "Monat" }], - citys: [{ id: 1, zip: 26316 }], - countrys: [{ id: 1, name: "Deutschland" }], - accounts: [{ id: 1, accountChart: "skr04", number: "4400" }], - } - - await addTenantExportGlobalResources( - tables, - new Set(Object.keys(rowsByTable)), - { id: 42, accountChart: "skr04" }, - async (table, whereSql, params) => { - calls.push({ table, whereSql, params }) - return rowsByTable[table] - }, - (target, table, rows) => { - target[table] = rows - } - ) - - assert.deepEqual(tables, rowsByTable) - assert.deepEqual(calls, [ - { table: "units", whereSql: "true", params: undefined }, - { table: "citys", whereSql: "true", params: undefined }, - { table: "countrys", whereSql: "true", params: undefined }, - { table: "accounts", whereSql: `"accountChart" = $1`, params: ["skr04"] }, - ]) -}) - -test("skips global tables that do not exist in an older database schema", async () => { - const tables: Record[]> = {} - - await addTenantExportGlobalResources( - tables, - new Set(["units"]), - { id: 42 }, - async () => [{ id: 1, name: "Stück" }], - (target, table, rows) => { - target[table] = rows - } - ) - - assert.deepEqual(tables, { units: [{ id: 1, name: "Stück" }] }) -}) diff --git a/backend/tests/tenantImportNumberRanges.test.ts b/backend/tests/tenantImportNumberRanges.test.ts deleted file mode 100644 index feeff0b..0000000 --- a/backend/tests/tenantImportNumberRanges.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" - -import { restoreImportedTenantNumberRanges } from "../src/utils/tenantImportNumberRanges" - -test("restores number ranges when importing into an existing target tenant", async () => { - const queries: { query: string, values: unknown[] }[] = [] - const numberRanges = { - invoices: { prefix: "RE-", suffix: "", nextNumber: 4712 }, - } - const client = { - async query(query: string, values: unknown[]) { - queries.push({ query, values }) - return { rowCount: 1 } - }, - } - - const updated = await restoreImportedTenantNumberRanges(client, { - tenantId: 42, - tables: { - tenants: [{ id: 42, name: "Zieltenant", numberRanges }], - }, - }) - - assert.equal(updated, 1) - assert.deepEqual(queries, [{ - query: `update "tenants" set "numberRanges" = $1::jsonb where "id" = $2`, - values: [JSON.stringify(numberRanges), 42], - }]) -}) - -test("does not overwrite number ranges when the export contains none", async () => { - let queryCalled = false - const client = { - async query() { - queryCalled = true - return { rowCount: 1 } - }, - } - - const updated = await restoreImportedTenantNumberRanges(client, { - tenantId: 42, - tables: { tenants: [{ id: 42, name: "Zieltenant" }] }, - }) - - assert.equal(updated, 0) - assert.equal(queryCalled, false) -}) diff --git a/backend/tests/tenantMergePlan.test.ts b/backend/tests/tenantMergePlan.test.ts deleted file mode 100644 index a91e069..0000000 --- a/backend/tests/tenantMergePlan.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" - -import { buildTenantMergePlan } from "../src/utils/tenantMergePlan" - -test("classifies imports, existing rows, conflicts and global id collisions", () => { - const plan = buildTenantMergePlan({ - units: [ - { id: 1, name: "Stück", short: "Stk." }, - { id: 2, name: "Monat", short: "Mon." }, - { id: 3, name: "Stunde", short: "Std." }, - ], - customers: [ - { id: 10, tenant: 42, name: "Neu" }, - { id: 11, tenant: 42, name: "Geändert" }, - ], - }, { - units: [ - { id: 1, name: "Stück", short: "Stk.", updated_at: "later" }, - { id: 2, name: "Kilometer", short: "km" }, - { id: 9, name: "Stunde", short: "h" }, - ], - customers: [ - { id: 11, tenant: 42, name: "Zieländerung" }, - ], - }, { - units: { primaryKey: ["id"] }, - customers: { primaryKey: ["id"] }, - }) - - assert.deepEqual(plan.summary, { - import: 1, - existing: 1, - conflict: 2, - id_collision: 1, - }) - - assert.equal(plan.items.find((item) => item.label === "Monat")?.kind, "id_collision") - assert.equal(plan.items.find((item) => item.label === "Stunde")?.kind, "conflict") - assert.equal(plan.items.find((item) => item.label === "Geändert")?.defaultDecision, "target") -}) - -test("uses target as the safe default for two-way conflicts", () => { - const plan = buildTenantMergePlan({ - accounts: [{ id: 5, accountChart: "skr03", number: "8400", label: "Quelle" }], - }, { - accounts: [{ id: 99, accountChart: "skr03", number: "8400", label: "Ziel" }], - }, { - accounts: { primaryKey: ["id"] }, - }) - - assert.equal(plan.items[0].kind, "conflict") - assert.equal(plan.items[0].targetKey, "id=99") - assert.equal(plan.items[0].defaultDecision, "target") - assert.deepEqual(plan.items[0].differences.sort(), ["id", "label"]) -}) - -test("ignores maintenance locks and audit timestamps during comparison", () => { - const plan = buildTenantMergePlan({ - tenants: [{ id: 42, name: "Tenant", locked: null, updatedAt: "before" }], - }, { - tenants: [{ id: 42, name: "Tenant", locked: "maintenance_tenant", updatedAt: "after" }], - }, { - tenants: { primaryKey: ["id"] }, - }) - - assert.equal(plan.items[0].kind, "existing") -}) diff --git a/frontend/composables/useAdmin.ts b/frontend/composables/useAdmin.ts index cdda5a1..ca7adbc 100644 --- a/frontend/composables/useAdmin.ts +++ b/frontend/composables/useAdmin.ts @@ -61,35 +61,6 @@ export type TenantImportResult = { filesDone?: number filesTotal?: number error?: string | null - reviewUrl?: string -} - -export type TenantMergeKind = "import" | "existing" | "conflict" | "id_collision" -export type TenantMergeDecision = "source" | "target" -export type TenantMergeItem = { - id: string - table: string - kind: TenantMergeKind - label: string - sourceKey: string - targetKey: string | null - defaultDecision: TenantMergeDecision - allowedDecisions: TenantMergeDecision[] - differences: string[] - sourceRow: Record - targetRow: Record | null -} -export type TenantMergeReview = { - importId: string - tenantId: number - status: string - filename: string - summary: Record - tables: string[] - total: number - offset: number - limit: number - items: TenantMergeItem[] } export type TenantExportJob = { @@ -251,20 +222,6 @@ export const useAdmin = () => { }) } - const getTenantImportReview = async ( - importId: string, - query: { kind?: string; table?: string; offset?: number; limit?: number } = {}, - ): Promise => { - return await $api(`/api/admin/tenant-imports/${importId}/review`, { query }) - } - - const executeTenantImportMerge = async (importId: string, decisions: Record) => { - return await $api(`/api/admin/tenant-imports/${importId}/execute`, { - method: "POST", - body: { decisions }, - }) - } - const getSystemStatus = async (): Promise => { return await $api("/api/admin/system-status") } @@ -305,7 +262,5 @@ export const useAdmin = () => { getTenantExport, downloadTenantExport, importTenant, - getTenantImportReview, - executeTenantImportMerge, } } diff --git a/frontend/pages/administration/tenant-imports/[importId].vue b/frontend/pages/administration/tenant-imports/[importId].vue deleted file mode 100644 index 5137bc9..0000000 --- a/frontend/pages/administration/tenant-imports/[importId].vue +++ /dev/null @@ -1,267 +0,0 @@ - - - diff --git a/frontend/pages/administration/tenants/[id].vue b/frontend/pages/administration/tenants/[id].vue index 6d2938d..16b9233 100644 --- a/frontend/pages/administration/tenants/[id].vue +++ b/frontend/pages/administration/tenants/[id].vue @@ -11,30 +11,15 @@ const tenantId = Number(route.params.id) const loading = ref(true) const saving = ref(false) const exportingTenant = ref(false) -const importingTenant = ref(false) const tenantExportProgress = ref(null) -const tenantImportProgress = ref(null) const creatingUser = ref(false) const createUserModalOpen = ref(false) const createdUserPassword = ref("") -const importFileInput = ref(null) -const lastImportResult = ref(null) const lockedOptions = [ { label: "Aktiv", value: null }, { label: "Tenant-Wartung", value: "maintenance_tenant" }, @@ -165,111 +150,6 @@ const downloadTenantExport = async () => { } } -const openImportFileDialog = () => { - importFileInput.value?.click() -} - -const importTenantExport = async (event: Event) => { - const input = event.target as HTMLInputElement - const file = input.files?.[0] - if (!file || importingTenant.value) return - - importingTenant.value = true - tenantImportProgress.value = null - lastImportResult.value = null - - try { - const targetTenantId = tenantForm.value?.id || tenantId - const isZipExport = file.name.endsWith(".zip") || file.name.endsWith(".fedeo-export.zip") || file.type === "application/zip" - let result - - if (isZipExport) { - const formData = new FormData() - formData.append("file", file) - formData.append("targetTenantId", String(targetTenantId)) - result = await admin.importTenant(formData) - } else { - result = await admin.importTenant({ - exportData: JSON.parse(await file.text()), - targetTenantId, - }) - } - - const importJobId = result.importId || result.exportId - if (importJobId) { - let job = result - - tenantImportProgress.value = { - status: job.status, - filesDone: job.filesDone || 0, - filesTotal: job.filesTotal || 0, - filename: job.filename || file.name, - } - - while (!["ready", "review", "failed"].includes(job.status)) { - await new Promise((resolve) => setTimeout(resolve, 1500)) - job = await admin.getTenantExport(importJobId) - tenantImportProgress.value = { - status: job.status, - filesDone: job.filesDone || 0, - filesTotal: job.filesTotal || 0, - filename: job.filename || file.name, - } - } - - if (job.status === "failed") { - throw new Error(job.error || "Import konnte nicht abgeschlossen werden.") - } - - if (job.status === "review") { - await router.push(`/administration/tenant-imports/${importJobId}`) - return - } - - await fetchTenant() - await auth.fetchMe() - await auth.switchTenant(String(job.tenantId || targetTenantId)) - - toast.add({ - title: "Mandantenimport abgeschlossen", - description: job.filename || file.name, - color: "green", - }) - return - } - - const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0) - - lastImportResult.value = { - tenantId: result.tenantId, - tableCount: result.tables?.length || 0, - rowCount, - restoredFiles: result.files?.restored || 0, - skippedFiles: result.files?.skipped || 0, - } - - await fetchTenant() - await auth.fetchMe() - await auth.switchTenant(String(result.tenantId)) - - toast.add({ - title: "Mandantenimport abgeschlossen", - description: `${rowCount} Datensätze und ${lastImportResult.value.restoredFiles} Dateien verarbeitet.`, - color: "green", - }) - } catch (err: any) { - console.error("[administration/tenants/import]", err) - toast.add({ - title: "Mandant konnte nicht importiert werden", - description: err?.data?.error || err?.message || "Unbekannter Fehler", - color: "red", - }) - } finally { - importingTenant.value = false - input.value = "" - } -} - const createTenantUser = async () => { if (!tenantForm.value || creatingUser.value) return @@ -398,7 +278,7 @@ onMounted(async () => { -
+
Full Export

@@ -424,50 +304,7 @@ onMounted(async () => { Export herunterladen

- -
-
Import
-

- Spielt einen FEDEO-Mandantenexport auf diesem Server ein. Bestehende Datensätze mit gleicher ID werden übersprungen. -

- - - Export importieren - -
- -

- {{ tenantImportProgress.status === 'ready' ? 'Import abgeschlossen' : 'Import wird verarbeitet' }} - - · {{ tenantImportProgress.filesDone }} / {{ tenantImportProgress.filesTotal }} Schritte - -

-
-
- -