diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 9d2fd9b..8bf99fd 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -340,6 +340,127 @@ export default async function adminRoutes(server: FastifyInstance) { return job; }; + const completeImportedTenantAccess = async ( + currentUser: { id: string; email: string }, + result: { tenantId: number } + ) => { + const fallbackName = deriveNameFromEmail(currentUser.email); + + await server.db + .insert(authTenantUsers) + .values({ + tenant_id: result.tenantId, + user_id: currentUser.id, + created_by: currentUser.id, + }) + .onConflictDoNothing(); + + const [existingAdminProfile] = await server.db + .select({ id: authProfiles.id }) + .from(authProfiles) + .where(and( + eq(authProfiles.tenant_id, result.tenantId), + eq(authProfiles.user_id, currentUser.id) + )) + .limit(1); + + if (!existingAdminProfile) { + await server.db + .insert(authProfiles) + .values({ + tenant_id: result.tenantId, + user_id: currentUser.id, + first_name: fallbackName.first_name, + last_name: fallbackName.last_name, + email: currentUser.email, + active: true, + }); + } + + let matrixProvisioned = false; + let matrixProvisioningError: string | null = null; + if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) { + try { + const matrix = matrixService(server); + await matrix.provisionTenantRoom(currentUser.id, result.tenantId, { + key: "allgemein", + name: "Allgemeiner Chat", + type: "general", + }); + matrixProvisioned = true; + } catch (err: any) { + matrixProvisioningError = err?.message || String(err); + server.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden"); + } + } + + return { + matrixProvisioned, + matrixProvisioningError, + }; + }; + + const startTenantImportJob = async ( + jobId: string, + targetTenantId: number, + currentUser: { id: string; email: string }, + importData: { type: "archive"; archiveBuffer: Buffer } | { type: "json"; exportData: TenantFullExport } + ) => { + try { + await server.db + .update(tenantExportJobs) + .set({ + status: "running", + filesDone: 0, + filesTotal: 1, + updatedAt: new Date(), + }) + .where(eq(tenantExportJobs.id, jobId)); + + const onProgress = async ({ done, total }: { done: number; total: number }) => { + await server.db + .update(tenantExportJobs) + .set({ + filesDone: done, + filesTotal: total, + updatedAt: new Date(), + }) + .where(eq(tenantExportJobs.id, jobId)); + }; + + const result = importData.type === "archive" + ? await importTenantFullExportArchive(server, importData.archiveBuffer, { targetTenantId, onProgress }) + : await importTenantFullExport(server, importData.exportData, { targetTenantId, onProgress }); + + await completeImportedTenantAccess(currentUser, result); + + await server.db + .update(tenantExportJobs) + .set({ + status: "ready", + filesDone: 1, + filesTotal: 1, + completedAt: new Date(), + updatedAt: new Date(), + error: null, + }) + .where(eq(tenantExportJobs.id, jobId)); + } catch (err: any) { + console.error("ERROR tenant import 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)); + } finally { + await unlockTenantForJob(targetTenantId, jobId); + } + }; + const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => { try { await lockTenantForJob(tenantId, jobId); @@ -1237,6 +1358,7 @@ export default async function adminRoutes(server: FastifyInstance) { return { exportId: job.id, tenantId: job.tenantId, + operation: job.operation, status: job.status, filename: job.filename, fileSize: job.fileSize, @@ -1246,7 +1368,7 @@ export default async function adminRoutes(server: FastifyInstance) { createdAt: job.createdAt, updatedAt: job.updatedAt, completedAt: job.completedAt, - downloadUrl: job.status === "ready" ? `/api/admin/tenant-exports/${job.id}/download` : null, + downloadUrl: job.operation === "export" && job.status === "ready" ? `/api/admin/tenant-exports/${job.id}/download` : null, }; } catch (err) { console.error("ERROR /admin/tenant-exports/:export_id:", err); @@ -1293,15 +1415,13 @@ export default async function adminRoutes(server: FastifyInstance) { // POST /admin/tenant-imports // ------------------------------------------------------------- server.post("/admin/tenant-imports", { bodyLimit: 1024 * 1024 * 1024 }, async (req, reply) => { - let importJob: any = null; - let targetTenantId: number | null = null; - try { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; const isMultipart = req.headers["content-type"]?.includes("multipart/form-data"); let result; + let targetTenantId: number | null = null; if (isMultipart) { const data: any = await req.file(); @@ -1311,25 +1431,23 @@ export default async function adminRoutes(server: FastifyInstance) { targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null; if (targetTenantId) { - importJob = await createTenantImportJob(targetTenantId, currentUser.id, data.filename || "tenant-import.zip"); + const importJob = await createTenantImportJob(targetTenantId, currentUser.id, data.filename || "tenant-import.zip"); + void startTenantImportJob(importJob.id, targetTenantId, currentUser, { + type: "archive", + archiveBuffer, + }); + + return reply.code(202).send({ + importId: importJob.id, + exportId: importJob.id, + tenantId: targetTenantId, + status: importJob.status, + filename: importJob.filename, + statusUrl: `/api/admin/tenant-exports/${importJob.id}`, + }); } - try { - result = await importTenantFullExportArchive(server, archiveBuffer, { targetTenantId }); - } catch (err: any) { - if (importJob) { - await server.db - .update(tenantExportJobs) - .set({ - status: "failed", - error: err?.message || String(err), - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(tenantExportJobs.id, importJob.id)); - } - throw err; - } + 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; @@ -1340,88 +1458,26 @@ export default async function adminRoutes(server: FastifyInstance) { } if (targetTenantId) { - importJob = await createTenantImportJob(targetTenantId, currentUser.id, "tenant-import.json"); - } - - try { - result = await importTenantFullExport(server, exportData, { targetTenantId }); - } catch (err: any) { - if (importJob) { - await server.db - .update(tenantExportJobs) - .set({ - status: "failed", - error: err?.message || String(err), - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(tenantExportJobs.id, importJob.id)); - } - throw err; - } - } - - if (importJob) { - await server.db - .update(tenantExportJobs) - .set({ - status: "ready", - completedAt: new Date(), - updatedAt: new Date(), - error: null, - }) - .where(eq(tenantExportJobs.id, importJob.id)); - } - - const fallbackName = deriveNameFromEmail(currentUser.email); - - await server.db - .insert(authTenantUsers) - .values({ - tenant_id: result.tenantId, - user_id: currentUser.id, - created_by: currentUser.id, - }) - .onConflictDoNothing(); - - const [existingAdminProfile] = await server.db - .select({ id: authProfiles.id }) - .from(authProfiles) - .where(and( - eq(authProfiles.tenant_id, result.tenantId), - eq(authProfiles.user_id, currentUser.id) - )) - .limit(1); - - if (!existingAdminProfile) { - await server.db - .insert(authProfiles) - .values({ - tenant_id: result.tenantId, - user_id: currentUser.id, - first_name: fallbackName.first_name, - last_name: fallbackName.last_name, - email: currentUser.email, - active: true, + const importJob = await createTenantImportJob(targetTenantId, currentUser.id, "tenant-import.json"); + void startTenantImportJob(importJob.id, targetTenantId, currentUser, { + type: "json", + exportData, }); + + return reply.code(202).send({ + importId: importJob.id, + exportId: importJob.id, + tenantId: targetTenantId, + status: importJob.status, + filename: importJob.filename, + statusUrl: `/api/admin/tenant-exports/${importJob.id}`, + }); + } + + result = await importTenantFullExport(server, exportData, { targetTenantId }); } - let matrixProvisioned = false; - let matrixProvisioningError: string | null = null; - if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) { - try { - const matrix = matrixService(server); - await matrix.provisionTenantRoom(currentUser.id, result.tenantId, { - key: "allgemein", - name: "Allgemeiner Chat", - type: "general", - }); - matrixProvisioned = true; - } catch (err: any) { - matrixProvisioningError = err?.message || String(err); - req.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden"); - } - } + const { matrixProvisioned, matrixProvisioningError } = await completeImportedTenantAccess(currentUser, result); return { success: true, @@ -1432,14 +1488,6 @@ export default async function adminRoutes(server: FastifyInstance) { } catch (err: any) { console.error("ERROR /admin/tenant-imports:", err); return reply.code(500).send({ error: err?.message || "Internal Server Error" }); - } finally { - if (importJob && targetTenantId) { - try { - await unlockTenantForJob(targetTenantId, importJob.id); - } catch (unlockErr) { - req.log.error({ err: unlockErr, importJobId: importJob.id, targetTenantId }, "Tenant konnte nach Import nicht entsperrt werden"); - } - } } }); diff --git a/backend/src/utils/tenantFullExport.ts b/backend/src/utils/tenantFullExport.ts index ae46139..59475e5 100644 --- a/backend/src/utils/tenantFullExport.ts +++ b/backend/src/utils/tenantFullExport.ts @@ -45,6 +45,7 @@ type ImportResult = { type ImportOptions = { targetTenantId?: number | null + onProgress?: (progress: { done: number; total: number; message?: string }) => Promise | void } type BuildTenantFullExportOptions = { @@ -834,10 +835,28 @@ export const importTenantFullExport = async ( ...importOrder, ...Object.keys(exportData.tables).filter((table) => !importOrder.includes(table)).sort(), ].filter((table, index, all) => all.indexOf(table) === index) + const specialTables = [ + 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 bankaccountMetadata = columnsByTable.get("bankaccounts") @@ -845,6 +864,8 @@ export const importTenantFullExport = async ( 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") @@ -853,6 +874,8 @@ export const importTenantFullExport = async ( 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") @@ -860,25 +883,31 @@ export const importTenantFullExport = async ( 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 tableNames) { - if (["bankaccounts", "bankstatements", "letterheads"].includes(table)) continue - + 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 { diff --git a/frontend/composables/useAdmin.ts b/frontend/composables/useAdmin.ts index 4820fc4..ca7adbc 100644 --- a/frontend/composables/useAdmin.ts +++ b/frontend/composables/useAdmin.ts @@ -54,11 +54,20 @@ export type TenantImportResult = { tenantId: number tables: { table: string; rows: number }[] files: { restored: number; skipped: number } + importId?: string + exportId?: string + status?: string + filename?: string + filesDone?: number + filesTotal?: number + error?: string | null } export type TenantExportJob = { exportId: string + importId?: string tenantId?: number + operation?: "export" | "import" | string status: "queued" | "running" | "ready" | "failed" | string filename: string fileSize?: number | null diff --git a/frontend/pages/administration/tenants/[id].vue b/frontend/pages/administration/tenants/[id].vue index 3d766a8..8c5c637 100644 --- a/frontend/pages/administration/tenants/[id].vue +++ b/frontend/pages/administration/tenants/[id].vue @@ -18,6 +18,12 @@ const tenantExportProgress = ref(null) +const tenantImportProgress = ref(null) const creatingUser = ref(false) const createUserModalOpen = ref(false) const createdUserPassword = ref("") @@ -169,6 +175,7 @@ const importTenantExport = async (event: Event) => { if (!file || importingTenant.value) return importingTenant.value = true + tenantImportProgress.value = null lastImportResult.value = null try { @@ -188,6 +195,44 @@ const importTenantExport = async (event: Event) => { }) } + const importJobId = result.importId || result.exportId + if (importJobId) { + let job = result + + tenantImportProgress.value = { + status: job.status, + filesDone: job.filesDone || 0, + filesTotal: job.filesTotal || 0, + filename: job.filename || file.name, + } + + while (!["ready", "failed"].includes(job.status)) { + await new Promise((resolve) => setTimeout(resolve, 1500)) + job = await admin.getTenantExport(importJobId) + tenantImportProgress.value = { + status: job.status, + filesDone: job.filesDone || 0, + filesTotal: job.filesTotal || 0, + filename: job.filename || file.name, + } + } + + if (job.status === "failed") { + throw new Error(job.error || "Import konnte nicht abgeschlossen werden.") + } + + await fetchTenant() + await auth.fetchMe() + await auth.switchTenant(String(job.tenantId || targetTenantId)) + + toast.add({ + title: "Mandantenimport abgeschlossen", + description: job.filename || file.name, + color: "green", + }) + return + } + const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0) lastImportResult.value = { @@ -396,6 +441,17 @@ onMounted(async () => { > Export importieren +
+ +

+ {{ tenantImportProgress.status === 'ready' ? 'Import abgeschlossen' : 'Import wird verarbeitet' }} + + · {{ tenantImportProgress.filesDone }} / {{ tenantImportProgress.filesTotal }} Schritte + +

+