Add tenant import progress polling

This commit is contained in:
2026-07-14 17:27:59 +02:00
parent 473c92d2e4
commit a9b063aa89
4 changed files with 253 additions and 111 deletions

View File

@@ -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;
}
} 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");
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}`,
});
}
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,
});
}
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");
}
}
}
});

View File

@@ -45,6 +45,7 @@ type ImportResult = {
type ImportOptions = {
targetTenantId?: number | null
onProgress?: (progress: { done: number; total: number; message?: string }) => Promise<void> | 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 {

View File

@@ -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

View File

@@ -18,6 +18,12 @@ const tenantExportProgress = ref<null | {
filesTotal: number
filename: string
}>(null)
const tenantImportProgress = ref<null | {
status: string
filesDone: number
filesTotal: number
filename: string
}>(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
</UButton>
<div v-if="tenantImportProgress" class="mt-4 space-y-2">
<UProgress
:model-value="tenantImportProgress.filesTotal ? Math.round((tenantImportProgress.filesDone / tenantImportProgress.filesTotal) * 100) : undefined"
/>
<p class="text-xs text-gray-500">
{{ tenantImportProgress.status === 'ready' ? 'Import abgeschlossen' : 'Import wird verarbeitet' }}
<span v-if="tenantImportProgress.filesTotal">
· {{ tenantImportProgress.filesDone }} / {{ tenantImportProgress.filesTotal }} Schritte
</span>
</p>
</div>
</div>
</div>