Add prepared tenant export archives

This commit is contained in:
2026-07-05 12:25:39 +02:00
parent 033e74adda
commit 0a7b0448ad
6 changed files with 601 additions and 19 deletions

View File

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