diff --git a/backend/src/utils/tenantFullExport.ts b/backend/src/utils/tenantFullExport.ts index e8ab4be..523099b 100644 --- a/backend/src/utils/tenantFullExport.ts +++ b/backend/src/utils/tenantFullExport.ts @@ -1025,41 +1025,14 @@ 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 - - if (manifest?.format !== "fedeo.tenant-archive-export" || manifest.version !== 1) { - throw new Error("Ungültiges FEDEO Mandantenarchiv-Format") - } - - const tables: TableRows = {} - for (const table of manifest.tables || []) { - tables[table.name] = JSON.parse(await readZipTextEntry(entriesByName, table.path)) - } - - const rawExportData: TenantFullExport = { - format: "fedeo.tenant-full-export", - version: 1, - exportedAt: manifest.exportedAt, - tenantId: manifest.tenantId, - tables, - files: (manifest.files || []).map((file) => ({ - id: file.id, - path: file.path, - name: file.name, - mimeType: file.mimeType, - size: file.size, - contentBase64: null, - missing: file.missing, - error: file.error, - })), - } - - const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId) const sourceTenantId = rawExportData.tenantId const targetTenantId = exportData.tenantId const sourcePrefix = `${sourceTenantId}/` @@ -1086,3 +1059,43 @@ export const importTenantFullExportArchive = async ( await reader.close() } } + +export const readTenantFullExportArchive = async (archiveBuffer: Buffer): Promise => { + 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 + + if (manifest?.format !== "fedeo.tenant-archive-export" || manifest.version !== 1) { + throw new Error("Ungültiges FEDEO Mandantenarchiv-Format") + } + + const tables: TableRows = {} + for (const table of manifest.tables || []) { + tables[table.name] = JSON.parse(await readZipTextEntry(entriesByName, table.path)) + } + + return { + format: "fedeo.tenant-full-export", + version: 1, + exportedAt: manifest.exportedAt, + tenantId: manifest.tenantId, + tables, + files: (manifest.files || []).map((file) => ({ + id: file.id, + path: file.path, + name: file.name, + mimeType: file.mimeType, + size: file.size, + contentBase64: null, + missing: file.missing, + error: file.error, + })), + } + + } finally { + await reader.close() + } +} diff --git a/backend/src/utils/tenantMergeService.ts b/backend/src/utils/tenantMergeService.ts new file mode 100644 index 0000000..0c99102 --- /dev/null +++ b/backend/src/utils/tenantMergeService.ts @@ -0,0 +1,124 @@ +import { pool } from "../../db" +import type { TenantFullExport } from "./tenantFullExport" +import { buildTenantMergePlan, type TenantMergePlan, type TenantMergeTableMetadata } from "./tenantMergePlan" + +type MergeDatabaseMetadata = TenantMergeTableMetadata & { + columns: 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 + from information_schema.columns + where table_schema = 'public' + order by table_name, ordinal_position + `) + 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: [] } + metadata[row.table_name].columns.push(row.column_name) + } + for (const row of primaryKeyResult.rows) { + metadata[row.table_name] ||= { columns: [], primaryKey: [] } + metadata[row.table_name].primaryKey.push(row.column_name) + } + + 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 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 sourceTables = remapSourceTenant(exportData, targetTenantId) + 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 buildTenantMergePlan(sourceTables, targetTables, metadata) +} + +export const createTenantMergeDryRun = async ( + exportData: TenantFullExport, + targetTenantId: number +) => { + const client = await pool.connect() + try { + return await createTenantMergeDryRunWithClient(client, exportData, targetTenantId) + } finally { + client.release() + } +}