Add prepared tenant export archives
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user