diff --git a/backend/db/migrations/0054_tenant_export_jobs.sql b/backend/db/migrations/0054_tenant_export_jobs.sql new file mode 100644 index 0000000..3f383fd --- /dev/null +++ b/backend/db/migrations/0054_tenant_export_jobs.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS "tenant_export_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE cascade, + "created_by" uuid REFERENCES "auth_users"("id"), + "status" text DEFAULT 'queued' NOT NULL, + "filename" text NOT NULL, + "storage_path" text, + "content_type" text DEFAULT 'application/zip' NOT NULL, + "file_size" bigint, + "error" text, + "files_total" integer DEFAULT 0 NOT NULL, + "files_done" integer DEFAULT 0 NOT NULL +); + +CREATE INDEX IF NOT EXISTS "tenant_export_jobs_tenant_status_idx" + ON "tenant_export_jobs" ("tenant_id", "status"); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index c1bfbdf..e2be355 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -358,6 +358,13 @@ "when": 1780261200000, "tag": "0050_outgoing_document_costcentres", "breakpoints": true + }, + { + "idx": 51, + "version": "7", + "when": 1783247083934, + "tag": "0054_tenant_export_jobs", + "breakpoints": true } ] } diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 8fabe61..90a53dd 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -82,6 +82,7 @@ export * from "./taxtypes" export * from "./telephony_calls" export * from "./telephony_extensions" export * from "./telephony_trunks" +export * from "./tenant_export_jobs" export * from "./tenants" export * from "./texttemplates" export * from "./units" diff --git a/backend/db/schema/tenant_export_jobs.ts b/backend/db/schema/tenant_export_jobs.ts new file mode 100644 index 0000000..84277ed --- /dev/null +++ b/backend/db/schema/tenant_export_jobs.ts @@ -0,0 +1,41 @@ +import { + pgTable, + uuid, + bigint, + timestamp, + text, + integer, +} from "drizzle-orm/pg-core" + +import { tenants } from "./tenants" +import { authUsers } from "./auth_users" + +export const tenantExportJobs = pgTable("tenant_export_jobs", { + id: uuid("id").primaryKey().defaultRandom(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + updatedAt: timestamp("updated_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + + tenantId: bigint("tenant_id", { mode: "number" }) + .notNull() + .references(() => tenants.id, { onDelete: "cascade" }), + + createdBy: uuid("created_by").references(() => authUsers.id), + + status: text("status").notNull().default("queued"), + filename: text("filename").notNull(), + storagePath: text("storage_path"), + contentType: text("content_type").notNull().default("application/zip"), + fileSize: bigint("file_size", { mode: "number" }), + error: text("error"), + + filesTotal: integer("files_total").notNull().default(0), + filesDone: integer("files_done").notNull().default(0), +}) + +export type TenantExportJob = typeof tenantExportJobs.$inferSelect +export type NewTenantExportJob = typeof tenantExportJobs.$inferInsert diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index c8e981a..e5cb46d 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,5 +1,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { and, eq, inArray, isNull } from "drizzle-orm"; +import multipart from "@fastify/multipart"; +import { GetObjectCommand } from "@aws-sdk/client-s3"; import { authTenantUsers, @@ -10,17 +12,28 @@ import { authUsers, filetags, folders, + tenantExportJobs, tenants, } from "../../db/schema"; import { generateRandomPassword, hashPassword } from "../utils/password"; import { sendMail } from "../utils/mailer"; import { ensureTenantBaseData } from "../modules/bootstrap.service"; -import { buildTenantFullExport, importTenantFullExport } from "../utils/tenantFullExport"; +import { + createTenantFullExportArchive, + importTenantFullExport, + importTenantFullExportArchive, +} 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"; export default async function adminRoutes(server: FastifyInstance) { + await server.register(multipart, { + limits: { fileSize: 1024 * 1024 * 1024 } + }); + const deriveNameFromEmail = (email: string) => { const localPart = email.split("@")[0] || "Benutzer"; const normalized = localPart.replace(/[._-]+/g, " ").trim(); @@ -245,6 +258,98 @@ export default async function adminRoutes(server: FastifyInstance) { ]); }; + const tenantExportFilename = (tenantId: number, tenant?: { short?: string | null; name?: string | null }) => { + const safeTenantName = String(tenant?.short || tenant?.name || tenantId) + .replace(/[^a-z0-9_-]+/gi, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); + + return `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.fedeo-export.zip`; + }; + + const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => { + try { + await server.db + .update(tenantExportJobs) + .set({ + status: "running", + updatedAt: new Date(), + }) + .where(eq(tenantExportJobs.id, jobId)); + + const result = await createTenantFullExportArchive(server, tenantId, { + filename, + storagePath: `tenant-exports/${tenantId}/${jobId}/${filename}`, + onProgress: async ({ filesDone, filesTotal }) => { + await server.db + .update(tenantExportJobs) + .set({ + filesDone, + filesTotal, + updatedAt: new Date(), + }) + .where(eq(tenantExportJobs.id, jobId)); + }, + }); + + await server.db + .update(tenantExportJobs) + .set({ + status: "ready", + storagePath: result.storagePath, + contentType: result.contentType, + fileSize: result.size, + filesTotal: result.filesTotal, + filesDone: result.filesDone, + completedAt: new Date(), + updatedAt: new Date(), + error: null, + }) + .where(eq(tenantExportJobs.id, jobId)); + } catch (err: any) { + console.error("ERROR tenant export 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)); + } + }; + + const createPreparedTenantExport = async (tenantId: number, currentUserId: string) => { + const [tenant] = await server.db + .select({ + id: tenants.id, + name: tenants.name, + short: tenants.short, + }) + .from(tenants) + .where(eq(tenants.id, tenantId)) + .limit(1); + + if (!tenant) return null; + + const filename = tenantExportFilename(tenantId, tenant); + const [job] = await server.db + .insert(tenantExportJobs) + .values({ + tenantId, + createdBy: currentUserId, + status: "queued", + filename, + updatedAt: new Date(), + }) + .returning(); + + void startTenantExportJob(job.id, tenantId, filename); + + return job; + }; + const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => { if (!req.user?.user_id) { reply.code(401).send({ error: "Unauthorized" }); @@ -969,22 +1074,128 @@ export default async function adminRoutes(server: FastifyInstance) { 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`; + const job = await createPreparedTenantExport(tenantId, currentUser.id); + if (!job) { + return reply.code(404).send({ error: "Tenant not found" }); + } - reply.header("Content-Type", "application/json"); - reply.header("Content-Disposition", `attachment; filename="${filename}"`); - return reply.send(exportData); + return reply.code(202).send({ + exportId: job.id, + status: job.status, + filename: job.filename, + statusUrl: `/api/admin/tenant-exports/${job.id}`, + downloadUrl: `/api/admin/tenant-exports/${job.id}/download`, + }); } catch (err) { console.error("ERROR /admin/tenants/:tenant_id/export:", err); return reply.code(500).send({ error: "Internal Server Error" }); } }); + // ------------------------------------------------------------- + // POST /admin/tenants/:tenant_id/exports + // ------------------------------------------------------------- + server.post("/admin/tenants/:tenant_id/exports", 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 job = await createPreparedTenantExport(tenantId, currentUser.id); + if (!job) { + return reply.code(404).send({ error: "Tenant not found" }); + } + + return reply.code(202).send({ + exportId: job.id, + status: job.status, + filename: job.filename, + statusUrl: `/api/admin/tenant-exports/${job.id}`, + downloadUrl: `/api/admin/tenant-exports/${job.id}/download`, + }); + } catch (err) { + console.error("ERROR /admin/tenants/:tenant_id/exports:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + + // ------------------------------------------------------------- + // GET /admin/tenant-exports/:export_id + // ------------------------------------------------------------- + server.get("/admin/tenant-exports/:export_id", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const { export_id } = req.params as { export_id: string }; + const [job] = await server.db + .select() + .from(tenantExportJobs) + .where(eq(tenantExportJobs.id, export_id)) + .limit(1); + + if (!job) return reply.code(404).send({ error: "Export not found" }); + + return { + exportId: job.id, + tenantId: job.tenantId, + status: job.status, + filename: job.filename, + fileSize: job.fileSize, + filesDone: job.filesDone, + filesTotal: job.filesTotal, + error: job.error, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + completedAt: job.completedAt, + downloadUrl: job.status === "ready" ? `/api/admin/tenant-exports/${job.id}/download` : null, + }; + } catch (err) { + console.error("ERROR /admin/tenant-exports/:export_id:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + + // ------------------------------------------------------------- + // GET /admin/tenant-exports/:export_id/download + // ------------------------------------------------------------- + server.get("/admin/tenant-exports/:export_id/download", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const { export_id } = req.params as { export_id: string }; + const [job] = await server.db + .select() + .from(tenantExportJobs) + .where(eq(tenantExportJobs.id, export_id)) + .limit(1); + + if (!job) return reply.code(404).send({ error: "Export not found" }); + if (job.status !== "ready" || !job.storagePath) { + return reply.code(409).send({ error: "Export not ready", status: job.status }); + } + + const { Body, ContentLength } = await s3.send(new GetObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: job.storagePath, + })); + + reply.header("Content-Type", job.contentType || "application/zip"); + reply.header("Content-Disposition", `attachment; filename="${job.filename}"`); + if (ContentLength) reply.header("Content-Length", String(ContentLength)); + return reply.send(Body as any); + } catch (err) { + console.error("ERROR /admin/tenant-exports/:export_id/download:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + // ------------------------------------------------------------- // POST /admin/tenant-imports // ------------------------------------------------------------- @@ -993,15 +1204,27 @@ export default async function adminRoutes(server: FastifyInstance) { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; - const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number }; - const exportData = "format" in body ? body : body.exportData; - const targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null; + const isMultipart = req.headers["content-type"]?.includes("multipart/form-data"); + let result; - if (!exportData) { - return reply.code(400).send({ error: "exportData required" }); + if (isMultipart) { + const data: any = await req.file(); + if (!data?.file) return reply.code(400).send({ error: "export file required" }); + + const archiveBuffer = await data.toBuffer(); + const targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null; + result = await importTenantFullExportArchive(server, archiveBuffer, { targetTenantId }); + } else { + const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number }; + const exportData = "format" in body ? body : body.exportData; + const targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null; + + if (!exportData) { + return reply.code(400).send({ error: "exportData required" }); + } + + result = await importTenantFullExport(server, exportData, { targetTenantId }); } - - const result = await importTenantFullExport(server, exportData, { targetTenantId }); const fallbackName = deriveNameFromEmail(currentUser.email); await server.db diff --git a/backend/src/utils/tenantFullExport.ts b/backend/src/utils/tenantFullExport.ts index f032169..ae46139 100644 --- a/backend/src/utils/tenantFullExport.ts +++ b/backend/src/utils/tenantFullExport.ts @@ -1,5 +1,11 @@ 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" @@ -41,6 +47,34 @@ type ImportOptions = { targetTenantId?: number | null } +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", @@ -196,8 +230,49 @@ const loadObjectAsBase64 = async (path: string) => { } } -export const buildTenantFullExport = async (server: FastifyInstance, tenantId: number): Promise => { +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 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) @@ -259,7 +334,9 @@ export const buildTenantFullExport = async (server: FastifyInstance, tenantId: n for (const file of fileRows) { if (!file.path) continue - const object = await loadObjectAsBase64(file.path) + const object = includeFileContents + ? await loadObjectAsBase64(file.path) + : { contentBase64: null, missing: false, error: null } if (object.missing) { server.log.warn({ fileId: file.id, @@ -294,6 +371,117 @@ export const buildTenantFullExport = async (server: FastifyInstance, tenantId: n } } +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.tables.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 @@ -318,6 +506,56 @@ const restoreFiles = async (exportData: TenantFullExport) => { 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.tables.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 @@ -349,6 +587,10 @@ const remapTenantScopedExport = ( 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 }) } @@ -613,8 +855,15 @@ export const importTenantFullExport = async ( importedTables.push({ table: "bankstatements", rows: result.count }) } + 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 }) + } + for (const table of tableNames) { - if (["bankaccounts", "bankstatements"].includes(table)) continue + if (["bankaccounts", "bankstatements", "letterheads"].includes(table)) continue const rows = exportData.tables[table] || [] const metadata = columnsByTable.get(table) @@ -644,3 +893,70 @@ export const importTenantFullExport = async ( 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() + } +} diff --git a/frontend/composables/useAdmin.ts b/frontend/composables/useAdmin.ts index 355dd85..89cc0e1 100644 --- a/frontend/composables/useAdmin.ts +++ b/frontend/composables/useAdmin.ts @@ -55,6 +55,22 @@ export type TenantImportResult = { files: { restored: number; skipped: number } } +export type TenantExportJob = { + exportId: string + tenantId?: number + status: "queued" | "running" | "ready" | "failed" | string + filename: string + fileSize?: number | null + filesDone?: number + filesTotal?: number + error?: string | null + statusUrl?: string + downloadUrl?: string | null + createdAt?: string + updatedAt?: string | null + completedAt?: string | null +} + export type SystemStatus = { checkedAt: string backend: { @@ -173,13 +189,23 @@ export const useAdmin = () => { }) } - const exportTenant = async (id: number): Promise => { - return await $api(`/api/admin/tenants/${id}/export`, { + const startTenantExport = async (id: number): Promise => { + return await $api(`/api/admin/tenants/${id}/exports`, { + method: "POST", + }) + } + + const getTenantExport = async (exportId: string): Promise => { + return await $api(`/api/admin/tenant-exports/${exportId}`) + } + + const downloadTenantExport = async (exportId: string): Promise => { + return await $api(`/api/admin/tenant-exports/${exportId}/download`, { responseType: "blob", }) } - const importTenant = async (body: Record): Promise => { + const importTenant = async (body: Record | FormData): Promise => { return await $api("/api/admin/tenant-imports", { method: "POST", body, @@ -222,7 +248,9 @@ export const useAdmin = () => { createTenant, invitePortalUser, updateTenant, - exportTenant, + startTenantExport, + getTenantExport, + downloadTenantExport, importTenant, } } diff --git a/frontend/pages/administration/tenants/[id].vue b/frontend/pages/administration/tenants/[id].vue index b541af8..87edd36 100644 --- a/frontend/pages/administration/tenants/[id].vue +++ b/frontend/pages/administration/tenants/[id].vue @@ -12,6 +12,12 @@ const loading = ref(true) const saving = ref(false) const exportingTenant = ref(false) const importingTenant = ref(false) +const tenantExportProgress = ref(null) const creatingUser = ref(false) const createUserModalOpen = ref(false) const createdUserPassword = ref("") @@ -93,26 +99,46 @@ const downloadTenantExport = async () => { if (!tenantForm.value || exportingTenant.value) return exportingTenant.value = true + tenantExportProgress.value = null try { - const blob = await admin.exportTenant(tenantForm.value.id) + const started = await admin.startTenantExport(tenantForm.value.id) + let job = started + + tenantExportProgress.value = { + status: job.status, + filesDone: job.filesDone || 0, + filesTotal: job.filesTotal || 0, + filename: job.filename, + } + + while (!["ready", "failed"].includes(job.status)) { + await new Promise((resolve) => setTimeout(resolve, 1500)) + job = await admin.getTenantExport(started.exportId) + tenantExportProgress.value = { + status: job.status, + filesDone: job.filesDone || 0, + filesTotal: job.filesTotal || 0, + filename: job.filename, + } + } + + if (job.status === "failed") { + throw new Error(job.error || "Export konnte nicht erstellt werden.") + } + + const blob = await admin.downloadTenantExport(started.exportId) 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` + link.download = job.filename || `fedeo-tenant-${tenantForm.value.id}.fedeo-export.zip` document.body.appendChild(link) link.click() link.remove() URL.revokeObjectURL(objectUrl) - toast.add({ title: "Mandantenexport erstellt", color: "green" }) + toast.add({ title: "Mandantenexport erstellt", description: job.filename, color: "green" }) } catch (err: any) { console.error("[administration/tenants/export]", err) toast.add({ @@ -138,12 +164,22 @@ const importTenantExport = async (event: Event) => { lastImportResult.value = null try { - const rawContent = await file.text() - const exportData = JSON.parse(rawContent) - const result = await admin.importTenant({ - exportData, - targetTenantId: tenantForm.value?.id || tenantId, - }) + 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 rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0) lastImportResult.value = { @@ -290,8 +326,19 @@ onMounted(async () => {
Full Export

- Erstellt eine portable JSON-Datei mit Mandantendaten, Benutzerzuordnungen, Rollen, Profilen und Dateiinhalten. + Erstellt ein portables ZIP-Archiv mit Mandantendaten, Benutzerzuordnungen, Rollen, Profilen und Dateien.

+
+ +

+ {{ tenantExportProgress.status === 'ready' ? 'Download wird vorbereitet' : 'Export wird erstellt' }} + + · {{ tenantExportProgress.filesDone }} / {{ tenantExportProgress.filesTotal }} Dateien + +

+
{ diff --git a/frontend/pages/administration/tenants/index.vue b/frontend/pages/administration/tenants/index.vue index 862c910..82f91f0 100644 --- a/frontend/pages/administration/tenants/index.vue +++ b/frontend/pages/administration/tenants/index.vue @@ -103,8 +103,17 @@ const importTenantExport = async (event: Event) => { importingTenant.value = true try { - const exportData = JSON.parse(await file.text()) - const result = await admin.importTenant(exportData) + 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) + result = await admin.importTenant(formData) + } else { + result = await admin.importTenant(JSON.parse(await file.text())) + } + const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0) await fetchTenants() @@ -158,7 +167,7 @@ onMounted(async () => {