Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m0s
Build and Push Docker Images / build-frontend (push) Successful in 1m13s
Build and Push Docker Images / build-website (push) Successful in 20s
Build and Push Docker Images / build-docs (push) Successful in 16s
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m0s
Build and Push Docker Images / build-frontend (push) Successful in 1m13s
Build and Push Docker Images / build-website (push) Successful in 20s
Build and Push Docker Images / build-docs (push) Successful in 16s
This commit is contained in:
30
README.md
30
README.md
@@ -146,7 +146,7 @@ Auf einem frischen Server kannst du die Betriebsdateien und die Konfiguration di
|
|||||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Der schnelle One-Liner mit direktem Stack-Start:
|
Der One-Liner von der Webseite nutzt den schnellen Standardpfad und startet den Stack direkt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash -s -- --simple --start
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-install.sh | bash -s -- --simple --start
|
||||||
@@ -491,17 +491,41 @@ Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADM
|
|||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
Bei neuen Versionen:
|
Script-installierte Instanzen liegen standardmassig unter `/opt/fedeo`. Ein Update besteht daraus, die Selfhost-Dateien zu erneuern, die vorgebauten Images zu ziehen und den Compose-Stack neu zu starten.
|
||||||
|
|
||||||
|
Vor kritischen Updates sollte ein Backup der persistenten Daten erstellt werden:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cd /opt/fedeo
|
||||||
|
tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" .env postgres minio
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach die aktuellen Selfhost-Dateien laden und den Stack aktualisieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/fedeo
|
||||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml
|
||||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example
|
||||||
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-setup.sh -o /opt/fedeo/scripts/selfhost-setup.sh
|
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-setup.sh -o /opt/fedeo/scripts/selfhost-setup.sh
|
||||||
chmod +x /opt/fedeo/scripts/selfhost-setup.sh
|
chmod +x /opt/fedeo/scripts/selfhost-setup.sh
|
||||||
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml pull
|
||||||
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an. Bei kritischen Updates sollte vorher ein Backup von `./postgres` und `./minio` erstellt werden.
|
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an, solange `FEDEO_RUN_MIGRATIONS` nicht deaktiviert wurde. Falls Migrationen manuell erzwungen werden sollen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml run --rm backend npm run migrate
|
||||||
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Anschliessend den Zustand prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml ps
|
||||||
|
curl -I https://app.example.com
|
||||||
|
curl https://app.example.com/backend/health
|
||||||
|
```
|
||||||
|
|
||||||
Die Selfhost-Compose-Datei nutzt vorgebaute Images. Dadurch braucht der Server keinen Repository-Checkout und keine lokalen Build-Kontexte.
|
Die Selfhost-Compose-Datei nutzt vorgebaute Images. Dadurch braucht der Server keinen Repository-Checkout und keine lokalen Build-Kontexte.
|
||||||
|
|
||||||
|
|||||||
234
backend/scripts/apply-tenant-export-lock-migration.ts
Normal file
234
backend/scripts/apply-tenant-export-lock-migration.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import "dotenv/config"
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres"
|
||||||
|
import { sql } from "drizzle-orm"
|
||||||
|
import { Pool } from "pg"
|
||||||
|
|
||||||
|
import { loadSecrets, secrets } from "../src/utils/secrets"
|
||||||
|
|
||||||
|
const maskConnectionString = (value: string) =>
|
||||||
|
value.replace(/:\/\/([^:@]+):([^@]+)@/, "://$1:***@")
|
||||||
|
|
||||||
|
const logStep = (message: string) => {
|
||||||
|
console.log(`[tenant-lock-migration] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminateIdleBlockers = process.argv.includes("--terminate-idle-blockers")
|
||||||
|
|
||||||
|
const relationLocksQuery = (relationName: string) => sql`
|
||||||
|
SELECT
|
||||||
|
activity.pid,
|
||||||
|
activity.state,
|
||||||
|
activity.wait_event_type,
|
||||||
|
activity.wait_event,
|
||||||
|
now() - activity.query_start AS query_age,
|
||||||
|
locks.mode,
|
||||||
|
locks.granted,
|
||||||
|
left(activity.query, 500) AS query
|
||||||
|
FROM pg_locks locks
|
||||||
|
JOIN pg_class relation ON relation.oid = locks.relation
|
||||||
|
LEFT JOIN pg_stat_activity activity ON activity.pid = locks.pid
|
||||||
|
WHERE relation.relname = ${relationName}
|
||||||
|
ORDER BY locks.granted ASC, activity.query_start ASC NULLS LAST;
|
||||||
|
`
|
||||||
|
|
||||||
|
const getRelationLocks = async (db: ReturnType<typeof drizzle>, relationName: string) => {
|
||||||
|
const result = await db.execute(relationLocksQuery(relationName))
|
||||||
|
return result.rows as Record<string, any>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const printRelationLocks = async (db: ReturnType<typeof drizzle>, relationName: string) => {
|
||||||
|
const rows = await getRelationLocks(db, relationName)
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
logStep(`Keine aktiven Locks fuer ${relationName} gefunden`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logStep(`Aktive Locks fuer ${relationName}:`)
|
||||||
|
for (const row of rows) {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
pid: row.pid,
|
||||||
|
state: row.state,
|
||||||
|
waitEventType: row.wait_event_type,
|
||||||
|
waitEvent: row.wait_event,
|
||||||
|
queryAge: row.query_age,
|
||||||
|
mode: row.mode,
|
||||||
|
granted: row.granted,
|
||||||
|
query: row.query,
|
||||||
|
}, null, 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminateIdleRelationBlockers = async (db: ReturnType<typeof drizzle>, relationName: string) => {
|
||||||
|
const rows = await getRelationLocks(db, relationName)
|
||||||
|
const blockerPids = rows
|
||||||
|
.filter((row) =>
|
||||||
|
row.granted === true &&
|
||||||
|
row.state === "idle in transaction" &&
|
||||||
|
Number(row.pid) !== process.pid
|
||||||
|
)
|
||||||
|
.map((row) => Number(row.pid))
|
||||||
|
.filter((pid) => Number.isFinite(pid))
|
||||||
|
|
||||||
|
if (!blockerPids.length) {
|
||||||
|
logStep(`Keine idle-in-transaction Blocker fuer ${relationName} gefunden`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pid of blockerPids) {
|
||||||
|
logStep(`Beende idle-in-transaction DB-Session ${pid}`)
|
||||||
|
await db.execute(sql`SELECT pg_terminate_backend(${pid})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeStatement = async (
|
||||||
|
db: ReturnType<typeof drizzle>,
|
||||||
|
statement: ReturnType<typeof sql>,
|
||||||
|
) => {
|
||||||
|
await db.execute(statement)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLockError = async (
|
||||||
|
db: ReturnType<typeof drizzle>,
|
||||||
|
relationName: string,
|
||||||
|
) => {
|
||||||
|
logStep(`Konnte Lock fuer ${relationName} nicht rechtzeitig bekommen`)
|
||||||
|
await printRelationLocks(db, relationName)
|
||||||
|
|
||||||
|
if (!terminateIdleBlockers) return false
|
||||||
|
|
||||||
|
const terminated = await terminateIdleRelationBlockers(db, relationName)
|
||||||
|
if (terminated) {
|
||||||
|
logStep(`Retry nach beendeten idle-in-transaction Sessions fuer ${relationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return terminated
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeStep = async (
|
||||||
|
db: ReturnType<typeof drizzle>,
|
||||||
|
message: string,
|
||||||
|
statement: ReturnType<typeof sql>,
|
||||||
|
lockRelationName?: string,
|
||||||
|
) => {
|
||||||
|
logStep(message)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executeStatement(db, statement)
|
||||||
|
} catch (err: any) {
|
||||||
|
const code = err?.cause?.code || err?.code
|
||||||
|
if (!lockRelationName || !["55P03", "57014"].includes(code)) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldRetry = await handleLockError(db, lockRelationName)
|
||||||
|
if (!shouldRetry) throw err
|
||||||
|
|
||||||
|
await executeStatement(db, statement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (terminateIdleBlockers) {
|
||||||
|
logStep("Option --terminate-idle-blockers aktiv")
|
||||||
|
}
|
||||||
|
|
||||||
|
let connectionString = process.env.DATABASE_URL
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
logStep("DATABASE_URL nicht in process.env gefunden, lade Secrets")
|
||||||
|
await loadSecrets()
|
||||||
|
connectionString = secrets.DATABASE_URL
|
||||||
|
} else {
|
||||||
|
logStep("DATABASE_URL aus process.env gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
logStep(`Verbinde mit ${maskConnectionString(connectionString)}`)
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString,
|
||||||
|
max: 1,
|
||||||
|
connectionTimeoutMillis: 10000,
|
||||||
|
idleTimeoutMillis: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = drizzle(pool)
|
||||||
|
|
||||||
|
logStep("Teste DB-Verbindung")
|
||||||
|
await db.execute(sql`SELECT 1`)
|
||||||
|
|
||||||
|
logStep("Setze Lock- und Statement-Timeout")
|
||||||
|
await db.execute(sql`SET lock_timeout = '10s'`)
|
||||||
|
await db.execute(sql`SET statement_timeout = '2min'`)
|
||||||
|
|
||||||
|
await executeStep(db, "Erweitere tenant_export_jobs", sql`
|
||||||
|
ALTER TABLE "tenant_export_jobs"
|
||||||
|
ADD COLUMN IF NOT EXISTS "operation" text DEFAULT 'export' NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS "previous_tenant_locked" "locked_tenant";
|
||||||
|
`, "tenant_export_jobs")
|
||||||
|
|
||||||
|
await executeStep(db, "Erweitere tenants", sql`
|
||||||
|
ALTER TABLE "tenants"
|
||||||
|
ADD COLUMN IF NOT EXISTS "locked_by_export_job_id" uuid;
|
||||||
|
`, "tenants")
|
||||||
|
|
||||||
|
await executeStep(db, "Pruefe Foreign Key", sql`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_schema = 'public'
|
||||||
|
AND constraint_name = 'tenants_locked_by_export_job_id_tenant_export_jobs_id_fk'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "tenants"
|
||||||
|
ADD CONSTRAINT "tenants_locked_by_export_job_id_tenant_export_jobs_id_fk"
|
||||||
|
FOREIGN KEY ("locked_by_export_job_id")
|
||||||
|
REFERENCES "tenant_export_jobs"("id")
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`, "tenants")
|
||||||
|
|
||||||
|
await executeStep(db, "Pruefe Drizzle-Schema", sql`
|
||||||
|
CREATE SCHEMA IF NOT EXISTS drizzle;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await executeStep(db, "Pruefe Drizzle-Migrationstabelle", sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hash text NOT NULL,
|
||||||
|
created_at bigint
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
await executeStep(db, "Setze Drizzle-Journal auf 0055", sql`
|
||||||
|
INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
|
||||||
|
SELECT 'manual-0055_tenant_export_maintenance_lock', 1784021669805
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM drizzle.__drizzle_migrations
|
||||||
|
WHERE created_at >= 1784021669805
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
logStep("Fertig")
|
||||||
|
console.log("✅ Tenant export lock migration applied")
|
||||||
|
} finally {
|
||||||
|
logStep("Schließe DB-Verbindung")
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error("❌ Tenant export lock migration failed")
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -340,6 +340,127 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
return job;
|
return job;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const completeImportedTenantAccess = async (
|
||||||
|
currentUser: { id: string; email: string },
|
||||||
|
result: { tenantId: number }
|
||||||
|
) => {
|
||||||
|
const fallbackName = deriveNameFromEmail(currentUser.email);
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.insert(authTenantUsers)
|
||||||
|
.values({
|
||||||
|
tenant_id: result.tenantId,
|
||||||
|
user_id: currentUser.id,
|
||||||
|
created_by: currentUser.id,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
const [existingAdminProfile] = await server.db
|
||||||
|
.select({ id: authProfiles.id })
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(and(
|
||||||
|
eq(authProfiles.tenant_id, result.tenantId),
|
||||||
|
eq(authProfiles.user_id, currentUser.id)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingAdminProfile) {
|
||||||
|
await server.db
|
||||||
|
.insert(authProfiles)
|
||||||
|
.values({
|
||||||
|
tenant_id: result.tenantId,
|
||||||
|
user_id: currentUser.id,
|
||||||
|
first_name: fallbackName.first_name,
|
||||||
|
last_name: fallbackName.last_name,
|
||||||
|
email: currentUser.email,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let matrixProvisioned = false;
|
||||||
|
let matrixProvisioningError: string | null = null;
|
||||||
|
if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) {
|
||||||
|
try {
|
||||||
|
const matrix = matrixService(server);
|
||||||
|
await matrix.provisionTenantRoom(currentUser.id, result.tenantId, {
|
||||||
|
key: "allgemein",
|
||||||
|
name: "Allgemeiner Chat",
|
||||||
|
type: "general",
|
||||||
|
});
|
||||||
|
matrixProvisioned = true;
|
||||||
|
} catch (err: any) {
|
||||||
|
matrixProvisioningError = err?.message || String(err);
|
||||||
|
server.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matrixProvisioned,
|
||||||
|
matrixProvisioningError,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTenantImportJob = async (
|
||||||
|
jobId: string,
|
||||||
|
targetTenantId: number,
|
||||||
|
currentUser: { id: string; email: string },
|
||||||
|
importData: { type: "archive"; archiveBuffer: Buffer } | { type: "json"; exportData: TenantFullExport }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await server.db
|
||||||
|
.update(tenantExportJobs)
|
||||||
|
.set({
|
||||||
|
status: "running",
|
||||||
|
filesDone: 0,
|
||||||
|
filesTotal: 1,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(tenantExportJobs.id, jobId));
|
||||||
|
|
||||||
|
const onProgress = async ({ done, total }: { done: number; total: number }) => {
|
||||||
|
await server.db
|
||||||
|
.update(tenantExportJobs)
|
||||||
|
.set({
|
||||||
|
filesDone: done,
|
||||||
|
filesTotal: total,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(tenantExportJobs.id, jobId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = importData.type === "archive"
|
||||||
|
? await importTenantFullExportArchive(server, importData.archiveBuffer, { targetTenantId, onProgress })
|
||||||
|
: await importTenantFullExport(server, importData.exportData, { targetTenantId, onProgress });
|
||||||
|
|
||||||
|
await completeImportedTenantAccess(currentUser, result);
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.update(tenantExportJobs)
|
||||||
|
.set({
|
||||||
|
status: "ready",
|
||||||
|
filesDone: 1,
|
||||||
|
filesTotal: 1,
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
.where(eq(tenantExportJobs.id, jobId));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("ERROR tenant import job:", err);
|
||||||
|
await server.db
|
||||||
|
.update(tenantExportJobs)
|
||||||
|
.set({
|
||||||
|
status: "failed",
|
||||||
|
error: err?.message || String(err),
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(tenantExportJobs.id, jobId));
|
||||||
|
} finally {
|
||||||
|
await unlockTenantForJob(targetTenantId, jobId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => {
|
const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => {
|
||||||
try {
|
try {
|
||||||
await lockTenantForJob(tenantId, jobId);
|
await lockTenantForJob(tenantId, jobId);
|
||||||
@@ -1237,6 +1358,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
return {
|
return {
|
||||||
exportId: job.id,
|
exportId: job.id,
|
||||||
tenantId: job.tenantId,
|
tenantId: job.tenantId,
|
||||||
|
operation: job.operation,
|
||||||
status: job.status,
|
status: job.status,
|
||||||
filename: job.filename,
|
filename: job.filename,
|
||||||
fileSize: job.fileSize,
|
fileSize: job.fileSize,
|
||||||
@@ -1246,7 +1368,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
createdAt: job.createdAt,
|
createdAt: job.createdAt,
|
||||||
updatedAt: job.updatedAt,
|
updatedAt: job.updatedAt,
|
||||||
completedAt: job.completedAt,
|
completedAt: job.completedAt,
|
||||||
downloadUrl: job.status === "ready" ? `/api/admin/tenant-exports/${job.id}/download` : null,
|
downloadUrl: job.operation === "export" && job.status === "ready" ? `/api/admin/tenant-exports/${job.id}/download` : null,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("ERROR /admin/tenant-exports/:export_id:", err);
|
console.error("ERROR /admin/tenant-exports/:export_id:", err);
|
||||||
@@ -1293,15 +1415,13 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
// POST /admin/tenant-imports
|
// POST /admin/tenant-imports
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
server.post("/admin/tenant-imports", { bodyLimit: 1024 * 1024 * 1024 }, async (req, reply) => {
|
server.post("/admin/tenant-imports", { bodyLimit: 1024 * 1024 * 1024 }, async (req, reply) => {
|
||||||
let importJob: any = null;
|
|
||||||
let targetTenantId: number | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentUser = await requireAdmin(req, reply);
|
const currentUser = await requireAdmin(req, reply);
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
const isMultipart = req.headers["content-type"]?.includes("multipart/form-data");
|
const isMultipart = req.headers["content-type"]?.includes("multipart/form-data");
|
||||||
let result;
|
let result;
|
||||||
|
let targetTenantId: number | null = null;
|
||||||
|
|
||||||
if (isMultipart) {
|
if (isMultipart) {
|
||||||
const data: any = await req.file();
|
const data: any = await req.file();
|
||||||
@@ -1311,25 +1431,23 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null;
|
targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null;
|
||||||
|
|
||||||
if (targetTenantId) {
|
if (targetTenantId) {
|
||||||
importJob = await createTenantImportJob(targetTenantId, currentUser.id, data.filename || "tenant-import.zip");
|
const importJob = await createTenantImportJob(targetTenantId, currentUser.id, data.filename || "tenant-import.zip");
|
||||||
|
void startTenantImportJob(importJob.id, targetTenantId, currentUser, {
|
||||||
|
type: "archive",
|
||||||
|
archiveBuffer,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(202).send({
|
||||||
|
importId: importJob.id,
|
||||||
|
exportId: importJob.id,
|
||||||
|
tenantId: targetTenantId,
|
||||||
|
status: importJob.status,
|
||||||
|
filename: importJob.filename,
|
||||||
|
statusUrl: `/api/admin/tenant-exports/${importJob.id}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
result = await importTenantFullExportArchive(server, archiveBuffer, { targetTenantId });
|
||||||
result = await importTenantFullExportArchive(server, archiveBuffer, { targetTenantId });
|
|
||||||
} catch (err: any) {
|
|
||||||
if (importJob) {
|
|
||||||
await server.db
|
|
||||||
.update(tenantExportJobs)
|
|
||||||
.set({
|
|
||||||
status: "failed",
|
|
||||||
error: err?.message || String(err),
|
|
||||||
completedAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(tenantExportJobs.id, importJob.id));
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number };
|
const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number };
|
||||||
const exportData = "format" in body ? body : body.exportData;
|
const exportData = "format" in body ? body : body.exportData;
|
||||||
@@ -1340,88 +1458,26 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (targetTenantId) {
|
if (targetTenantId) {
|
||||||
importJob = await createTenantImportJob(targetTenantId, currentUser.id, "tenant-import.json");
|
const importJob = await createTenantImportJob(targetTenantId, currentUser.id, "tenant-import.json");
|
||||||
}
|
void startTenantImportJob(importJob.id, targetTenantId, currentUser, {
|
||||||
|
type: "json",
|
||||||
try {
|
exportData,
|
||||||
result = await importTenantFullExport(server, exportData, { targetTenantId });
|
|
||||||
} catch (err: any) {
|
|
||||||
if (importJob) {
|
|
||||||
await server.db
|
|
||||||
.update(tenantExportJobs)
|
|
||||||
.set({
|
|
||||||
status: "failed",
|
|
||||||
error: err?.message || String(err),
|
|
||||||
completedAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(tenantExportJobs.id, importJob.id));
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (importJob) {
|
|
||||||
await server.db
|
|
||||||
.update(tenantExportJobs)
|
|
||||||
.set({
|
|
||||||
status: "ready",
|
|
||||||
completedAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
.where(eq(tenantExportJobs.id, importJob.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackName = deriveNameFromEmail(currentUser.email);
|
|
||||||
|
|
||||||
await server.db
|
|
||||||
.insert(authTenantUsers)
|
|
||||||
.values({
|
|
||||||
tenant_id: result.tenantId,
|
|
||||||
user_id: currentUser.id,
|
|
||||||
created_by: currentUser.id,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
|
|
||||||
const [existingAdminProfile] = await server.db
|
|
||||||
.select({ id: authProfiles.id })
|
|
||||||
.from(authProfiles)
|
|
||||||
.where(and(
|
|
||||||
eq(authProfiles.tenant_id, result.tenantId),
|
|
||||||
eq(authProfiles.user_id, currentUser.id)
|
|
||||||
))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!existingAdminProfile) {
|
|
||||||
await server.db
|
|
||||||
.insert(authProfiles)
|
|
||||||
.values({
|
|
||||||
tenant_id: result.tenantId,
|
|
||||||
user_id: currentUser.id,
|
|
||||||
first_name: fallbackName.first_name,
|
|
||||||
last_name: fallbackName.last_name,
|
|
||||||
email: currentUser.email,
|
|
||||||
active: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return reply.code(202).send({
|
||||||
|
importId: importJob.id,
|
||||||
|
exportId: importJob.id,
|
||||||
|
tenantId: targetTenantId,
|
||||||
|
status: importJob.status,
|
||||||
|
filename: importJob.filename,
|
||||||
|
statusUrl: `/api/admin/tenant-exports/${importJob.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await importTenantFullExport(server, exportData, { targetTenantId });
|
||||||
}
|
}
|
||||||
|
|
||||||
let matrixProvisioned = false;
|
const { matrixProvisioned, matrixProvisioningError } = await completeImportedTenantAccess(currentUser, result);
|
||||||
let matrixProvisioningError: string | null = null;
|
|
||||||
if (process.env.MATRIX_REGISTRATION_SHARED_SECRET) {
|
|
||||||
try {
|
|
||||||
const matrix = matrixService(server);
|
|
||||||
await matrix.provisionTenantRoom(currentUser.id, result.tenantId, {
|
|
||||||
key: "allgemein",
|
|
||||||
name: "Allgemeiner Chat",
|
|
||||||
type: "general",
|
|
||||||
});
|
|
||||||
matrixProvisioned = true;
|
|
||||||
} catch (err: any) {
|
|
||||||
matrixProvisioningError = err?.message || String(err);
|
|
||||||
req.log.warn({ err }, "Matrix-Räume konnten nach Tenant-Import nicht neu provisioniert werden");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -1432,14 +1488,6 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("ERROR /admin/tenant-imports:", err);
|
console.error("ERROR /admin/tenant-imports:", err);
|
||||||
return reply.code(500).send({ error: err?.message || "Internal Server Error" });
|
return reply.code(500).send({ error: err?.message || "Internal Server Error" });
|
||||||
} finally {
|
|
||||||
if (importJob && targetTenantId) {
|
|
||||||
try {
|
|
||||||
await unlockTenantForJob(targetTenantId, importJob.id);
|
|
||||||
} catch (unlockErr) {
|
|
||||||
req.log.error({ err: unlockErr, importJobId: importJob.id, targetTenantId }, "Tenant konnte nach Import nicht entsperrt werden");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type ImportResult = {
|
|||||||
|
|
||||||
type ImportOptions = {
|
type ImportOptions = {
|
||||||
targetTenantId?: number | null
|
targetTenantId?: number | null
|
||||||
|
onProgress?: (progress: { done: number; total: number; message?: string }) => Promise<void> | void
|
||||||
}
|
}
|
||||||
|
|
||||||
type BuildTenantFullExportOptions = {
|
type BuildTenantFullExportOptions = {
|
||||||
@@ -264,6 +265,22 @@ const archiveEntryPathForFile = (path: string) => {
|
|||||||
return `files/${normalizedPath || "unnamed-file"}`
|
return `files/${normalizedPath || "unnamed-file"}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filenameFromPath = (path: string) => {
|
||||||
|
const filename = path
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.pop()
|
||||||
|
|
||||||
|
if (!filename) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(filename)
|
||||||
|
} catch {
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const jsonBuffer = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8")
|
const jsonBuffer = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8")
|
||||||
|
|
||||||
export const buildTenantFullExport = async (
|
export const buildTenantFullExport = async (
|
||||||
@@ -328,11 +345,17 @@ export const buildTenantFullExport = async (
|
|||||||
tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts)
|
tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileRows = tables.files || []
|
|
||||||
const files = []
|
const files = []
|
||||||
|
const filePaths = new Set<string>()
|
||||||
for (const file of fileRows) {
|
const addExportFile = async (file: {
|
||||||
if (!file.path) continue
|
id: string
|
||||||
|
path: string
|
||||||
|
name?: string | null
|
||||||
|
mimeType?: string | null
|
||||||
|
size?: number | null
|
||||||
|
}) => {
|
||||||
|
if (!file.path || filePaths.has(file.path)) return
|
||||||
|
filePaths.add(file.path)
|
||||||
|
|
||||||
const object = includeFileContents
|
const object = includeFileContents
|
||||||
? await loadObjectAsBase64(file.path)
|
? await loadObjectAsBase64(file.path)
|
||||||
@@ -349,7 +372,7 @@ export const buildTenantFullExport = async (
|
|||||||
files.push({
|
files.push({
|
||||||
id: file.id,
|
id: file.id,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
name: file.name || null,
|
name: file.name || filenameFromPath(file.path),
|
||||||
mimeType: file.mimeType || null,
|
mimeType: file.mimeType || null,
|
||||||
size: file.size || null,
|
size: file.size || null,
|
||||||
contentBase64: object.contentBase64,
|
contentBase64: object.contentBase64,
|
||||||
@@ -358,6 +381,30 @@ export const buildTenantFullExport = async (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const file of tables.files || []) {
|
||||||
|
if (!file.path) continue
|
||||||
|
|
||||||
|
await addExportFile({
|
||||||
|
id: file.id,
|
||||||
|
path: file.path,
|
||||||
|
name: file.name || null,
|
||||||
|
mimeType: file.mimeType || null,
|
||||||
|
size: file.size || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const letterhead of tables.letterheads || []) {
|
||||||
|
if (!letterhead.path) continue
|
||||||
|
|
||||||
|
await addExportFile({
|
||||||
|
id: `letterhead:${letterhead.id}`,
|
||||||
|
path: letterhead.path,
|
||||||
|
name: filenameFromPath(letterhead.path),
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
size: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
format: "fedeo.tenant-full-export",
|
format: "fedeo.tenant-full-export",
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -383,7 +430,7 @@ export const createTenantFullExportArchive = async (
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
const filename = options.filename || `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.fedeo-export.zip`
|
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 storagePath = options.storagePath || `tenant-exports/${tenantId}/${Date.now()}-${filename}`
|
||||||
const fileRows = exportData.tables.files || []
|
const fileRows = exportData.files || []
|
||||||
const archive = archiver("zip", { zlib: { level: 6 } })
|
const archive = archiver("zip", { zlib: { level: 6 } })
|
||||||
const tempPath = path.join(os.tmpdir(), `fedeo-tenant-export-${tenantId}-${Date.now()}-${filename}`)
|
const tempPath = path.join(os.tmpdir(), `fedeo-tenant-export-${tenantId}-${Date.now()}-${filename}`)
|
||||||
const output = createWriteStream(tempPath)
|
const output = createWriteStream(tempPath)
|
||||||
@@ -522,7 +569,7 @@ const restoreArchiveFiles = async (
|
|||||||
let skipped = 0
|
let skipped = 0
|
||||||
const filesByPath = new Map((manifest.files || []).map((file) => [file.path, file]))
|
const filesByPath = new Map((manifest.files || []).map((file) => [file.path, file]))
|
||||||
|
|
||||||
for (const fileRow of exportData.tables.files || []) {
|
for (const fileRow of exportData.files || []) {
|
||||||
const originalPath = fileRow.path
|
const originalPath = fileRow.path
|
||||||
if (!originalPath) {
|
if (!originalPath) {
|
||||||
skipped += 1
|
skipped += 1
|
||||||
@@ -834,10 +881,28 @@ export const importTenantFullExport = async (
|
|||||||
...importOrder,
|
...importOrder,
|
||||||
...Object.keys(exportData.tables).filter((table) => !importOrder.includes(table)).sort(),
|
...Object.keys(exportData.tables).filter((table) => !importOrder.includes(table)).sort(),
|
||||||
].filter((table, index, all) => all.indexOf(table) === index)
|
].filter((table, index, all) => all.indexOf(table) === index)
|
||||||
|
const specialTables = [
|
||||||
|
columnsByTable.has("bankaccounts") ? "bankaccounts" : null,
|
||||||
|
columnsByTable.has("bankstatements") ? "bankstatements" : null,
|
||||||
|
columnsByTable.has("letterheads") ? "letterheads" : null,
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
const regularTableNames = tableNames.filter((table) => !specialTables.includes(table))
|
||||||
|
const progressTotal = Math.max(1, specialTables.length + regularTableNames.length + 3)
|
||||||
|
let progressDone = 0
|
||||||
|
const reportProgress = async (message: string) => {
|
||||||
|
await options.onProgress?.({
|
||||||
|
done: Math.min(progressDone, progressTotal),
|
||||||
|
total: progressTotal,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
await client.query("begin")
|
await client.query("begin")
|
||||||
await client.query("set local session_replication_role = replica")
|
await client.query("set local session_replication_role = replica")
|
||||||
|
await reportProgress("Dateien werden vorbereitet")
|
||||||
const files = await restoreFiles(exportData)
|
const files = await restoreFiles(exportData)
|
||||||
|
progressDone += 1
|
||||||
|
await reportProgress("Dateien vorbereitet")
|
||||||
|
|
||||||
const importedTables: { table: string; rows: number }[] = []
|
const importedTables: { table: string; rows: number }[] = []
|
||||||
const bankaccountMetadata = columnsByTable.get("bankaccounts")
|
const bankaccountMetadata = columnsByTable.get("bankaccounts")
|
||||||
@@ -845,6 +910,8 @@ export const importTenantFullExport = async (
|
|||||||
const result = await insertIdentityRowsWithRemap(client, "bankaccounts", exportData.tables.bankaccounts || [], bankaccountMetadata)
|
const result = await insertIdentityRowsWithRemap(client, "bankaccounts", exportData.tables.bankaccounts || [], bankaccountMetadata)
|
||||||
remapTableColumn(exportData.tables.bankstatements, "account", result.idMap)
|
remapTableColumn(exportData.tables.bankstatements, "account", result.idMap)
|
||||||
importedTables.push({ table: "bankaccounts", rows: result.count })
|
importedTables.push({ table: "bankaccounts", rows: result.count })
|
||||||
|
progressDone += 1
|
||||||
|
await reportProgress("Bankkonten importiert")
|
||||||
}
|
}
|
||||||
|
|
||||||
const bankstatementMetadata = columnsByTable.get("bankstatements")
|
const bankstatementMetadata = columnsByTable.get("bankstatements")
|
||||||
@@ -853,6 +920,8 @@ export const importTenantFullExport = async (
|
|||||||
remapTableColumn(exportData.tables.statementallocations, "bs_id", result.idMap)
|
remapTableColumn(exportData.tables.statementallocations, "bs_id", result.idMap)
|
||||||
remapTableColumn(exportData.tables.historyitems, "bankstatement", result.idMap)
|
remapTableColumn(exportData.tables.historyitems, "bankstatement", result.idMap)
|
||||||
importedTables.push({ table: "bankstatements", rows: result.count })
|
importedTables.push({ table: "bankstatements", rows: result.count })
|
||||||
|
progressDone += 1
|
||||||
|
await reportProgress("Bankauszüge importiert")
|
||||||
}
|
}
|
||||||
|
|
||||||
const letterheadMetadata = columnsByTable.get("letterheads")
|
const letterheadMetadata = columnsByTable.get("letterheads")
|
||||||
@@ -860,25 +929,31 @@ export const importTenantFullExport = async (
|
|||||||
const result = await insertIdentityRowsWithRemap(client, "letterheads", exportData.tables.letterheads || [], letterheadMetadata)
|
const result = await insertIdentityRowsWithRemap(client, "letterheads", exportData.tables.letterheads || [], letterheadMetadata)
|
||||||
remapTableColumn(exportData.tables.createddocuments, "letterhead", result.idMap)
|
remapTableColumn(exportData.tables.createddocuments, "letterhead", result.idMap)
|
||||||
importedTables.push({ table: "letterheads", rows: result.count })
|
importedTables.push({ table: "letterheads", rows: result.count })
|
||||||
|
progressDone += 1
|
||||||
|
await reportProgress("Briefpapiere importiert")
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const table of tableNames) {
|
for (const table of regularTableNames) {
|
||||||
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)
|
||||||
if (!metadata) continue
|
if (!metadata) continue
|
||||||
|
|
||||||
const count = await insertRows(client, table, rows, metadata)
|
const count = await insertRows(client, table, rows, metadata)
|
||||||
importedTables.push({ table, rows: count })
|
importedTables.push({ table, rows: count })
|
||||||
|
progressDone += 1
|
||||||
|
await reportProgress(`${table} importiert`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanedCommunicationRooms = await cleanupImportedCommunicationRooms(client, exportData)
|
const cleanedCommunicationRooms = await cleanupImportedCommunicationRooms(client, exportData)
|
||||||
if (cleanedCommunicationRooms) {
|
if (cleanedCommunicationRooms) {
|
||||||
importedTables.push({ table: "communication_rooms_matrix_reset", rows: cleanedCommunicationRooms })
|
importedTables.push({ table: "communication_rooms_matrix_reset", rows: cleanedCommunicationRooms })
|
||||||
}
|
}
|
||||||
|
progressDone += 1
|
||||||
|
await reportProgress("Kommunikationsräume bereinigt")
|
||||||
|
|
||||||
await refreshSequences(client, columnsByTable)
|
await refreshSequences(client, columnsByTable)
|
||||||
|
progressDone = progressTotal
|
||||||
|
await reportProgress("Import abgeschlossen")
|
||||||
await client.query("commit")
|
await client.query("commit")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -54,11 +54,20 @@ export type TenantImportResult = {
|
|||||||
tenantId: number
|
tenantId: number
|
||||||
tables: { table: string; rows: number }[]
|
tables: { table: string; rows: number }[]
|
||||||
files: { restored: number; skipped: number }
|
files: { restored: number; skipped: number }
|
||||||
|
importId?: string
|
||||||
|
exportId?: string
|
||||||
|
status?: string
|
||||||
|
filename?: string
|
||||||
|
filesDone?: number
|
||||||
|
filesTotal?: number
|
||||||
|
error?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TenantExportJob = {
|
export type TenantExportJob = {
|
||||||
exportId: string
|
exportId: string
|
||||||
|
importId?: string
|
||||||
tenantId?: number
|
tenantId?: number
|
||||||
|
operation?: "export" | "import" | string
|
||||||
status: "queued" | "running" | "ready" | "failed" | string
|
status: "queued" | "running" | "ready" | "failed" | string
|
||||||
filename: string
|
filename: string
|
||||||
fileSize?: number | null
|
fileSize?: number | null
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ const tenantExportProgress = ref<null | {
|
|||||||
filesTotal: number
|
filesTotal: number
|
||||||
filename: string
|
filename: string
|
||||||
}>(null)
|
}>(null)
|
||||||
|
const tenantImportProgress = 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("")
|
||||||
@@ -169,6 +175,7 @@ const importTenantExport = async (event: Event) => {
|
|||||||
if (!file || importingTenant.value) return
|
if (!file || importingTenant.value) return
|
||||||
|
|
||||||
importingTenant.value = true
|
importingTenant.value = true
|
||||||
|
tenantImportProgress.value = null
|
||||||
lastImportResult.value = null
|
lastImportResult.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -188,6 +195,44 @@ const importTenantExport = async (event: Event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importJobId = result.importId || result.exportId
|
||||||
|
if (importJobId) {
|
||||||
|
let job = result
|
||||||
|
|
||||||
|
tenantImportProgress.value = {
|
||||||
|
status: job.status,
|
||||||
|
filesDone: job.filesDone || 0,
|
||||||
|
filesTotal: job.filesTotal || 0,
|
||||||
|
filename: job.filename || file.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!["ready", "failed"].includes(job.status)) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||||
|
job = await admin.getTenantExport(importJobId)
|
||||||
|
tenantImportProgress.value = {
|
||||||
|
status: job.status,
|
||||||
|
filesDone: job.filesDone || 0,
|
||||||
|
filesTotal: job.filesTotal || 0,
|
||||||
|
filename: job.filename || file.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.status === "failed") {
|
||||||
|
throw new Error(job.error || "Import konnte nicht abgeschlossen werden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchTenant()
|
||||||
|
await auth.fetchMe()
|
||||||
|
await auth.switchTenant(String(job.tenantId || targetTenantId))
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: "Mandantenimport abgeschlossen",
|
||||||
|
description: job.filename || file.name,
|
||||||
|
color: "green",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0)
|
const rowCount = (result.tables || []).reduce((sum, table) => sum + table.rows, 0)
|
||||||
|
|
||||||
lastImportResult.value = {
|
lastImportResult.value = {
|
||||||
@@ -396,6 +441,17 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
Export importieren
|
Export importieren
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<div v-if="tenantImportProgress" class="mt-4 space-y-2">
|
||||||
|
<UProgress
|
||||||
|
:model-value="tenantImportProgress.filesTotal ? Math.round((tenantImportProgress.filesDone / tenantImportProgress.filesTotal) * 100) : undefined"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
{{ tenantImportProgress.status === 'ready' ? 'Import abgeschlossen' : 'Import wird verarbeitet' }}
|
||||||
|
<span v-if="tenantImportProgress.filesTotal">
|
||||||
|
· {{ tenantImportProgress.filesDone }} / {{ tenantImportProgress.filesTotal }} Schritte
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user