From e2c17aea18e06f19340b0d17e8f5db0165e1012b Mon Sep 17 00:00:00 2001 From: Z User Date: Thu, 29 Oct 2026 22:50:51 +0000 Subject: [PATCH] 883bab87-6894-46db-972a-fb5bc303c0eb --- src/app/api/aos/instances/[id]/route.ts | 56 +++++++++------------- src/app/api/aos/instances/cleanup/route.ts | 37 +++++++------- src/components/aos/views/instances.tsx | 6 +-- 3 files changed, 46 insertions(+), 53 deletions(-) diff --git a/src/app/api/aos/instances/[id]/route.ts b/src/app/api/aos/instances/[id]/route.ts index c8f51f9..78a726a 100755 --- a/src/app/api/aos/instances/[id]/route.ts +++ b/src/app/api/aos/instances/[id]/route.ts @@ -214,7 +214,14 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st // DELETE /api/aos/instances/[id] — tear down instance (idempotent, resumable) // Removes: Dokploy domain → database → application + volumes → DNS record. -// Never deletes the Instance DB row (keeps audit record) — sets status=deleted. +// +// HARD DELETE: the Instance DB row is deleted entirely (cascade removes +// InstanceEvent + ProvisioningJob). Instances are infrastructure, not +// accounting records — they have NO GoBD retention requirement. Only the +// accounting module (muhasebe/buchhaltung: invoices, expenses, kassenbuch, +// belege, buchungssätze, licenses) is exempt from hard-deletion for GoBD. +// The cross-cutting AuditLog entry (separate table, string entityId) is the +// persistent record that this action happened. export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params @@ -222,9 +229,6 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: if (!instance) { return NextResponse.json({ error: 'instance not found' }, { status: 404 }) } - if (instance.status === 'deleted') { - return NextResponse.json({ ok: true, message: 'already deleted', steps: [] }) - } await db.instance.update({ where: { id }, data: { status: 'deleting' } }) await db.instanceEvent.create({ @@ -285,8 +289,8 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: } // 3. Decide final state. - // - Both succeeded → 'deleted' (clear all IDs + secrets) - // - Either failed → 'failed' (keep IDs for reconciler retry) + // - Both succeeded → hard-delete the instance row (no GoBD on infra) + // - Either failed → keep row as 'failed' (retain IDs for reconciler retry) const now = new Date() if (dokployFailed || dnsFailed) { await db.instance.update({ @@ -319,44 +323,28 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: }, { status: 500 }) } - // 4. Both succeeded → mark deleted (keep row for audit — GoBD) - await db.instance.update({ - where: { id }, - data: { - status: 'deleted', - deletedAt: now, - dokployAppId: null, - dokployDbId: null, - dnsRecordId: null, - dokployDbPassword: null, // clear secrets - adminLoginToken: null, - }, - }) - - // 5. Unlink from license (keep license, just remove instance ref) - if (instance.licenseId) { - await db.license.update({ - where: { id: instance.licenseId }, - data: { instance: { disconnect: true } }, - }) - } - - await db.instanceEvent.create({ - data: { instanceId: id, type: 'delete.ok', step: 'done', result: 'ok', details: JSON.stringify({ steps, deletedAt: now }) }, - }) - + // 4. Both succeeded → hard-delete the instance row. + // Instances are infrastructure (not accounting), so there is no GoBD + // retention requirement on the row itself. Cascade deletes the + // InstanceEvent + ProvisioningJob rows. The AuditLog entry (separate + // table) is the durable record of this action. await db.auditLog.create({ data: { actor: 'admin:ui', action: 'instance.delete', entity: 'Instance', entityId: id, - after: JSON.stringify({ subdomain: instance.subdomain, steps }), + before: JSON.stringify({ subdomain: instance.subdomain, status: instance.status, product: instance.product, companyId: instance.companyId }), + after: JSON.stringify({ subdomain: instance.subdomain, steps, deletedAt: now }), hash: 'sha256:' + Math.random().toString(36).slice(2), }, }) - return NextResponse.json({ ok: true, status: 'deleted', steps, deletedAt: now }) + // Hard-delete: License 1:1 reverse relation is automatically cleared + // (the FK lives on the Instance row). Cascade removes events + jobs. + await db.instance.delete({ where: { id } }) + + return NextResponse.json({ ok: true, deleted: true, steps, deletedAt: now }) } catch (err) { const msg = err instanceof Error ? err.message : 'internal server error' return NextResponse.json({ error: msg }, { status: 500 }) diff --git a/src/app/api/aos/instances/cleanup/route.ts b/src/app/api/aos/instances/cleanup/route.ts index 3f50ac0..2570118 100755 --- a/src/app/api/aos/instances/cleanup/route.ts +++ b/src/app/api/aos/instances/cleanup/route.ts @@ -8,9 +8,12 @@ export const maxDuration = 300 // POST /api/aos/instances/cleanup // Cron job: run daily. Finds instances whose grace period has elapsed and -// tears them down (Dokploy + DNS + volumes). Also finds expired demos and -// moves them to grace. Idempotent — safe to call multiple times. +// tears them down (Dokploy + DNS + volumes), then HARD-DELETES the instance +// row. Also finds expired demos and moves them to grace. Idempotent — safe +// to call multiple times. // +// Instances are infrastructure, not accounting records — no GoBD retention. +// The cross-cutting AuditLog entry (separate table) records each deletion. // In production, this is triggered by a cron job (e.g. via Dokploy's cron // or a GitHub Action hitting this endpoint with a shared secret). export async function POST(req: Request) { @@ -126,21 +129,23 @@ export async function POST(req: Request) { results.failed++ results.details.push(`${inst.subdomain}: PARTIAL (${steps.join(', ')}) — retry next run`) } else { - // Both succeeded → mark deleted (clear IDs + secrets) - await db.instance.update({ - where: { id: inst.id }, + // Both succeeded → hard-delete the instance row. + // Instances are infrastructure (not accounting) — no GoBD retention. + // Cascade removes InstanceEvent + ProvisioningJob. The AuditLog + // entry (separate table) is the durable record. + await db.auditLog.create({ data: { - status: 'deleted', - deletedAt: now, - dokployAppId: null, - dokployDbId: null, - dokployDbPassword: null, - dnsRecordId: null, - adminLoginToken: null, + actor: 'cron:cleanup', + action: 'instance.delete', + entity: 'Instance', + entityId: inst.id, + before: JSON.stringify({ subdomain: inst.subdomain, status: inst.status, product: inst.product }), + after: JSON.stringify({ subdomain: inst.subdomain, steps, deletedAt: now }), + hash: 'sha256:' + Math.random().toString(36).slice(2), }, }) - // Unlink license + // Unlink license first (1:1 reverse relation) then hard-delete. if (inst.licenseId) { await db.license.update({ where: { id: inst.licenseId }, @@ -148,9 +153,9 @@ export async function POST(req: Request) { }) } - await db.instanceEvent.create({ - data: { instanceId: inst.id, type: 'cleanup.delete.ok', step: 'reconciler', result: 'ok', details: JSON.stringify({ steps }) }, - }) + await db.instance.delete({ where: { id: inst.id } }) + // NOTE: no post-delete InstanceEvent — the row is gone and cascade + // would wipe it anyway. The AuditLog entry above is the durable record. results.deleted++ results.details.push(`${inst.subdomain}: deleted (${steps.join(', ')})`) } diff --git a/src/components/aos/views/instances.tsx b/src/components/aos/views/instances.tsx index 8329118..9faddce 100755 --- a/src/components/aos/views/instances.tsx +++ b/src/components/aos/views/instances.tsx @@ -899,9 +899,9 @@ function InstanceDetailSheet({ {confirmingDelete ? (

- Die Instanz wird unwiderruflich gelöscht. Dokploy-Ressourcen, DNS-Einträge - und Volumes werden entfernt (die Datenbank-Zeile bleibt für das Audit-Log - erhalten). + Die Instanz wird unwiderruflich gelöscht. Dokploy-Ressourcen, DNS-Einträge, + Volumes und der Datenbank-Datensatz (inkl. Events & Jobs) werden entfernt. + Nur der Audit-Log-Eintrag bleibt bestehen.