Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
19
backend/db/migrations/0054_tenant_export_jobs.sql
Normal file
19
backend/db/migrations/0054_tenant_export_jobs.sql
Normal 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");
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
41
backend/db/schema/tenant_export_jobs.ts
Normal file
41
backend/db/schema/tenant_export_jobs.ts
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -349,6 +587,10 @@ const remapTenantScopedExport = (
|
|||||||
nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}`
|
nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (table === "letterheads" && typeof nextRow.path === "string" && nextRow.path.startsWith(sourcePathPrefix)) {
|
||||||
|
nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}`
|
||||||
|
}
|
||||||
|
|
||||||
return nextRow
|
return nextRow
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -613,8 +855,15 @@ export const importTenantFullExport = async (
|
|||||||
importedTables.push({ table: "bankstatements", rows: result.count })
|
importedTables.push({ table: "bankstatements", rows: result.count })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const letterheadMetadata = columnsByTable.get("letterheads")
|
||||||
|
if (letterheadMetadata) {
|
||||||
|
const result = await insertIdentityRowsWithRemap(client, "letterheads", exportData.tables.letterheads || [], letterheadMetadata)
|
||||||
|
remapTableColumn(exportData.tables.createddocuments, "letterhead", result.idMap)
|
||||||
|
importedTables.push({ table: "letterheads", rows: result.count })
|
||||||
|
}
|
||||||
|
|
||||||
for (const table of tableNames) {
|
for (const table of tableNames) {
|
||||||
if (["bankaccounts", "bankstatements"].includes(table)) continue
|
if (["bankaccounts", "bankstatements", "letterheads"].includes(table)) continue
|
||||||
|
|
||||||
const rows = exportData.tables[table] || []
|
const rows = exportData.tables[table] || []
|
||||||
const metadata = columnsByTable.get(table)
|
const metadata = columnsByTable.get(table)
|
||||||
@@ -644,3 +893,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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user