0bb384e7-e18c-49c5-b7ef-fe73701ab6ab

This commit is contained in:
Z User committed 2026-10-28 17:00:06 +00:00
1 parent c4d338ee48
commit 8c34778db3
8 files changed
+121 -52

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
}) })
// TODO (production): send email to company.email with subject // 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. // containing the discount and a link to the agency portal.
return NextResponse.json({ return NextResponse.json({
+49 -3
View File
@@ -176,6 +176,9 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
// Parse metadata for Dokploy domain ID // Parse metadata for Dokploy domain ID
const meta = instance.metadata ? JSON.parse(instance.metadata) as { dokployDomainId?: string; url?: string } : {} 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) // 1. Remove Dokploy domain + database + application (idempotent)
if (dokploy) { if (dokploy) {
try { 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 }) }, data: { instanceId: id, type: 'delete.dokploy.done', step: 'dokploy', result: 'ok', details: JSON.stringify({ steps: result.steps }) },
}) })
} catch (e) { } catch (e) {
dokployFailed = true
steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`) steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`)
await db.instanceEvent.create({ 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' }) }, 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)') 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') { if (dns && instance.dnsRecordId && instance.dnsRecordId !== 'wildcard') {
try { try {
await dns.deleteRecord(instance.dnsRecordId) 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 }) }, data: { instanceId: id, type: 'delete.dns.removed', step: 'dns', result: 'ok', details: JSON.stringify({ recordId: instance.dnsRecordId }) },
}) })
} catch (e) { } catch (e) {
dnsFailed = true
steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`) 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() 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({ await db.instance.update({
where: { id }, where: { id },
data: { 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) { if (instance.licenseId) {
await db.license.update({ await db.license.update({
where: { id: instance.licenseId }, where: { id: instance.licenseId },
+49 -26
View File
@@ -80,6 +80,8 @@ export async function POST(req: Request) {
const meta = inst.metadata ? JSON.parse(inst.metadata) as { dokployDomainId?: string } : {} const meta = inst.metadata ? JSON.parse(inst.metadata) as { dokployDomainId?: string } : {}
const steps: string[] = [] const steps: string[] = []
let dokployFailed = false
let dnsFailed = false
// Teardown Dokploy resources // Teardown Dokploy resources
if (dokploy) { if (dokploy) {
@@ -91,47 +93,68 @@ export async function POST(req: Request) {
}) })
steps.push(...r.steps) steps.push(...r.steps)
} catch (e) { } catch (e) {
dokployFailed = true
steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`) 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') { if (dns && inst.dnsRecordId && inst.dnsRecordId !== 'wildcard') {
try { try {
await dns.deleteRecord(inst.dnsRecordId) await dns.deleteRecord(inst.dnsRecordId)
steps.push('dns.removed') steps.push('dns.removed')
} catch (e) { } catch (e) {
dnsFailed = true
steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`) steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`)
} }
} }
// Mark deleted // Decide final state — both must succeed for full deletion
await db.instance.update({ if (dokployFailed || dnsFailed) {
where: { id: inst.id }, // Partial failure → keep IDs for next reconciler run
data: { await db.instance.update({
status: 'deleted', where: { id: inst.id },
deletedAt: now, data: {
dokployAppId: null, status: 'failed',
dokployDbId: null, lastError: `cleanup partial: ${steps.join(', ')}`,
dokployDbPassword: null, lastErrorAt: now,
dnsRecordId: null, // Keep dokployAppId + dnsRecordId for retry
adminLoginToken: null, },
}, })
}) await db.instanceEvent.create({
data: { instanceId: inst.id, type: 'cleanup.delete.partial', step: 'reconciler', result: 'error', details: JSON.stringify({ steps, dokployFailed, dnsFailed }) },
// Unlink license })
if (inst.licenseId) { results.failed++
await db.license.update({ results.details.push(`${inst.subdomain}: PARTIAL (${steps.join(', ')}) — retry next run`)
where: { id: inst.licenseId }, } else {
data: { instance: { disconnect: true } }, // 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({ // Unlink license
data: { instanceId: inst.id, type: 'cleanup.delete.ok', step: 'reconciler', result: 'ok', details: JSON.stringify({ steps }) }, if (inst.licenseId) {
}) await db.license.update({
results.deleted++ where: { id: inst.licenseId },
results.details.push(`${inst.subdomain}: deleted (${steps.join(', ')})`) 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) { } catch (e) {
results.failed++ results.failed++
results.details.push(`${inst.subdomain}: FAILED (${e instanceof Error ? e.message : 'unknown'})`) results.details.push(`${inst.subdomain}: FAILED (${e instanceof Error ? e.message : 'unknown'})`)
+1 -1
View File
@@ -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: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' },
{ id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' }, { id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' },
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' }, { 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: 'instances', label: 'Instanzen', icon: Server, hint: 'Tenant-Instanzen & Provisioning' },
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' }, { id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' }, { id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
+1 -1
View File
@@ -68,7 +68,7 @@ const NAV: {
group: 'nav.operations', group: 'nav.operations',
items: [ items: [
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' }, { 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: 'instances', label: 'Instanzen', icon: Server, permission: 'admin' },
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' }, { id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' }, { id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
+1 -1
View File
@@ -28,7 +28,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
timesheet: { title: 'Zeiterfassung', crumb: 'Lieferung / Zeiterfassung' }, timesheet: { title: 'Zeiterfassung', crumb: 'Lieferung / Zeiterfassung' },
support: { title: 'Support', crumb: 'Lieferung / Tickets' }, support: { title: 'Support', crumb: 'Lieferung / Tickets' },
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' }, licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
agency: { title: 'Bayis', crumb: 'Betrieb / Bayis' }, agency: { title: 'Reseller', crumb: 'Betrieb / Reseller' },
instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' }, instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' },
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' }, contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' }, expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
+17 -17
View File
@@ -135,8 +135,8 @@ export function AgencyView() {
return ( return (
<div className="space-y-6 p-4 md:p-6"> <div className="space-y-6 p-4 md:p-6">
<PageHeader <PageHeader
title="Bayis" title="Reseller"
description="Reseller-Verwaltung: Bayi-Bewerbungen prüfen, Rabatt-Sätze festlegen (0–20 %) und genehmigte Bayis mit ihren Kunden einsehen." description="Reseller-Verwaltung: Bewerbungen prüfen, Rabatt-Sätze festlegen (0–20 %) und genehmigte Reseller mit ihren Kunden einsehen."
/> />
{/* Stats */} {/* Stats */}
@@ -148,7 +148,7 @@ export function AgencyView() {
hint="wartet auf Freigabe" hint="wartet auf Freigabe"
/> />
<StatCard <StatCard
label="Genehmigte Bayis" label="Genehmigte Reseller"
value={stats.approved} value={stats.approved}
icon={Users} icon={Users}
hint="aktiv im System" hint="aktiv im System"
@@ -163,7 +163,7 @@ export function AgencyView() {
label="Ø Rabatt" label="Ø Rabatt"
value={`${avgDiscount}%`} value={`${avgDiscount}%`}
icon={Percent} icon={Percent}
hint={agencies.length ? `über ${agencies.length} Bayis` : '—'} hint={agencies.length ? `über ${agencies.length} Reseller` : '—'}
/> />
</div> </div>
@@ -215,7 +215,7 @@ function PendingApplicationsSection({
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-sm font-semibold tracking-tight"> <h2 className="text-sm font-semibold tracking-tight">
Bewerbende Bayis Bewerbende Reseller
<span className="ml-2 text-xs font-normal text-muted-foreground"> <span className="ml-2 text-xs font-normal text-muted-foreground">
{applications.length} offen {applications.length} offen
</span> </span>
@@ -230,7 +230,7 @@ function PendingApplicationsSection({
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium">Keine bewerbenden Anfragen</p> <p className="text-sm font-medium">Keine bewerbenden Anfragen</p>
<p className="max-w-sm text-xs text-muted-foreground"> <p className="max-w-sm text-xs text-muted-foreground">
Neue Bayi-Bewerbungen erscheinen hier. Neue Reseller-Bewerbungen erscheinen hier.
</p> </p>
</div> </div>
</div> </div>
@@ -393,11 +393,11 @@ function ApproveDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<CheckCircle2 className="size-4" /> <CheckCircle2 className="size-4" />
Bayi-Bewerbung genehmigen Reseller-Bewerbung genehmigen
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<span className="font-medium text-foreground">{application.company.name}</span> wird <span className="font-medium text-foreground">{application.company.name}</span> 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. angewendet.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -524,7 +524,7 @@ function RejectDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<XCircle className="size-4" /> <XCircle className="size-4" />
Bayi-Bewerbung ablehnen Reseller-Bewerbung ablehnen
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<span className="font-medium text-foreground">{application.company.name}</span> <span className="font-medium text-foreground">{application.company.name}</span>
@@ -577,7 +577,7 @@ function ApprovedAgenciesSection({ agencies }: { agencies: ApprovedAgency[] }) {
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-sm font-semibold tracking-tight"> <h2 className="text-sm font-semibold tracking-tight">
Genehmigte Bayis Genehmigte Reseller
<span className="ml-2 text-xs font-normal text-muted-foreground"> <span className="ml-2 text-xs font-normal text-muted-foreground">
{agencies.length} aktiv {agencies.length} aktiv
</span> </span>
@@ -590,23 +590,23 @@ function ApprovedAgenciesSection({ agencies }: { agencies: ApprovedAgency[] }) {
<Users className="size-5 text-muted-foreground" /> <Users className="size-5 text-muted-foreground" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium">Noch keine Bayis genehmigt</p> <p className="text-sm font-medium">Noch keine Reseller genehmigt</p>
<p className="max-w-sm text-xs text-muted-foreground"> <p className="max-w-sm text-xs text-muted-foreground">
Genehmigte Bayi-Bewerbungen erscheinen hier mit ihren Kunden und Lizenzen. Genehmigte Reseller-Bewerbungen erscheinen hier mit ihren Kunden und Lizenzen.
</p> </p>
</div> </div>
</div> </div>
) : ( ) : (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm">Bayis ({agencies.length})</CardTitle> <CardTitle className="text-sm">Reseller ({agencies.length})</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground"> <thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr> <tr>
<th className="px-4 py-2 font-medium">Bayi</th> <th className="px-4 py-2 font-medium">Reseller</th>
<th className="px-4 py-2 font-medium">Rabatt</th> <th className="px-4 py-2 font-medium">Rabatt</th>
<th className="px-4 py-2 font-medium text-right">Kunden</th> <th className="px-4 py-2 font-medium text-right">Kunden</th>
<th className="px-4 py-2 font-medium text-right">Lizenzen</th> <th className="px-4 py-2 font-medium text-right">Lizenzen</th>
@@ -692,7 +692,7 @@ function ClientsSheet({
{agency.name} {agency.name}
</SheetTitle> </SheetTitle>
<SheetDescription> <SheetDescription>
Bayi-Kundenverwaltung — {agency._count.agencyClients} Kunde(n),{' '} Reseller-Kundenverwaltung — {agency._count.agencyClients} Kunde(n),{' '}
{agency._count.licenses} Lizenz(en). {agency._count.licenses} Lizenz(en).
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
@@ -702,8 +702,8 @@ function ClientsSheet({
<div className="mb-3 flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3 text-[11px]"> <div className="mb-3 flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3 text-[11px]">
<Mail className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" /> <Mail className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Bayi kann eigene Kunden anlegen und lizenzieren. Tickets von dessen Kunden werden Reseller kann eigene Kunden anlegen und lizenzieren. Tickets von dessen Kunden werden
an den Bayi geroutet, nicht an uns. an den Reseller geroutet, nicht an uns.
</p> </p>
</div> </div>
+2 -2
View File
@@ -28,7 +28,7 @@ const de: Dict = {
'nav.projects': 'Projekte', 'nav.projects': 'Projekte',
'nav.support': 'Support', 'nav.support': 'Support',
'nav.licenses': 'Lizenzen', 'nav.licenses': 'Lizenzen',
'nav.agency': 'Bayis', 'nav.agency': 'Reseller',
'nav.instances': 'Instanzen', 'nav.instances': 'Instanzen',
'nav.contracts': 'Verträge', 'nav.contracts': 'Verträge',
'nav.expenses': 'Ausgaben', 'nav.expenses': 'Ausgaben',
@@ -86,7 +86,7 @@ const en: Dict = {
'nav.projects': 'Projects', 'nav.projects': 'Projects',
'nav.support': 'Support', 'nav.support': 'Support',
'nav.licenses': 'Licenses', 'nav.licenses': 'Licenses',
'nav.agency': 'Resellers (Bayis)', 'nav.agency': 'Resellers',
'nav.instances': 'Instances', 'nav.instances': 'Instances',
'nav.contracts': 'Contracts', 'nav.contracts': 'Contracts',
'nav.expenses': 'Expenses', 'nav.expenses': 'Expenses',