diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 9f52e7e..daf10a8 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -15,6 +15,8 @@ import { import { generateRandomPassword, hashPassword } from "../utils/password"; import { sendMail } from "../utils/mailer"; import { ensureTenantBaseData } from "../modules/bootstrap.service"; +import { buildTenantFullExport, importTenantFullExport } from "../utils/tenantFullExport"; +import type { TenantFullExport } from "../utils/tenantFullExport"; export default async function adminRoutes(server: FastifyInstance) { const deriveNameFromEmail = (email: string) => { @@ -929,6 +931,57 @@ export default async function adminRoutes(server: FastifyInstance) { } }); + // ------------------------------------------------------------- + // GET /admin/tenants/:tenant_id/export + // ------------------------------------------------------------- + server.get("/admin/tenants/:tenant_id/export", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const { tenant_id } = req.params as { tenant_id: string }; + const tenantId = Number(tenant_id); + if (!tenantId) { + return reply.code(400).send({ error: "tenant_id required" }); + } + + const exportData = await buildTenantFullExport(server, tenantId); + const safeTenantName = String(exportData.tables.tenants?.[0]?.short || exportData.tables.tenants?.[0]?.name || tenantId) + .replace(/[^a-z0-9_-]+/gi, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); + const filename = `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.json`; + + reply.header("Content-Type", "application/json"); + reply.header("Content-Disposition", `attachment; filename="${filename}"`); + return reply.send(exportData); + } catch (err) { + console.error("ERROR /admin/tenants/:tenant_id/export:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + + // ------------------------------------------------------------- + // POST /admin/tenant-imports + // ------------------------------------------------------------- + server.post("/admin/tenant-imports", { bodyLimit: 1024 * 1024 * 1024 }, async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const exportData = req.body as TenantFullExport; + const result = await importTenantFullExport(server, exportData); + + return { + success: true, + ...result, + }; + } catch (err: any) { + console.error("ERROR /admin/tenant-imports:", err); + return reply.code(500).send({ error: err?.message || "Internal Server Error" }); + } + }); + // ------------------------------------------------------------- // PUT /admin/users/:user_id/access // ------------------------------------------------------------- diff --git a/backend/src/utils/tenantFullExport.ts b/backend/src/utils/tenantFullExport.ts new file mode 100644 index 0000000..50f856d --- /dev/null +++ b/backend/src/utils/tenantFullExport.ts @@ -0,0 +1,293 @@ +import { FastifyInstance } from "fastify" +import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3" + +import { pool } from "../../db" +import { s3 } from "./s3" +import { secrets } from "./secrets" + +type TableRows = Record[]> + +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 + }[] +} + +type ImportResult = { + tenantId: number + tables: { table: string; rows: number }[] + files: { restored: number; skipped: number } +} + +const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"` + +const tableColumns = async (client: any) => { + const result = await client.query(` + select table_name, column_name + 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 columns = columnsByTable.get(row.table_name) || [] + columns.push(row.column_name) + columnsByTable.set(row.table_name, columns) + } + + 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 loadObjectAsBase64 = async (path: string) => { + 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 Buffer.concat(chunks).toString("base64") +} + +export const buildTenantFullExport = async (server: FastifyInstance, tenantId: number): Promise => { + const client = await pool.connect() + + 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, columns] of columnsByTable.entries()) { + if (table === "tenants") continue + + 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])) + } + + const fileRows = tables.files || [] + const files = [] + + for (const file of fileRows) { + if (!file.path) continue + + files.push({ + id: file.id, + path: file.path, + name: file.name || null, + mimeType: file.mimeType || null, + size: file.size || null, + contentBase64: await loadObjectAsBase64(file.path), + }) + } + + return { + format: "fedeo.tenant-full-export", + version: 1, + exportedAt: new Date().toISOString(), + tenantId, + tables, + files, + } + } finally { + client.release() + } +} + +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 insertRows = async (client: any, table: string, rows: Record[], columns: string[]) => { + if (!rows.length) return 0 + + let inserted = 0 + const availableColumns = new Set(columns) + + 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) => row[column]) + + await client.query( + `insert into ${quoteIdent(table)} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders}) on conflict do nothing`, + values + ) + inserted += 1 + } + + return inserted +} + +const refreshSequences = async (client: any, columnsByTable: Map) => { + for (const [table, columns] of columnsByTable.entries()) { + 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, exportData: TenantFullExport): Promise => { + if (exportData?.format !== "fedeo.tenant-full-export" || exportData.version !== 1) { + throw new Error("Ungültiges FEDEO Mandantenexport-Format") + } + + 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) + + await client.query("begin") + await client.query("set local session_replication_role = replica") + const files = await restoreFiles(exportData) + + const importedTables: { table: string; rows: number }[] = [] + for (const table of tableNames) { + const rows = exportData.tables[table] || [] + const columns = columnsByTable.get(table) + if (!columns) continue + + const count = await insertRows(client, table, rows, columns) + importedTables.push({ table, rows: count }) + } + + await refreshSequences(client, columnsByTable) + await client.query("commit") + + return { + tenantId: exportData.tenantId, + tables: importedTables, + files, + } + } catch (err) { + await client.query("rollback") + throw err + } finally { + client.release() + } +} diff --git a/frontend/composables/useAdmin.ts b/frontend/composables/useAdmin.ts index b48ab4c..b0be327 100644 --- a/frontend/composables/useAdmin.ts +++ b/frontend/composables/useAdmin.ts @@ -48,6 +48,13 @@ export type AdminOverview = { unassignedProfiles: AdminUserProfile[] } +export type TenantImportResult = { + success: boolean + tenantId: number + tables: { table: string; rows: number }[] + files: { restored: number; skipped: number } +} + export const useAdmin = () => { const { $api } = useNuxtApp() @@ -110,6 +117,19 @@ export const useAdmin = () => { }) } + const exportTenant = async (id: number): Promise => { + return await $api(`/api/admin/tenants/${id}/export`, { + responseType: "blob", + }) + } + + const importTenant = async (body: Record): Promise => { + return await $api("/api/admin/tenant-imports", { + method: "POST", + body, + }) + } + return { getOverview, createUser, @@ -119,5 +139,7 @@ export const useAdmin = () => { createTenant, invitePortalUser, updateTenant, + exportTenant, + importTenant, } } diff --git a/frontend/pages/administration/tenants/[id].vue b/frontend/pages/administration/tenants/[id].vue index 8f4a6a0..c6630a2 100644 --- a/frontend/pages/administration/tenants/[id].vue +++ b/frontend/pages/administration/tenants/[id].vue @@ -10,9 +10,19 @@ const admin = useAdmin() const tenantId = Number(route.params.id) const loading = ref(true) const saving = ref(false) +const exportingTenant = ref(false) +const importingTenant = ref(false) const creatingUser = ref(false) const createUserModalOpen = ref(false) const createdUserPassword = ref("") +const importFileInput = ref(null) +const lastImportResult = ref(null) const tenantForm = ref(null) const assignedUsers = ref([]) @@ -79,6 +89,88 @@ const saveTenant = async () => { } } +const downloadTenantExport = async () => { + if (!tenantForm.value || exportingTenant.value) return + + exportingTenant.value = true + + try { + const blob = await admin.exportTenant(tenantForm.value.id) + const objectUrl = URL.createObjectURL(blob) + const link = document.createElement("a") + const safeName = (tenantForm.value.short || tenantForm.value.name || tenantForm.value.id) + .toString() + .trim() + .replace(/[^a-z0-9_-]+/gi, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase() + + link.href = objectUrl + link.download = `fedeo-tenant-${safeName || tenantForm.value.id}-${new Date().toISOString().slice(0, 10)}.json` + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(objectUrl) + + toast.add({ title: "Mandantenexport erstellt", color: "green" }) + } catch (err: any) { + console.error("[administration/tenants/export]", err) + toast.add({ + title: "Mandant konnte nicht exportiert werden", + description: err?.data?.error || err?.message || "Unbekannter Fehler", + color: "red", + }) + } finally { + exportingTenant.value = false + } +} + +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 + lastImportResult.value = null + + try { + const rawContent = await file.text() + const exportData = JSON.parse(rawContent) + const result = await admin.importTenant(exportData) + 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() + + 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 @@ -186,6 +278,59 @@ onMounted(async () => { + + + +
+
+
Full Export
+

+ Erstellt eine portable JSON-Datei mit Mandantendaten, Benutzerzuordnungen, Rollen, Profilen und Dateiinhalten. +

+ + Export herunterladen + +
+ +
+
Import
+

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

+ + + Export importieren + +
+
+ + +
+ diff --git a/frontend/pages/administration/tenants/index.vue b/frontend/pages/administration/tenants/index.vue index 664827e..9e04873 100644 --- a/frontend/pages/administration/tenants/index.vue +++ b/frontend/pages/administration/tenants/index.vue @@ -8,7 +8,9 @@ const admin = useAdmin() const loading = ref(true) const creatingTenant = ref(false) +const importingTenant = ref(false) const createTenantModalOpen = ref(false) +const importFileInput = ref(null) const tenants = ref([]) const searchString = ref("") @@ -89,6 +91,46 @@ const createTenant = 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 + + try { + const exportData = JSON.parse(await file.text()) + const result = await admin.importTenant(exportData) + const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0) + + await fetchTenants() + + toast.add({ + title: "Mandantenimport abgeschlossen", + description: `Tenant ${result.tenantId}: ${rowCount} Datensätze und ${result.files?.restored || 0} Dateien verarbeitet.`, + color: "green", + }) + + if (result.tenantId) { + await router.push(`/administration/tenants/${result.tenantId}`) + } + } 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 = "" + } +} + onMounted(async () => { if (!auth.user?.is_admin) { await router.push("/") @@ -111,6 +153,22 @@ onMounted(async () => { Tenant + + + Import +