From 8c34778db37d90c2920903a204a0069004ae3304 Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 28 Oct 2026 17:00:06 +0000 Subject: [PATCH] 0bb384e7-e18c-49c5-b7ef-fe73701ab6ab --- src/app/api/aos/agency/[id]/approve/route.ts | 2 +- src/app/api/aos/instances/[id]/route.ts | 52 +++++++++++++- src/app/api/aos/instances/cleanup/route.ts | 75 +++++++++++++------- src/components/aos/command-menu.tsx | 2 +- src/components/aos/sidebar-nav.tsx | 2 +- src/components/aos/top-bar.tsx | 2 +- src/components/aos/views/agency.tsx | 34 ++++----- src/lib/i18n.ts | 4 +- 8 files changed, 121 insertions(+), 52 deletions(-) diff --git a/src/app/api/aos/agency/[id]/approve/route.ts b/src/app/api/aos/agency/[id]/approve/route.ts index 422b346..dd8e418 100644 --- a/src/app/api/aos/agency/[id]/approve/route.ts +++ b/src/app/api/aos/agency/[id]/approve/route.ts @@ -73,7 +73,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st }) // TODO (production): send email to company.email with subject - // "Ihre Bayi-Bewerbung wurde genehmigt — {discountPct}% Rabatt" + // "Ihre Reseller-Bewerbung wurde genehmigt — {discountPct}% Rabatt" // containing the discount and a link to the agency portal. return NextResponse.json({ diff --git a/src/app/api/aos/instances/[id]/route.ts b/src/app/api/aos/instances/[id]/route.ts index 96e8a08..bec2589 100644 --- a/src/app/api/aos/instances/[id]/route.ts +++ b/src/app/api/aos/instances/[id]/route.ts @@ -176,6 +176,9 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: // Parse metadata for Dokploy domain ID const meta = instance.metadata ? JSON.parse(instance.metadata) as { dokployDomainId?: string; url?: string } : {} + let dokployFailed = false + let dnsFailed = false + // 1. Remove Dokploy domain + database + application (idempotent) if (dokploy) { try { @@ -189,6 +192,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: data: { instanceId: id, type: 'delete.dokploy.done', step: 'dokploy', result: 'ok', details: JSON.stringify({ steps: result.steps }) }, }) } catch (e) { + dokployFailed = true steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`) await db.instanceEvent.create({ data: { instanceId: id, type: 'delete.dokploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) }, @@ -198,7 +202,11 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: steps.push('dokploy.skipped (not configured)') } - // 2. Remove DNS A record (if per-instance, not wildcard) + // 2. Remove DNS A record (if per-instance, not wildcard). + // IMPORTANT: Dokploy silme başarısız olsa bile DNS'i silmeye çalış — + // orphaned DNS record subdomain takeover riski taşır. Ama DNS silme + // hata verirse dnsRecordId'yi KORU, instance'ı failed yap ki reconciler + // sonra tekrar deneyebilsin. if (dns && instance.dnsRecordId && instance.dnsRecordId !== 'wildcard') { try { await dns.deleteRecord(instance.dnsRecordId) @@ -207,12 +215,50 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: data: { instanceId: id, type: 'delete.dns.removed', step: 'dns', result: 'ok', details: JSON.stringify({ recordId: instance.dnsRecordId }) }, }) } catch (e) { + dnsFailed = true steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`) + await db.instanceEvent.create({ + data: { instanceId: id, type: 'delete.dns.error', step: 'dns', result: 'error', details: JSON.stringify({ recordId: instance.dnsRecordId, error: e instanceof Error ? e.message : 'unknown' }) }, + }) } } - // 3. Mark deleted (keep the row for audit — GoBD) + // 3. Decide final state. + // - Both succeeded → 'deleted' (clear all IDs + secrets) + // - Either failed → 'failed' (keep IDs for reconciler retry) const now = new Date() + if (dokployFailed || dnsFailed) { + await db.instance.update({ + where: { id }, + data: { + status: 'failed', + lastError: `deletion partial: ${steps.join(', ')}`, + lastErrorAt: now, + // Keep dokployAppId + dnsRecordId so reconciler can retry. + }, + }) + await db.instanceEvent.create({ + data: { instanceId: id, type: 'delete.partial', step: 'done', result: 'error', details: JSON.stringify({ steps, dokployFailed, dnsFailed }) }, + }) + await db.auditLog.create({ + data: { + actor: 'admin:ui', + action: 'instance.delete.partial', + entity: 'Instance', + entityId: id, + after: JSON.stringify({ subdomain: instance.subdomain, steps, dokployFailed, dnsFailed, message: 'reconciler will retry' }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + return NextResponse.json({ + ok: false, + status: 'failed', + steps, + message: 'Löschung teilweise fehlgeschlagen — Reconciler wird erneut versuchen. Dokploy + Hetzner A record birlikte silinmedi; orphaned resource riski için instance failed durumda.', + }, { status: 500 }) + } + + // 4. Both succeeded → mark deleted (keep row for audit — GoBD) await db.instance.update({ where: { id }, data: { @@ -226,7 +272,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: }, }) - // 4. Unlink from license (keep license, just remove instance ref) + // 5. Unlink from license (keep license, just remove instance ref) if (instance.licenseId) { await db.license.update({ where: { id: instance.licenseId }, diff --git a/src/app/api/aos/instances/cleanup/route.ts b/src/app/api/aos/instances/cleanup/route.ts index fd80b20..c76eae3 100644 --- a/src/app/api/aos/instances/cleanup/route.ts +++ b/src/app/api/aos/instances/cleanup/route.ts @@ -80,6 +80,8 @@ export async function POST(req: Request) { const meta = inst.metadata ? JSON.parse(inst.metadata) as { dokployDomainId?: string } : {} const steps: string[] = [] + let dokployFailed = false + let dnsFailed = false // Teardown Dokploy resources if (dokploy) { @@ -91,47 +93,68 @@ export async function POST(req: Request) { }) steps.push(...r.steps) } catch (e) { + dokployFailed = true steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`) } } - // Remove DNS record + // Remove DNS record (even if Dokploy failed — orphaned DNS = takeover risk) if (dns && inst.dnsRecordId && inst.dnsRecordId !== 'wildcard') { try { await dns.deleteRecord(inst.dnsRecordId) steps.push('dns.removed') } catch (e) { + dnsFailed = true steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`) } } - // Mark deleted - await db.instance.update({ - where: { id: inst.id }, - data: { - status: 'deleted', - deletedAt: now, - dokployAppId: null, - dokployDbId: null, - dokployDbPassword: null, - dnsRecordId: null, - adminLoginToken: null, - }, - }) - - // Unlink license - if (inst.licenseId) { - await db.license.update({ - where: { id: inst.licenseId }, - data: { instance: { disconnect: true } }, + // Decide final state — both must succeed for full deletion + if (dokployFailed || dnsFailed) { + // Partial failure → keep IDs for next reconciler run + await db.instance.update({ + where: { id: inst.id }, + data: { + status: 'failed', + lastError: `cleanup partial: ${steps.join(', ')}`, + lastErrorAt: now, + // Keep dokployAppId + dnsRecordId for retry + }, + }) + await db.instanceEvent.create({ + data: { instanceId: inst.id, type: 'cleanup.delete.partial', step: 'reconciler', result: 'error', details: JSON.stringify({ steps, dokployFailed, dnsFailed }) }, + }) + 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 }, + data: { + status: 'deleted', + deletedAt: now, + dokployAppId: null, + dokployDbId: null, + dokployDbPassword: null, + dnsRecordId: null, + adminLoginToken: null, + }, }) - } - await db.instanceEvent.create({ - data: { instanceId: inst.id, type: 'cleanup.delete.ok', step: 'reconciler', result: 'ok', details: JSON.stringify({ steps }) }, - }) - results.deleted++ - results.details.push(`${inst.subdomain}: deleted (${steps.join(', ')})`) + // Unlink license + if (inst.licenseId) { + await db.license.update({ + where: { id: inst.licenseId }, + data: { instance: { disconnect: true } }, + }) + } + + await db.instanceEvent.create({ + data: { instanceId: inst.id, type: 'cleanup.delete.ok', step: 'reconciler', result: 'ok', details: JSON.stringify({ steps }) }, + }) + results.deleted++ + results.details.push(`${inst.subdomain}: deleted (${steps.join(', ')})`) + } } catch (e) { results.failed++ results.details.push(`${inst.subdomain}: FAILED (${e instanceof Error ? e.message : 'unknown'})`) diff --git a/src/components/aos/command-menu.tsx b/src/components/aos/command-menu.tsx index e89e2fc..f9529fd 100755 --- a/src/components/aos/command-menu.tsx +++ b/src/components/aos/command-menu.tsx @@ -50,7 +50,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st { id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' }, { id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' }, { id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' }, - { id: 'agency', label: 'Bayis', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' }, + { id: 'agency', label: 'Reseller', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' }, { id: 'instances', label: 'Instanzen', icon: Server, hint: 'Tenant-Instanzen & Provisioning' }, { id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' }, { id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' }, diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index 26bdf24..a800e36 100755 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -68,7 +68,7 @@ const NAV: { group: 'nav.operations', items: [ { id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' }, - { id: 'agency', label: 'Bayis', icon: Users, permission: 'admin' }, + { id: 'agency', label: 'Reseller', icon: Users, permission: 'admin' }, { id: 'instances', label: 'Instanzen', icon: Server, permission: 'admin' }, { id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' }, { id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' }, diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index a9af470..69fb15c 100755 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -28,7 +28,7 @@ const TITLES: Record = { timesheet: { title: 'Zeiterfassung', crumb: 'Lieferung / Zeiterfassung' }, support: { title: 'Support', crumb: 'Lieferung / Tickets' }, licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' }, - agency: { title: 'Bayis', crumb: 'Betrieb / Bayis' }, + agency: { title: 'Reseller', crumb: 'Betrieb / Reseller' }, instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' }, contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' }, expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' }, diff --git a/src/components/aos/views/agency.tsx b/src/components/aos/views/agency.tsx index ac53d9d..8f993bd 100644 --- a/src/components/aos/views/agency.tsx +++ b/src/components/aos/views/agency.tsx @@ -135,8 +135,8 @@ export function AgencyView() { return (
{/* Stats */} @@ -148,7 +148,7 @@ export function AgencyView() { hint="wartet auf Freigabe" />
@@ -215,7 +215,7 @@ function PendingApplicationsSection({

- Bewerbende Bayis + Bewerbende Reseller {applications.length} offen @@ -230,7 +230,7 @@ function PendingApplicationsSection({

Keine bewerbenden Anfragen

- Neue Bayi-Bewerbungen erscheinen hier. + Neue Reseller-Bewerbungen erscheinen hier.

@@ -393,11 +393,11 @@ function ApproveDialog({ - Bayi-Bewerbung genehmigen + Reseller-Bewerbung genehmigen {application.company.name} wird - als Bayi freigeschaltet. Der Rabatt wird auf alle vom Bayi angelegten Kundenlizenzierungen + als Reseller freigeschaltet. Der Rabatt wird auf alle vom Reseller angelegten Kundenlizenzierungen angewendet. @@ -524,7 +524,7 @@ function RejectDialog({ - Bayi-Bewerbung ablehnen + Reseller-Bewerbung ablehnen {application.company.name} @@ -577,7 +577,7 @@ function ApprovedAgenciesSection({ agencies }: { agencies: ApprovedAgency[] }) {

- Genehmigte Bayis + Genehmigte Reseller {agencies.length} aktiv @@ -590,23 +590,23 @@ function ApprovedAgenciesSection({ agencies }: { agencies: ApprovedAgency[] }) {

-

Noch keine Bayis genehmigt

+

Noch keine Reseller genehmigt

- Genehmigte Bayi-Bewerbungen erscheinen hier mit ihren Kunden und Lizenzen. + Genehmigte Reseller-Bewerbungen erscheinen hier mit ihren Kunden und Lizenzen.

) : ( - Bayis ({agencies.length}) + Reseller ({agencies.length})
- + @@ -692,7 +692,7 @@ function ClientsSheet({ {agency.name} - Bayi-Kundenverwaltung — {agency._count.agencyClients} Kunde(n),{' '} + Reseller-Kundenverwaltung — {agency._count.agencyClients} Kunde(n),{' '} {agency._count.licenses} Lizenz(en). @@ -702,8 +702,8 @@ function ClientsSheet({

- Bayi kann eigene Kunden anlegen und lizenzieren. Tickets von dessen Kunden werden - an den Bayi geroutet, nicht an uns. + Reseller kann eigene Kunden anlegen und lizenzieren. Tickets von dessen Kunden werden + an den Reseller geroutet, nicht an uns.

diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index b7edc61..e9e29d9 100755 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -28,7 +28,7 @@ const de: Dict = { 'nav.projects': 'Projekte', 'nav.support': 'Support', 'nav.licenses': 'Lizenzen', - 'nav.agency': 'Bayis', + 'nav.agency': 'Reseller', 'nav.instances': 'Instanzen', 'nav.contracts': 'Verträge', 'nav.expenses': 'Ausgaben', @@ -86,7 +86,7 @@ const en: Dict = { 'nav.projects': 'Projects', 'nav.support': 'Support', 'nav.licenses': 'Licenses', - 'nav.agency': 'Resellers (Bayis)', + 'nav.agency': 'Resellers', 'nav.instances': 'Instances', 'nav.contracts': 'Contracts', 'nav.expenses': 'Expenses',
BayiReseller Rabatt Kunden Lizenzen