Compare commits

...

3 Commits

Author SHA1 Message Date
c54e9a51a5 Fix tenant archive S3 upload
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 26s
Build and Push Docker Images / build-frontend (push) Successful in 58s
Build and Push Docker Images / build-website (push) Successful in 13s
Build and Push Docker Images / build-docs (push) Successful in 12s
2026-07-05 12:41:23 +02:00
3c4a75a858 Wire tenant archive export in frontend 2026-07-05 12:31:59 +02:00
0a7b0448ad Add prepared tenant export archives 2026-07-05 12:25:39 +02:00
9 changed files with 723 additions and 43 deletions

View File

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

View File

@@ -358,6 +358,13 @@
"when": 1780261200000, "when": 1780261200000,
"tag": "0050_outgoing_document_costcentres", "tag": "0050_outgoing_document_costcentres",
"breakpoints": true "breakpoints": true
},
{
"idx": 51,
"version": "7",
"when": 1783247083934,
"tag": "0054_tenant_export_jobs",
"breakpoints": true
} }
] ]
} }

View File

@@ -82,6 +82,7 @@ export * from "./taxtypes"
export * from "./telephony_calls" export * from "./telephony_calls"
export * from "./telephony_extensions" export * from "./telephony_extensions"
export * from "./telephony_trunks" export * from "./telephony_trunks"
export * from "./tenant_export_jobs"
export * from "./tenants" export * from "./tenants"
export * from "./texttemplates" export * from "./texttemplates"
export * from "./units" export * from "./units"

View File

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

View File

@@ -1,5 +1,7 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { and, eq, inArray, isNull } from "drizzle-orm"; import { and, eq, inArray, isNull } from "drizzle-orm";
import multipart from "@fastify/multipart";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { import {
authTenantUsers, authTenantUsers,
@@ -10,17 +12,28 @@ import {
authUsers, authUsers,
filetags, filetags,
folders, folders,
tenantExportJobs,
tenants, tenants,
} from "../../db/schema"; } from "../../db/schema";
import { generateRandomPassword, hashPassword } from "../utils/password"; import { generateRandomPassword, hashPassword } from "../utils/password";
import { sendMail } from "../utils/mailer"; import { sendMail } from "../utils/mailer";
import { ensureTenantBaseData } from "../modules/bootstrap.service"; 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 type { TenantFullExport } from "../utils/tenantFullExport";
import { buildSystemStatus } from "../modules/system-status.service"; import { buildSystemStatus } from "../modules/system-status.service";
import { matrixService } from "../modules/matrix.service"; import { matrixService } from "../modules/matrix.service";
import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
export default async function adminRoutes(server: FastifyInstance) { export default async function adminRoutes(server: FastifyInstance) {
await server.register(multipart, {
limits: { fileSize: 1024 * 1024 * 1024 }
});
const deriveNameFromEmail = (email: string) => { const deriveNameFromEmail = (email: string) => {
const localPart = email.split("@")[0] || "Benutzer"; const localPart = email.split("@")[0] || "Benutzer";
const normalized = localPart.replace(/[._-]+/g, " ").trim(); 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) => { const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.user?.user_id) { if (!req.user?.user_id) {
reply.code(401).send({ error: "Unauthorized" }); 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" }); return reply.code(400).send({ error: "tenant_id required" });
} }
const exportData = await buildTenantFullExport(server, tenantId); const job = await createPreparedTenantExport(tenantId, currentUser.id);
const safeTenantName = String(exportData.tables.tenants?.[0]?.short || exportData.tables.tenants?.[0]?.name || tenantId) if (!job) {
.replace(/[^a-z0-9_-]+/gi, "-") return reply.code(404).send({ error: "Tenant not found" });
.replace(/^-+|-+$/g, "") }
.toLowerCase();
const filename = `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.json`;
reply.header("Content-Type", "application/json"); return reply.code(202).send({
reply.header("Content-Disposition", `attachment; filename="${filename}"`); exportId: job.id,
return reply.send(exportData); status: job.status,
filename: job.filename,
statusUrl: `/api/admin/tenant-exports/${job.id}`,
downloadUrl: `/api/admin/tenant-exports/${job.id}/download`,
});
} catch (err) { } catch (err) {
console.error("ERROR /admin/tenants/:tenant_id/export:", err); console.error("ERROR /admin/tenants/:tenant_id/export:", err);
return reply.code(500).send({ error: "Internal Server Error" }); 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 // POST /admin/tenant-imports
// ------------------------------------------------------------- // -------------------------------------------------------------
@@ -993,15 +1204,27 @@ export default async function adminRoutes(server: FastifyInstance) {
const currentUser = await requireAdmin(req, reply); const currentUser = await requireAdmin(req, reply);
if (!currentUser) return; if (!currentUser) return;
const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number }; const isMultipart = req.headers["content-type"]?.includes("multipart/form-data");
const exportData = "format" in body ? body : body.exportData; let result;
const targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null;
if (!exportData) { if (isMultipart) {
return reply.code(400).send({ error: "exportData required" }); 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); const fallbackName = deriveNameFromEmail(currentUser.email);
await server.db await server.db

View File

@@ -1,5 +1,11 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3" 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 { pool } from "../../db"
import { s3 } from "./s3" import { s3 } from "./s3"
@@ -41,6 +47,34 @@ type ImportOptions = {
targetTenantId?: number | null 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> | void
}
const ENTITY_BANKACCOUNT_PLAIN_FIELDS = { const ENTITY_BANKACCOUNT_PLAIN_FIELDS = {
iban: "__plainIban", iban: "__plainIban",
bic: "__plainBic", bic: "__plainBic",
@@ -196,8 +230,49 @@ const loadObjectAsBase64 = async (path: string) => {
} }
} }
export const buildTenantFullExport = async (server: FastifyInstance, tenantId: number): Promise<TenantFullExport> => { 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<TenantFullExport> => {
const client = await pool.connect() const client = await pool.connect()
const includeFileContents = options.includeFileContents !== false
try { try {
const columnsByTable = await tableColumns(client) const columnsByTable = await tableColumns(client)
@@ -259,7 +334,9 @@ export const buildTenantFullExport = async (server: FastifyInstance, tenantId: n
for (const file of fileRows) { for (const file of fileRows) {
if (!file.path) continue 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) { if (object.missing) {
server.log.warn({ server.log.warn({
fileId: file.id, 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<void>((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) => { const restoreFiles = async (exportData: TenantFullExport) => {
let restored = 0 let restored = 0
let skipped = 0 let skipped = 0
@@ -318,6 +506,56 @@ const restoreFiles = async (exportData: TenantFullExport) => {
return { restored, skipped } return { restored, skipped }
} }
const readZipTextEntry = async (entriesByName: Map<string, any>, 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<string, any>,
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 = ( const remapTenantScopedExport = (
exportData: TenantFullExport, exportData: TenantFullExport,
targetTenantId?: number | null targetTenantId?: number | null
@@ -644,3 +882,70 @@ export const importTenantFullExport = async (
client.release() client.release()
} }
} }
export const importTenantFullExportArchive = async (
server: FastifyInstance,
archiveBuffer: Buffer,
options: ImportOptions = {}
): Promise<ImportResult> => {
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()
}
}

View File

@@ -55,6 +55,22 @@ export type TenantImportResult = {
files: { restored: number; skipped: number } 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 = { export type SystemStatus = {
checkedAt: string checkedAt: string
backend: { backend: {
@@ -173,13 +189,23 @@ export const useAdmin = () => {
}) })
} }
const exportTenant = async (id: number): Promise<Blob> => { const startTenantExport = async (id: number): Promise<TenantExportJob> => {
return await $api(`/api/admin/tenants/${id}/export`, { return await $api(`/api/admin/tenants/${id}/exports`, {
method: "POST",
})
}
const getTenantExport = async (exportId: string): Promise<TenantExportJob> => {
return await $api(`/api/admin/tenant-exports/${exportId}`)
}
const downloadTenantExport = async (exportId: string): Promise<Blob> => {
return await $api(`/api/admin/tenant-exports/${exportId}/download`, {
responseType: "blob", responseType: "blob",
}) })
} }
const importTenant = async (body: Record<string, any>): Promise<TenantImportResult> => { const importTenant = async (body: Record<string, any> | FormData): Promise<TenantImportResult> => {
return await $api("/api/admin/tenant-imports", { return await $api("/api/admin/tenant-imports", {
method: "POST", method: "POST",
body, body,
@@ -222,7 +248,9 @@ export const useAdmin = () => {
createTenant, createTenant,
invitePortalUser, invitePortalUser,
updateTenant, updateTenant,
exportTenant, startTenantExport,
getTenantExport,
downloadTenantExport,
importTenant, importTenant,
} }
} }

View File

@@ -12,6 +12,12 @@ const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const exportingTenant = ref(false) const exportingTenant = ref(false)
const importingTenant = ref(false) const importingTenant = ref(false)
const tenantExportProgress = ref<null | {
status: string
filesDone: number
filesTotal: number
filename: string
}>(null)
const creatingUser = ref(false) const creatingUser = ref(false)
const createUserModalOpen = ref(false) const createUserModalOpen = ref(false)
const createdUserPassword = ref("") const createdUserPassword = ref("")
@@ -93,26 +99,46 @@ const downloadTenantExport = async () => {
if (!tenantForm.value || exportingTenant.value) return if (!tenantForm.value || exportingTenant.value) return
exportingTenant.value = true exportingTenant.value = true
tenantExportProgress.value = null
try { 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 objectUrl = URL.createObjectURL(blob)
const link = document.createElement("a") 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.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) document.body.appendChild(link)
link.click() link.click()
link.remove() link.remove()
URL.revokeObjectURL(objectUrl) URL.revokeObjectURL(objectUrl)
toast.add({ title: "Mandantenexport erstellt", color: "green" }) toast.add({ title: "Mandantenexport erstellt", description: job.filename, color: "green" })
} catch (err: any) { } catch (err: any) {
console.error("[administration/tenants/export]", err) console.error("[administration/tenants/export]", err)
toast.add({ toast.add({
@@ -138,12 +164,22 @@ const importTenantExport = async (event: Event) => {
lastImportResult.value = null lastImportResult.value = null
try { try {
const rawContent = await file.text() const targetTenantId = tenantForm.value?.id || tenantId
const exportData = JSON.parse(rawContent) const isZipExport = file.name.endsWith(".zip") || file.name.endsWith(".fedeo-export.zip") || file.type === "application/zip"
const result = await admin.importTenant({ let result
exportData,
targetTenantId: tenantForm.value?.id || tenantId, 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) const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0)
lastImportResult.value = { lastImportResult.value = {
@@ -290,8 +326,19 @@ onMounted(async () => {
<div class="border border-gray-200 dark:border-gray-800 rounded-lg p-4"> <div class="border border-gray-200 dark:border-gray-800 rounded-lg p-4">
<div class="font-medium">Full Export</div> <div class="font-medium">Full Export</div>
<p class="text-sm text-gray-500 mt-1"> <p class="text-sm text-gray-500 mt-1">
Erstellt eine portable JSON-Datei mit Mandantendaten, Benutzerzuordnungen, Rollen, Profilen und Dateiinhalten. Erstellt ein portables ZIP-Archiv mit Mandantendaten, Benutzerzuordnungen, Rollen, Profilen und Dateien.
</p> </p>
<div v-if="tenantExportProgress" class="mt-4 space-y-2">
<UProgress
:model-value="tenantExportProgress.filesTotal ? Math.round((tenantExportProgress.filesDone / tenantExportProgress.filesTotal) * 100) : undefined"
/>
<p class="text-xs text-gray-500">
{{ tenantExportProgress.status === 'ready' ? 'Download wird vorbereitet' : 'Export wird erstellt' }}
<span v-if="tenantExportProgress.filesTotal">
· {{ tenantExportProgress.filesDone }} / {{ tenantExportProgress.filesTotal }} Dateien
</span>
</p>
</div>
<UButton <UButton
class="mt-4" class="mt-4"
icon="i-heroicons-arrow-down-tray" icon="i-heroicons-arrow-down-tray"
@@ -310,7 +357,7 @@ onMounted(async () => {
<input <input
ref="importFileInput" ref="importFileInput"
type="file" type="file"
accept="application/json,.json" accept="application/json,application/zip,.json,.zip,.fedeo-export.zip"
class="hidden" class="hidden"
@change="importTenantExport" @change="importTenantExport"
> >

View File

@@ -103,8 +103,17 @@ const importTenantExport = async (event: Event) => {
importingTenant.value = true importingTenant.value = true
try { try {
const exportData = JSON.parse(await file.text()) const isZipExport = file.name.endsWith(".zip") || file.name.endsWith(".fedeo-export.zip") || file.type === "application/zip"
const result = await admin.importTenant(exportData) 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) const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0)
await fetchTenants() await fetchTenants()
@@ -158,7 +167,7 @@ onMounted(async () => {
<input <input
ref="importFileInput" ref="importFileInput"
type="file" type="file"
accept="application/json,.json" accept="application/json,application/zip,.json,.zip,.fedeo-export.zip"
class="hidden" class="hidden"
@change="importTenantExport" @change="importTenantExport"
> >