diff --git a/README.md b/README.md index e872870..a5f85ac 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Auf einem frischen Server kannst du die Betriebsdateien und die Konfiguration di curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash ``` -Der schnelle One-Liner mit direktem Stack-Start: +Der One-Liner von der Webseite nutzt den schnellen Standardpfad und startet den Stack direkt: ```bash curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash -s -- --simple --start @@ -491,17 +491,41 @@ Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADM ## Updates -Bei neuen Versionen: +Script-installierte Instanzen liegen standardmassig unter `/opt/fedeo`. Ein Update besteht daraus, die Selfhost-Dateien zu erneuern, die vorgebauten Images zu ziehen und den Compose-Stack neu zu starten. + +Vor kritischen Updates sollte ein Backup der persistenten Daten erstellt werden: ```bash +cd /opt/fedeo +tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" .env postgres minio +``` + +Danach die aktuellen Selfhost-Dateien laden und den Stack aktualisieren: + +```bash +cd /opt/fedeo curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-setup.sh -o /opt/fedeo/scripts/selfhost-setup.sh chmod +x /opt/fedeo/scripts/selfhost-setup.sh +docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml pull docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d ``` -Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an. Bei kritischen Updates sollte vorher ein Backup von `./postgres` und `./minio` erstellt werden. +Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an, solange `FEDEO_RUN_MIGRATIONS` nicht deaktiviert wurde. Falls Migrationen manuell erzwungen werden sollen: + +```bash +docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml run --rm backend npm run migrate +docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d +``` + +Anschliessend den Zustand prüfen: + +```bash +docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml ps +curl -I https://app.example.com +curl https://app.example.com/backend/health +``` Die Selfhost-Compose-Datei nutzt vorgebaute Images. Dadurch braucht der Server keinen Repository-Checkout und keine lokalen Build-Kontexte. diff --git a/backend/scripts/apply-tenant-export-lock-migration.ts b/backend/scripts/apply-tenant-export-lock-migration.ts new file mode 100644 index 0000000..42cd474 --- /dev/null +++ b/backend/scripts/apply-tenant-export-lock-migration.ts @@ -0,0 +1,234 @@ +import "dotenv/config" +import { drizzle } from "drizzle-orm/node-postgres" +import { sql } from "drizzle-orm" +import { Pool } from "pg" + +import { loadSecrets, secrets } from "../src/utils/secrets" + +const maskConnectionString = (value: string) => + value.replace(/:\/\/([^:@]+):([^@]+)@/, "://$1:***@") + +const logStep = (message: string) => { + console.log(`[tenant-lock-migration] ${message}`) +} + +const terminateIdleBlockers = process.argv.includes("--terminate-idle-blockers") + +const relationLocksQuery = (relationName: string) => sql` + SELECT + activity.pid, + activity.state, + activity.wait_event_type, + activity.wait_event, + now() - activity.query_start AS query_age, + locks.mode, + locks.granted, + left(activity.query, 500) AS query + FROM pg_locks locks + JOIN pg_class relation ON relation.oid = locks.relation + LEFT JOIN pg_stat_activity activity ON activity.pid = locks.pid + WHERE relation.relname = ${relationName} + ORDER BY locks.granted ASC, activity.query_start ASC NULLS LAST; +` + +const getRelationLocks = async (db: ReturnType, relationName: string) => { + const result = await db.execute(relationLocksQuery(relationName)) + return result.rows as Record[] +} + +const printRelationLocks = async (db: ReturnType, relationName: string) => { + const rows = await getRelationLocks(db, relationName) + + if (!rows.length) { + logStep(`Keine aktiven Locks fuer ${relationName} gefunden`) + return + } + + logStep(`Aktive Locks fuer ${relationName}:`) + for (const row of rows) { + console.log(JSON.stringify({ + pid: row.pid, + state: row.state, + waitEventType: row.wait_event_type, + waitEvent: row.wait_event, + queryAge: row.query_age, + mode: row.mode, + granted: row.granted, + query: row.query, + }, null, 2)) + } +} + +const terminateIdleRelationBlockers = async (db: ReturnType, relationName: string) => { + const rows = await getRelationLocks(db, relationName) + const blockerPids = rows + .filter((row) => + row.granted === true && + row.state === "idle in transaction" && + Number(row.pid) !== process.pid + ) + .map((row) => Number(row.pid)) + .filter((pid) => Number.isFinite(pid)) + + if (!blockerPids.length) { + logStep(`Keine idle-in-transaction Blocker fuer ${relationName} gefunden`) + return false + } + + for (const pid of blockerPids) { + logStep(`Beende idle-in-transaction DB-Session ${pid}`) + await db.execute(sql`SELECT pg_terminate_backend(${pid})`) + } + + return true +} + +const executeStatement = async ( + db: ReturnType, + statement: ReturnType, +) => { + await db.execute(statement) +} + +const handleLockError = async ( + db: ReturnType, + relationName: string, +) => { + logStep(`Konnte Lock fuer ${relationName} nicht rechtzeitig bekommen`) + await printRelationLocks(db, relationName) + + if (!terminateIdleBlockers) return false + + const terminated = await terminateIdleRelationBlockers(db, relationName) + if (terminated) { + logStep(`Retry nach beendeten idle-in-transaction Sessions fuer ${relationName}`) + } + + return terminated +} + +const executeStep = async ( + db: ReturnType, + message: string, + statement: ReturnType, + lockRelationName?: string, +) => { + logStep(message) + + try { + await executeStatement(db, statement) + } catch (err: any) { + const code = err?.cause?.code || err?.code + if (!lockRelationName || !["55P03", "57014"].includes(code)) { + throw err + } + + const shouldRetry = await handleLockError(db, lockRelationName) + if (!shouldRetry) throw err + + await executeStatement(db, statement) + } +} + +async function run() { + if (terminateIdleBlockers) { + logStep("Option --terminate-idle-blockers aktiv") + } + + let connectionString = process.env.DATABASE_URL + + if (!connectionString) { + logStep("DATABASE_URL nicht in process.env gefunden, lade Secrets") + await loadSecrets() + connectionString = secrets.DATABASE_URL + } else { + logStep("DATABASE_URL aus process.env gefunden") + } + + if (!connectionString) { + throw new Error("DATABASE_URL not configured") + } + + logStep(`Verbinde mit ${maskConnectionString(connectionString)}`) + + const pool = new Pool({ + connectionString, + max: 1, + connectionTimeoutMillis: 10000, + idleTimeoutMillis: 10000, + }) + + try { + const db = drizzle(pool) + + logStep("Teste DB-Verbindung") + await db.execute(sql`SELECT 1`) + + logStep("Setze Lock- und Statement-Timeout") + await db.execute(sql`SET lock_timeout = '10s'`) + await db.execute(sql`SET statement_timeout = '2min'`) + + await executeStep(db, "Erweitere tenant_export_jobs", sql` + ALTER TABLE "tenant_export_jobs" + ADD COLUMN IF NOT EXISTS "operation" text DEFAULT 'export' NOT NULL, + ADD COLUMN IF NOT EXISTS "previous_tenant_locked" "locked_tenant"; + `, "tenant_export_jobs") + + await executeStep(db, "Erweitere tenants", sql` + ALTER TABLE "tenants" + ADD COLUMN IF NOT EXISTS "locked_by_export_job_id" uuid; + `, "tenants") + + await executeStep(db, "Pruefe Foreign Key", sql` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_schema = 'public' + AND constraint_name = 'tenants_locked_by_export_job_id_tenant_export_jobs_id_fk' + ) THEN + ALTER TABLE "tenants" + ADD CONSTRAINT "tenants_locked_by_export_job_id_tenant_export_jobs_id_fk" + FOREIGN KEY ("locked_by_export_job_id") + REFERENCES "tenant_export_jobs"("id") + ON DELETE SET NULL; + END IF; + END $$; + `, "tenants") + + await executeStep(db, "Pruefe Drizzle-Schema", sql` + CREATE SCHEMA IF NOT EXISTS drizzle; + `) + + await executeStep(db, "Pruefe Drizzle-Migrationstabelle", sql` + CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at bigint + ); + `) + + await executeStep(db, "Setze Drizzle-Journal auf 0055", sql` + INSERT INTO drizzle.__drizzle_migrations (hash, created_at) + SELECT 'manual-0055_tenant_export_maintenance_lock', 1784021669805 + WHERE NOT EXISTS ( + SELECT 1 + FROM drizzle.__drizzle_migrations + WHERE created_at >= 1784021669805 + ); + `) + + logStep("Fertig") + console.log("✅ Tenant export lock migration applied") + } finally { + logStep("Schließe DB-Verbindung") + await pool.end() + } +} + +run().catch((err) => { + console.error("❌ Tenant export lock migration failed") + console.error(err) + process.exit(1) +}) 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..f2e4579 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 = { @@ -264,6 +265,22 @@ const archiveEntryPathForFile = (path: string) => { 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 ( @@ -328,11 +345,17 @@ export const buildTenantFullExport = async ( tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts) } - const fileRows = tables.files || [] const files = [] - - for (const file of fileRows) { - if (!file.path) continue + 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) @@ -349,7 +372,7 @@ export const buildTenantFullExport = async ( files.push({ id: file.id, path: file.path, - name: file.name || null, + name: file.name || filenameFromPath(file.path), mimeType: file.mimeType || null, size: file.size || null, contentBase64: object.contentBase64, @@ -358,6 +381,30 @@ export const buildTenantFullExport = async ( }) } + 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, @@ -383,7 +430,7 @@ export const createTenantFullExportArchive = async ( .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 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) @@ -522,7 +569,7 @@ const restoreArchiveFiles = async ( let skipped = 0 const filesByPath = new Map((manifest.files || []).map((file) => [file.path, file])) - for (const fileRow of exportData.tables.files || []) { + for (const fileRow of exportData.files || []) { const originalPath = fileRow.path if (!originalPath) { skipped += 1 @@ -834,10 +881,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 +910,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 +920,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 +929,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 + +

+