Connect tenant merge planner to exports

This commit is contained in:
2026-08-06 12:26:00 +02:00
parent f691e3db33
commit fd330b28c2
2 changed files with 166 additions and 29 deletions

View File

@@ -1025,41 +1025,14 @@ export const importTenantFullExportArchive = async (
archiveBuffer: Buffer,
options: ImportOptions = {}
): Promise<ImportResult> => {
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<TenantFullExport> => {
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()
}
}

View File

@@ -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<string, MergeDatabaseMetadata> = {}
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<string, Record<string, any>[]> = {}
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<string, any>[],
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<TenantMergePlan> => {
const metadata = await loadMergeMetadata(client)
const sourceTables = remapSourceTenant(exportData, targetTenantId)
const targetTables: Record<string, Record<string, any>[]> = {}
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()
}
}