From 6b5d4f7f36cd2a5a3a42f679c2c3eb2ca16a905e Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Thu, 27 Aug 2026 08:50:17 +0200 Subject: [PATCH] =?UTF-8?q?KI-AGENT:=20Statusanzeige=20f=C3=BCr=20Tenant-I?= =?UTF-8?q?mport=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0064_tenant_import_job_status.sql | 4 + backend/db/schema/tenant_export_jobs.ts | 4 +- backend/src/routes/admin.ts | 141 ++++++++++++++++-- backend/src/utils/tenantFullExport.ts | 17 ++- frontend/composables/useAdmin.ts | 25 +++- .../pages/administration/tenants/index.vue | 62 +++++++- 6 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 backend/db/migrations/0064_tenant_import_job_status.sql diff --git a/backend/db/migrations/0064_tenant_import_job_status.sql b/backend/db/migrations/0064_tenant_import_job_status.sql new file mode 100644 index 0000000..e5844f4 --- /dev/null +++ b/backend/db/migrations/0064_tenant_import_job_status.sql @@ -0,0 +1,4 @@ +ALTER TABLE "tenant_export_jobs" + ALTER COLUMN "tenant_id" DROP NOT NULL, + ADD COLUMN IF NOT EXISTS "status_message" text, + ADD COLUMN IF NOT EXISTS "import_result" jsonb; diff --git a/backend/db/schema/tenant_export_jobs.ts b/backend/db/schema/tenant_export_jobs.ts index 8d19ab2..6028f00 100644 --- a/backend/db/schema/tenant_export_jobs.ts +++ b/backend/db/schema/tenant_export_jobs.ts @@ -5,6 +5,7 @@ import { timestamp, text, integer, + jsonb, } from "drizzle-orm/pg-core" import { tenants } from "./tenants" @@ -22,7 +23,6 @@ export const tenantExportJobs = pgTable("tenant_export_jobs", { 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), @@ -35,6 +35,8 @@ export const tenantExportJobs = pgTable("tenant_export_jobs", { contentType: text("content_type").notNull().default("application/zip"), fileSize: bigint("file_size", { mode: "number" }), error: text("error"), + statusMessage: text("status_message"), + importResult: jsonb("import_result"), filesTotal: integer("files_total").notNull().default(0), filesDone: integer("files_done").notNull().default(0), diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 40451c3..bdbbfad 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -439,6 +439,66 @@ export default async function adminRoutes(server: FastifyInstance) { } }; + const startTenantImportJob = async ( + jobId: string, + currentUser: { id: string; email: string }, + source: { archiveBuffer: Buffer } | { exportData: TenantFullExport } + ) => { + try { + await server.db + .update(tenantExportJobs) + .set({ + status: "running", + statusMessage: "Mandantenimport wird vorbereitet", + updatedAt: new Date(), + }) + .where(eq(tenantExportJobs.id, jobId)); + + const onProgress = async ({ done, total, message }: { done: number; total: number; message?: string }) => { + await server.db + .update(tenantExportJobs) + .set({ + filesDone: done, + filesTotal: total, + statusMessage: message || "Mandant wird importiert", + updatedAt: new Date(), + }) + .where(eq(tenantExportJobs.id, jobId)); + }; + + const result = "archiveBuffer" in source + ? await importTenantFullExportArchive(server, source.archiveBuffer, { onProgress }) + : await importTenantFullExport(server, source.exportData, { onProgress }); + const access = await completeImportedTenantAccess(currentUser, result); + const importResult = { success: true, ...access, ...result }; + + await server.db + .update(tenantExportJobs) + .set({ + tenantId: result.tenantId, + status: "completed", + statusMessage: "Mandantenimport abgeschlossen", + importResult, + error: null, + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(tenantExportJobs.id, jobId)); + } catch (err: any) { + console.error("ERROR tenant import job:", err); + await server.db + .update(tenantExportJobs) + .set({ + status: "failed", + statusMessage: "Mandantenimport fehlgeschlagen", + 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({ @@ -1347,7 +1407,9 @@ export default async function adminRoutes(server: FastifyInstance) { if (!currentUser) return; const isMultipart = req.headers["content-type"]?.includes("multipart/form-data"); - let result; + let source!: { archiveBuffer: Buffer } | { exportData: TenantFullExport }; + let filename = "tenant-export.json"; + let fileSize: number | null = null; let targetTenantId: number | null = null; if (isMultipart) { @@ -1355,6 +1417,8 @@ export default async function adminRoutes(server: FastifyInstance) { if (!data?.file) return reply.code(400).send({ error: "export file required" }); const archiveBuffer = await data.toBuffer(); + filename = data.filename || "tenant-export.fedeo-export.zip"; + fileSize = archiveBuffer.length; targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null; if (targetTenantId) { return reply.code(409).send({ @@ -1362,7 +1426,7 @@ export default async function adminRoutes(server: FastifyInstance) { }); } - result = await importTenantFullExportArchive(server, archiveBuffer); + source = { archiveBuffer }; } else { const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number }; const exportData = "format" in body ? body : body.exportData; @@ -1378,17 +1442,32 @@ export default async function adminRoutes(server: FastifyInstance) { }); } - result = await importTenantFullExport(server, exportData); + source = { exportData }; } - const { matrixProvisioned, matrixProvisioningError } = await completeImportedTenantAccess(currentUser, result); + const [job] = await server.db + .insert(tenantExportJobs) + .values({ + tenantId: null, + createdBy: currentUser.id, + operation: "import", + status: "queued", + statusMessage: "Upload abgeschlossen, Import wird gestartet", + filename, + fileSize, + updatedAt: new Date(), + }) + .returning(); - return { - success: true, - matrixProvisioned, - matrixProvisioningError, - ...result, - }; + void startTenantImportJob(job.id, currentUser, source); + + return reply.code(202).send({ + importId: job.id, + status: job.status, + statusMessage: job.statusMessage, + filename: job.filename, + statusUrl: `/api/admin/tenant-imports/${job.id}`, + }); } catch (err: any) { console.error("ERROR /admin/tenant-imports:", err); const message = err?.message || "Internal Server Error"; @@ -1397,6 +1476,48 @@ export default async function adminRoutes(server: FastifyInstance) { } }); + // ------------------------------------------------------------- + // GET /admin/tenant-imports/:import_id + // ------------------------------------------------------------- + server.get("/admin/tenant-imports/:import_id", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const { import_id } = req.params as { import_id: string }; + const [job] = await server.db + .select() + .from(tenantExportJobs) + .where(and( + eq(tenantExportJobs.id, import_id), + eq(tenantExportJobs.operation, "import") + )) + .limit(1); + + if (!job) return reply.code(404).send({ error: "Import nicht gefunden" }); + + return { + importId: job.id, + tenantId: job.tenantId, + operation: job.operation, + status: job.status, + statusMessage: job.statusMessage, + filename: job.filename, + fileSize: job.fileSize, + filesDone: job.filesDone, + filesTotal: job.filesTotal, + error: job.error, + result: job.importResult, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + completedAt: job.completedAt, + }; + } catch (err) { + console.error("ERROR /admin/tenant-imports/:import_id:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + // ------------------------------------------------------------- // PUT /admin/users/:user_id/access // ------------------------------------------------------------- diff --git a/backend/src/utils/tenantFullExport.ts b/backend/src/utils/tenantFullExport.ts index c543534..f8b0dc5 100644 --- a/backend/src/utils/tenantFullExport.ts +++ b/backend/src/utils/tenantFullExport.ts @@ -565,16 +565,21 @@ const readZipTextEntry = async (entriesByName: Map, name: string) = const restoreArchiveFiles = async ( entriesByName: Map, exportData: TenantFullExport, - manifest: TenantArchiveManifest + manifest: TenantArchiveManifest, + onProgress?: (progress: { done: number; total: number; message?: string }) => Promise | void ) => { let restored = 0 let skipped = 0 const filesByPath = new Map((manifest.files || []).map((file) => [file.path, file])) + const filesTotal = exportData.files?.length || 0 + let filesDone = 0 for (const fileRow of exportData.files || []) { const originalPath = fileRow.path if (!originalPath) { skipped += 1 + filesDone += 1 + await onProgress?.({ done: filesDone, total: filesTotal, message: "Archivdateien werden wiederhergestellt" }) continue } @@ -582,12 +587,16 @@ const restoreArchiveFiles = async ( const archivePath = manifestFile?.archivePath if (!archivePath || manifestFile?.missing) { skipped += 1 + filesDone += 1 + await onProgress?.({ done: filesDone, total: filesTotal, message: "Archivdateien werden wiederhergestellt" }) continue } const entry = entriesByName.get(archivePath) if (!entry) { skipped += 1 + filesDone += 1 + await onProgress?.({ done: filesDone, total: filesTotal, message: "Archivdateien werden wiederhergestellt" }) continue } @@ -600,6 +609,8 @@ const restoreArchiveFiles = async ( ContentLength: content.length, })) restored += 1 + filesDone += 1 + await onProgress?.({ done: filesDone, total: filesTotal, message: "Archivdateien werden wiederhergestellt" }) } return { restored, skipped } @@ -1008,8 +1019,8 @@ export const importTenantFullExportArchive = async ( })), } - const result = await importTenantFullExport(server, rawExportData) - const files = await restoreArchiveFiles(entriesByName, rawExportData, manifest) + const result = await importTenantFullExport(server, rawExportData, options) + const files = await restoreArchiveFiles(entriesByName, rawExportData, manifest, options.onProgress) return { ...result, diff --git a/frontend/composables/useAdmin.ts b/frontend/composables/useAdmin.ts index ca7adbc..52f5c3f 100644 --- a/frontend/composables/useAdmin.ts +++ b/frontend/composables/useAdmin.ts @@ -63,6 +63,24 @@ export type TenantImportResult = { error?: string | null } +export type TenantImportJob = { + importId: string + tenantId?: number | null + operation?: "import" | string + status: "queued" | "running" | "completed" | "failed" | string + statusMessage?: string | null + filename: string + fileSize?: number | null + filesDone?: number + filesTotal?: number + error?: string | null + result?: TenantImportResult | null + statusUrl?: string + createdAt?: string + updatedAt?: string | null + completedAt?: string | null +} + export type TenantExportJob = { exportId: string importId?: string @@ -215,13 +233,17 @@ export const useAdmin = () => { }) } - const importTenant = async (body: Record | FormData): Promise => { + const importTenant = async (body: Record | FormData): Promise => { return await $api("/api/admin/tenant-imports", { method: "POST", body, }) } + const getTenantImport = async (importId: string): Promise => { + return await $api(`/api/admin/tenant-imports/${importId}`) + } + const getSystemStatus = async (): Promise => { return await $api("/api/admin/system-status") } @@ -262,5 +284,6 @@ export const useAdmin = () => { getTenantExport, downloadTenantExport, importTenant, + getTenantImport, } } diff --git a/frontend/pages/administration/tenants/index.vue b/frontend/pages/administration/tenants/index.vue index 82f91f0..2037795 100644 --- a/frontend/pages/administration/tenants/index.vue +++ b/frontend/pages/administration/tenants/index.vue @@ -1,5 +1,5 @@