import { FastifyInstance } from "fastify" import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3" import archiver from "archiver" import { createReadStream, createWriteStream } from "fs" import { stat, unlink } from "fs/promises" import os from "os" import path from "path" import { BlobReader, TextWriter, Uint8ArrayWriter, ZipReader } from "@zip.js/zip.js" import { pool } from "../../db" import { s3 } from "./s3" import { secrets } from "./secrets" import { decrypt, encrypt } from "./crypt" type TableRows = Record[]> type TableMetadata = { columns: string[] jsonColumns: Set generatedColumns: Set } export type TenantFullExport = { format: "fedeo.tenant-full-export" version: 1 exportedAt: string tenantId: number tables: TableRows files: { id: string path: string name: string | null mimeType: string | null size: number | null contentBase64: string | null missing?: boolean error?: string }[] } type ImportResult = { tenantId: number tables: { table: string; rows: number }[] files: { restored: number; skipped: number } } type ImportOptions = { targetTenantId?: number | null onProgress?: (progress: { done: number; total: number; message?: string }) => Promise | void } type BuildTenantFullExportOptions = { includeFileContents?: boolean } type TenantArchiveManifest = { format: "fedeo.tenant-archive-export" version: 1 exportedAt: string tenantId: number tables: { name: string; rows: number; path: string }[] files: { id: string path: string archivePath: string | null name: string | null mimeType: string | null size: number | null missing?: boolean error?: string }[] } type ArchiveExportOptions = { filename?: string storagePath?: string onProgress?: (progress: { filesDone: number; filesTotal: number }) => Promise | void } const ENTITY_BANKACCOUNT_PLAIN_FIELDS = { iban: "__plainIban", bic: "__plainBic", bankName: "__plainBankName", } const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"` const matrixServerName = () => process.env.MATRIX_SERVER_NAME || secrets.MATRIX_SERVER_NAME || process.env.DOMAIN || "localhost" const normalizeMatrixLocalpartSeed = (value: string) => { const normalized = value .toLowerCase() .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/ä/g, "a") .replace(/ö/g, "o") .replace(/ü/g, "u") .replace(/ß/g, "ss") .replace(/[^a-z0-9._=-]+/g, "_") .replace(/_+/g, "_") .replace(/^[._=-]+|[._=-]+$/g, "") return normalized || "user" } const normalizeMatrixAliasSeed = (value: string) => normalizeMatrixLocalpartSeed(value) .replace(/[.=]/g, "_") .replace(/_+/g, "_") const tenantRoomAliasLocalpart = ( tenant: { id: number, short?: string | null, name?: string | null }, roomKey: string ) => { const tenantSeed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`) const roomSeed = normalizeMatrixAliasSeed(roomKey) return `fedeo_${tenantSeed}_${tenant.id}_${roomSeed}` } const tenantRoomAlias = ( tenant: { id: number, short?: string | null, name?: string | null }, roomKey: string ) => `#${tenantRoomAliasLocalpart(tenant, roomKey)}:${matrixServerName()}` const tableColumns = async (client: any) => { const result = 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 columnsByTable = new Map() for (const row of result.rows) { const metadata = columnsByTable.get(row.table_name) || { columns: [], jsonColumns: new Set(), generatedColumns: new Set(), } metadata.columns.push(row.column_name) if (row.data_type === "json" || row.data_type === "jsonb") { metadata.jsonColumns.add(row.column_name) } if (row.is_generated === "ALWAYS") { metadata.generatedColumns.add(row.column_name) } columnsByTable.set(row.table_name, metadata) } return columnsByTable } const loadRows = async (client: any, table: string, whereSql: string, params: any[] = []) => { const result = await client.query(`select * from ${quoteIdent(table)} where ${whereSql}`, params) return result.rows } const collectIds = (rows: Record[], column: string) => { return Array.from(new Set(rows.map((row) => row[column]).filter(Boolean))) } const addRows = (tables: TableRows, table: string, rows: Record[]) => { if (!rows.length) { if (!tables[table]) tables[table] = [] return } const existingRows = tables[table] || [] const existingKeys = new Set(existingRows.map((row) => JSON.stringify(row))) for (const row of rows) { const key = JSON.stringify(row) if (!existingKeys.has(key)) { existingRows.push(row) existingKeys.add(key) } } tables[table] = existingRows } const decryptEntityBankAccountsForExport = (rows: Record[] = []) => { return rows.map((row) => { const nextRow = { ...row } try { nextRow[ENTITY_BANKACCOUNT_PLAIN_FIELDS.iban] = row.iban_encrypted ? decrypt(row.iban_encrypted) : null nextRow[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bic] = row.bic_encrypted ? decrypt(row.bic_encrypted) : null nextRow[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bankName] = row.bank_name_encrypted ? decrypt(row.bank_name_encrypted) : null } catch (err: any) { throw new Error(`Bankverbindung ${row.id || ""} konnte für den Export nicht entschlüsselt werden: ${err?.message || err}`) } return nextRow }) } const isMissingObjectError = (err: any) => err?.Code === "NoSuchKey" || err?.name === "NoSuchKey" || err?.$metadata?.httpStatusCode === 404 const loadObjectAsBase64 = async (path: string) => { try { const { Body } = await s3.send(new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: path, })) const chunks: Buffer[] = [] for await (const chunk of Body as any) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) } return { contentBase64: Buffer.concat(chunks).toString("base64"), missing: false, error: null, } } catch (err: any) { if (!isMissingObjectError(err)) throw err return { contentBase64: null, missing: true, error: err?.Code || err?.name || "NoSuchKey", } } } const loadObjectStream = async (path: string) => { try { const { Body } = await s3.send(new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: path, })) return { body: Body as any, missing: false, error: null, } } catch (err: any) { if (!isMissingObjectError(err)) throw err return { body: null, missing: true, error: err?.Code || err?.name || "NoSuchKey", } } } const archiveEntryPathForFile = (path: string) => { const normalizedPath = path .replace(/\\/g, "/") .replace(/^\/+/, "") .split("/") .filter((part) => part && part !== "." && part !== "..") .join("/") return `files/${normalizedPath || "unnamed-file"}` } const filenameFromPath = (path: string) => { const filename = path .replace(/\\/g, "/") .split("/") .filter(Boolean) .pop() if (!filename) return null try { return decodeURIComponent(filename) } catch { return filename } } const jsonBuffer = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8") export const buildTenantFullExport = async ( server: FastifyInstance, tenantId: number, options: BuildTenantFullExportOptions = {} ): Promise => { const client = await pool.connect() const includeFileContents = options.includeFileContents !== false try { const columnsByTable = await tableColumns(client) const tables: TableRows = {} const tenantRows = await loadRows(client, "tenants", "id = $1", [tenantId]) if (!tenantRows.length) throw new Error("Tenant nicht gefunden") addRows(tables, "tenants", tenantRows) for (const [table, metadata] of columnsByTable.entries()) { if (table === "tenants") continue const { columns } = metadata const tenantColumn = columns.includes("tenant") ? "tenant" : columns.includes("tenant_id") ? "tenant_id" : null if (!tenantColumn) continue const rows = await loadRows(client, table, `${quoteIdent(tenantColumn)} = $1`, [tenantId]) addRows(tables, table, rows) } const profileIds = collectIds(tables.auth_profiles || [], "id") const userIds = Array.from(new Set([ ...collectIds(tables.auth_tenant_users || [], "user_id"), ...collectIds(tables.auth_profiles || [], "user_id"), ...collectIds(tables.auth_user_roles || [], "user_id"), ])) const roleIds = Array.from(new Set([ ...collectIds(tables.auth_roles || [], "id"), ...collectIds(tables.auth_user_roles || [], "role_id"), ])) if (userIds.length) { addRows(tables, "auth_users", await loadRows(client, "auth_users", "id = any($1::uuid[])", [userIds])) } if (roleIds.length) { addRows(tables, "auth_roles", await loadRows(client, "auth_roles", "id = any($1::uuid[])", [roleIds])) addRows(tables, "auth_role_permissions", await loadRows(client, "auth_role_permissions", "role_id = any($1::uuid[])", [roleIds])) } if (profileIds.length) { addRows(tables, "auth_profile_branches", await loadRows(client, "auth_profile_branches", "profile_id = any($1::uuid[])", [profileIds])) addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds])) } if (tables.entitybankaccounts?.length) { tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts) } const files = [] const filePaths = new Set() const addExportFile = async (file: { id: string path: string name?: string | null mimeType?: string | null size?: number | null }) => { if (!file.path || filePaths.has(file.path)) return filePaths.add(file.path) const object = includeFileContents ? await loadObjectAsBase64(file.path) : { contentBase64: null, missing: false, error: null } if (object.missing) { server.log.warn({ fileId: file.id, path: file.path, bucket: secrets.S3_BUCKET, error: object.error, }, "Tenant full export skipped missing S3 object") } files.push({ id: file.id, path: file.path, name: file.name || filenameFromPath(file.path), mimeType: file.mimeType || null, size: file.size || null, contentBase64: object.contentBase64, missing: object.missing || undefined, error: object.error || undefined, }) } for (const file of tables.files || []) { if (!file.path) continue await addExportFile({ id: file.id, path: file.path, name: file.name || null, mimeType: file.mimeType || null, size: file.size || null, }) } for (const letterhead of tables.letterheads || []) { if (!letterhead.path) continue await addExportFile({ id: `letterhead:${letterhead.id}`, path: letterhead.path, name: filenameFromPath(letterhead.path), mimeType: "application/pdf", size: null, }) } return { format: "fedeo.tenant-full-export", version: 1, exportedAt: new Date().toISOString(), tenantId, tables, files, } } finally { client.release() } } export const createTenantFullExportArchive = async ( server: FastifyInstance, tenantId: number, options: ArchiveExportOptions = {} ) => { const exportData = await buildTenantFullExport(server, tenantId, { includeFileContents: false }) const safeTenantName = String(exportData.tables.tenants?.[0]?.short || exportData.tables.tenants?.[0]?.name || tenantId) .replace(/[^a-z0-9_-]+/gi, "-") .replace(/^-+|-+$/g, "") .toLowerCase() const filename = options.filename || `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.fedeo-export.zip` const storagePath = options.storagePath || `tenant-exports/${tenantId}/${Date.now()}-${filename}` const fileRows = exportData.files || [] const archive = archiver("zip", { zlib: { level: 6 } }) const tempPath = path.join(os.tmpdir(), `fedeo-tenant-export-${tenantId}-${Date.now()}-${filename}`) const output = createWriteStream(tempPath) const archiveComplete = new Promise((resolve, reject) => { output.on("close", resolve) output.on("error", reject) archive.on("error", reject) archive.on("warning", (err) => { if ((err as NodeJS.ErrnoException).code === "ENOENT") return reject(err) }) }) archive.pipe(output) try { const tables = Object.entries(exportData.tables) .sort(([a], [b]) => a.localeCompare(b)) .map(([table, rows]) => { const path = `tables/${table}.json` archive.append(jsonBuffer(rows), { name: path }) return { name: table, rows: rows.length, path } }) const manifestFiles: TenantArchiveManifest["files"] = [] let filesDone = 0 const filesTotal = fileRows.filter((file) => file.path).length for (const file of fileRows) { if (!file.path) continue const archivePath = archiveEntryPathForFile(file.path) const object = await loadObjectStream(file.path) if (object.missing) { server.log.warn({ fileId: file.id, path: file.path, bucket: secrets.S3_BUCKET, error: object.error, }, "Tenant archive export skipped missing S3 object") } else { archive.append(object.body, { name: archivePath }) } filesDone += 1 manifestFiles.push({ id: file.id, path: file.path, archivePath: object.missing ? null : archivePath, name: file.name || null, mimeType: file.mimeType || null, size: file.size || null, missing: object.missing || undefined, error: object.error || undefined, }) if (filesDone % 10 === 0 || filesDone === filesTotal) { await options.onProgress?.({ filesDone, filesTotal }) } } const manifest: TenantArchiveManifest = { format: "fedeo.tenant-archive-export", version: 1, exportedAt: exportData.exportedAt, tenantId, tables, files: manifestFiles, } archive.append(jsonBuffer(manifest), { name: "manifest.json" }) await archive.finalize() await archiveComplete const { size } = await stat(tempPath) await s3.send(new PutObjectCommand({ Bucket: secrets.S3_BUCKET, Key: storagePath, Body: createReadStream(tempPath), ContentLength: size, ContentType: "application/zip", ContentDisposition: `attachment; filename="${filename}"`, })) return { filename, storagePath, contentType: "application/zip", size, filesTotal, filesDone, } } finally { await unlink(tempPath).catch(() => undefined) } } const restoreFiles = async (exportData: TenantFullExport) => { let restored = 0 let skipped = 0 for (const file of exportData.files || []) { if (!file.path || !file.contentBase64) { skipped += 1 continue } const body = Buffer.from(file.contentBase64, "base64") await s3.send(new PutObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path, Body: body, ContentType: file.mimeType || "application/octet-stream", ContentLength: body.length, })) restored += 1 } return { restored, skipped } } const readZipTextEntry = async (entriesByName: Map, name: string) => { const entry = entriesByName.get(name) if (!entry) throw new Error(`${name} fehlt im Mandantenexport`) return entry.getData(new TextWriter()) } const restoreArchiveFiles = async ( entriesByName: Map, exportData: TenantFullExport, manifest: TenantArchiveManifest ) => { let restored = 0 let skipped = 0 const filesByPath = new Map((manifest.files || []).map((file) => [file.path, file])) for (const fileRow of exportData.files || []) { const originalPath = fileRow.path if (!originalPath) { skipped += 1 continue } const manifestFile = filesByPath.get(originalPath) const archivePath = manifestFile?.archivePath if (!archivePath || manifestFile?.missing) { skipped += 1 continue } const entry = entriesByName.get(archivePath) if (!entry) { skipped += 1 continue } const content = await entry.getData(new Uint8ArrayWriter()) await s3.send(new PutObjectCommand({ Bucket: secrets.S3_BUCKET, Key: fileRow.path, Body: Buffer.from(content), ContentType: fileRow.mimeType || manifestFile.mimeType || "application/octet-stream", ContentLength: content.length, })) restored += 1 } 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 || [] for (const row of rows) { const plainIban = row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.iban] const plainBic = row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bic] const plainBankName = row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bankName] if (typeof plainIban === "string" && typeof plainBic === "string" && typeof plainBankName === "string") { row.iban_encrypted = encrypt(plainIban) row.bic_encrypted = encrypt(plainBic) row.bank_name_encrypted = encrypt(plainBankName) } delete row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.iban] delete row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bic] delete row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bankName] } } const prepareCommunicationRoomsForImport = (exportData: TenantFullExport) => { const rows = exportData.tables.communication_rooms || [] if (!rows.length) return const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [ Number(tenant.id), { id: Number(tenant.id), name: tenant.name, short: tenant.short, }, ])) for (const row of rows) { const tenantId = Number(row.tenant_id) const tenant = tenantById.get(tenantId) row.matrix_room_id = null row.parent_space_room_id = null if (tenant && row.key) { row.matrix_alias = tenantRoomAlias(tenant, String(row.key)) } else { row.matrix_alias = null } } } const cleanupImportedCommunicationRooms = async (client: any, exportData: TenantFullExport) => { const rows = exportData.tables.communication_rooms || [] if (!rows.length) return 0 const tenantById = new Map((exportData.tables.tenants || []).map((tenant) => [ Number(tenant.id), { id: Number(tenant.id), name: tenant.name, short: tenant.short, }, ])) let cleaned = 0 for (const row of rows) { const tenantId = Number(row.tenant_id) const key = String(row.key || "") const tenant = tenantById.get(tenantId) if (!tenantId || !key || !tenant) continue const alias = tenantRoomAlias(tenant, key) const result = await client.query( ` update communication_rooms set matrix_room_id = null, parent_space_room_id = null, matrix_alias = $3, updated_at = now() where tenant_id = $1 and key = $2 `, [tenantId, key, alias] ) cleaned += result.rowCount || 0 } return cleaned } const prepareColumnValue = (value: any, isJsonColumn: boolean) => { if (!isJsonColumn || value === null || typeof value === "undefined") return value if (typeof value === "string") return value return JSON.stringify(value) } const insertRows = async (client: any, table: string, rows: Record[], metadata: TableMetadata) => { if (!rows.length) return 0 let inserted = 0 const availableColumns = new Set(metadata.columns.filter((column) => !metadata.generatedColumns.has(column))) for (const row of rows) { const rowColumns = Object.keys(row).filter((column) => availableColumns.has(column)) if (!rowColumns.length) continue const placeholders = rowColumns.map((_, index) => `$${index + 1}`).join(", ") const values = rowColumns.map((column) => prepareColumnValue(row[column], metadata.jsonColumns.has(column))) await client.query( `insert into ${quoteIdent(table)} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders}) on conflict do nothing`, values ) inserted += 1 } return inserted } const insertAuthTenantUserRows = async (client: any, rows: Record[], metadata: TableMetadata) => { if (!rows.length) return 0 let inserted = 0 const availableColumns = new Set(metadata.columns.filter((column) => !metadata.generatedColumns.has(column))) for (const row of rows) { if (!row.tenant_id || !row.user_id) continue const existing = await client.query( `select 1 from ${quoteIdent("auth_tenant_users")} where ${quoteIdent("tenant_id")} = $1 and ${quoteIdent("user_id")} = $2 limit 1`, [row.tenant_id, row.user_id] ) if (existing.rows.length) continue const rowColumns = Object.keys(row).filter((column) => availableColumns.has(column)) if (!rowColumns.length) continue const placeholders = rowColumns.map((_, index) => `$${index + 1}`).join(", ") const values = rowColumns.map((column) => prepareColumnValue(row[column], metadata.jsonColumns.has(column))) await client.query( `insert into ${quoteIdent("auth_tenant_users")} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders})`, values ) inserted += 1 } return inserted } const insertIdentityRowsWithRemap = async ( client: any, table: string, rows: Record[], metadata: TableMetadata ) => { if (!rows.length) return { count: 0, idMap: new Map() } let count = 0 const idMap = new Map() const availableColumns = new Set(metadata.columns.filter((column) => !metadata.generatedColumns.has(column))) for (const row of rows) { const sourceId = Number(row.id) if (!sourceId) continue const existing = await client.query(`select * from ${quoteIdent(table)} where ${quoteIdent("id")} = $1 limit 1`, [sourceId]) const existingTenant = existing.rows[0]?.tenant ?? existing.rows[0]?.tenant_id const rowTenant = row.tenant ?? row.tenant_id const shouldPreserveId = existing.rows.length === 0 const shouldReuseExisting = existing.rows.length > 0 && rowTenant && existingTenant === rowTenant const rowForInsert = shouldPreserveId ? row : { ...row, id: undefined } const rowColumns = Object .keys(rowForInsert) .filter((column) => availableColumns.has(column) && typeof rowForInsert[column] !== "undefined") if (shouldReuseExisting) { idMap.set(sourceId, sourceId) continue } if (!rowColumns.length) continue const placeholders = rowColumns.map((_, index) => `$${index + 1}`).join(", ") const values = rowColumns.map((column) => prepareColumnValue(rowForInsert[column], metadata.jsonColumns.has(column))) const inserted = await client.query( `insert into ${quoteIdent(table)} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders}) returning ${quoteIdent("id")}`, values ) const targetId = Number(inserted.rows[0]?.id) if (targetId) { idMap.set(sourceId, targetId) } count += 1 } return { count, idMap } } const remapTableColumn = (rows: Record[] = [], column: string, idMap: Map) => { if (!idMap.size) return for (const row of rows) { const currentValue = Number(row[column]) const mappedValue = idMap.get(currentValue) if (mappedValue) row[column] = mappedValue } } const refreshSequences = async (client: any, columnsByTable: Map) => { for (const [table, metadata] of columnsByTable.entries()) { const { columns } = metadata if (!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]) } } export const importTenantFullExport = async ( server: FastifyInstance, rawExportData: TenantFullExport, options: ImportOptions = {} ): Promise => { if (rawExportData?.format !== "fedeo.tenant-full-export" || rawExportData.version !== 1) { throw new Error("Ungültiges FEDEO Mandantenexport-Format") } const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId) encryptEntityBankAccountRowsForImport(exportData) prepareCommunicationRoomsForImport(exportData) const client = await pool.connect() const importOrder = [ "tenants", "auth_users", "auth_roles", "auth_role_permissions", "auth_tenant_users", "auth_profiles", "auth_user_roles", "auth_profile_branches", "auth_profile_teams", ] try { 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) const specialTables = [ columnsByTable.has("auth_tenant_users") ? "auth_tenant_users" : null, columnsByTable.has("bankaccounts") ? "bankaccounts" : null, columnsByTable.has("bankstatements") ? "bankstatements" : null, columnsByTable.has("letterheads") ? "letterheads" : null, ].filter(Boolean) as string[] const regularTableNames = tableNames.filter((table) => !specialTables.includes(table)) const progressTotal = Math.max(1, specialTables.length + regularTableNames.length + 3) let progressDone = 0 const reportProgress = async (message: string) => { await options.onProgress?.({ done: Math.min(progressDone, progressTotal), total: progressTotal, message, }) } await client.query("begin") await client.query("set local session_replication_role = replica") await reportProgress("Dateien werden vorbereitet") const files = await restoreFiles(exportData) progressDone += 1 await reportProgress("Dateien vorbereitet") const importedTables: { table: string; rows: number }[] = [] const authTenantUsersMetadata = columnsByTable.get("auth_tenant_users") if (authTenantUsersMetadata) { const count = await insertAuthTenantUserRows(client, exportData.tables.auth_tenant_users || [], authTenantUsersMetadata) importedTables.push({ table: "auth_tenant_users", rows: count }) progressDone += 1 await reportProgress("Tenant-Zuordnungen importiert") } const bankaccountMetadata = columnsByTable.get("bankaccounts") if (bankaccountMetadata) { const result = await insertIdentityRowsWithRemap(client, "bankaccounts", exportData.tables.bankaccounts || [], bankaccountMetadata) remapTableColumn(exportData.tables.bankstatements, "account", result.idMap) importedTables.push({ table: "bankaccounts", rows: result.count }) progressDone += 1 await reportProgress("Bankkonten importiert") } const bankstatementMetadata = columnsByTable.get("bankstatements") if (bankstatementMetadata) { const result = await insertIdentityRowsWithRemap(client, "bankstatements", exportData.tables.bankstatements || [], bankstatementMetadata) remapTableColumn(exportData.tables.statementallocations, "bs_id", result.idMap) remapTableColumn(exportData.tables.historyitems, "bankstatement", result.idMap) importedTables.push({ table: "bankstatements", rows: result.count }) progressDone += 1 await reportProgress("Bankauszüge importiert") } const letterheadMetadata = columnsByTable.get("letterheads") if (letterheadMetadata) { const result = await insertIdentityRowsWithRemap(client, "letterheads", exportData.tables.letterheads || [], letterheadMetadata) remapTableColumn(exportData.tables.createddocuments, "letterhead", result.idMap) importedTables.push({ table: "letterheads", rows: result.count }) progressDone += 1 await reportProgress("Briefpapiere importiert") } for (const table of regularTableNames) { const rows = exportData.tables[table] || [] const metadata = columnsByTable.get(table) if (!metadata) continue const count = await insertRows(client, table, rows, metadata) importedTables.push({ table, rows: count }) progressDone += 1 await reportProgress(`${table} importiert`) } const cleanedCommunicationRooms = await cleanupImportedCommunicationRooms(client, exportData) if (cleanedCommunicationRooms) { importedTables.push({ table: "communication_rooms_matrix_reset", rows: cleanedCommunicationRooms }) } progressDone += 1 await reportProgress("Kommunikationsräume bereinigt") await refreshSequences(client, columnsByTable) progressDone = progressTotal await reportProgress("Import abgeschlossen") await client.query("commit") return { tenantId: exportData.tenantId, tables: importedTables, files, } } catch (err) { await client.query("rollback") throw err } finally { client.release() } } export const importTenantFullExportArchive = async ( server: FastifyInstance, archiveBuffer: Buffer, options: ImportOptions = {} ): 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)) } 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}/` 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() } }