0bb384e7-e18c-49c5-b7ef-fe73701ab6ab
This commit is contained in:
1 parent
c4d338ee48
commit
8c34778db3
8 files changed
+97
-28
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,21 +93,41 @@ 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
|
||||
// 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: {
|
||||
@@ -132,6 +154,7 @@ export async function POST(req: Request) {
|
||||
})
|
||||
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'})`)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -28,7 +28,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
||||
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' },
|
||||
|
||||
@@ -135,8 +135,8 @@ export function AgencyView() {
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Bayis"
|
||||
description="Reseller-Verwaltung: Bayi-Bewerbungen prüfen, Rabatt-Sätze festlegen (0–20 %) und genehmigte Bayis mit ihren Kunden einsehen."
|
||||
title="Reseller"
|
||||
description="Reseller-Verwaltung: Bewerbungen prüfen, Rabatt-Sätze festlegen (0–20 %) und genehmigte Reseller mit ihren Kunden einsehen."
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -148,7 +148,7 @@ export function AgencyView() {
|
||||
hint="wartet auf Freigabe"
|
||||
/>
|
||||
<StatCard
|
||||
label="Genehmigte Bayis"
|
||||
label="Genehmigte Reseller"
|
||||
value={stats.approved}
|
||||
icon={Users}
|
||||
hint="aktiv im System"
|
||||
@@ -163,7 +163,7 @@ export function AgencyView() {
|
||||
label="Ø Rabatt"
|
||||
value={`${avgDiscount}%`}
|
||||
icon={Percent}
|
||||
hint={agencies.length ? `über ${agencies.length} Bayis` : '—'}
|
||||
hint={agencies.length ? `über ${agencies.length} Reseller` : '—'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -215,7 +215,7 @@ function PendingApplicationsSection({
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold tracking-tight">
|
||||
Bewerbende Bayis
|
||||
Bewerbende Reseller
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{applications.length} offen
|
||||
</span>
|
||||
@@ -230,7 +230,7 @@ function PendingApplicationsSection({
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Keine bewerbenden Anfragen</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
Neue Bayi-Bewerbungen erscheinen hier.
|
||||
Neue Reseller-Bewerbungen erscheinen hier.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,11 +393,11 @@ function ApproveDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="size-4" />
|
||||
Bayi-Bewerbung genehmigen
|
||||
Reseller-Bewerbung genehmigen
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -524,7 +524,7 @@ function RejectDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<XCircle className="size-4" />
|
||||
Bayi-Bewerbung ablehnen
|
||||
Reseller-Bewerbung ablehnen
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{application.company.name}</span>
|
||||
@@ -577,7 +577,7 @@ function ApprovedAgenciesSection({ agencies }: { agencies: ApprovedAgency[] }) {
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold tracking-tight">
|
||||
Genehmigte Bayis
|
||||
Genehmigte Reseller
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{agencies.length} aktiv
|
||||
</span>
|
||||
@@ -590,23 +590,23 @@ function ApprovedAgenciesSection({ agencies }: { agencies: ApprovedAgency[] }) {
|
||||
<Users className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<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">
|
||||
Genehmigte Bayi-Bewerbungen erscheinen hier mit ihren Kunden und Lizenzen.
|
||||
Genehmigte Reseller-Bewerbungen erscheinen hier mit ihren Kunden und Lizenzen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Bayis ({agencies.length})</CardTitle>
|
||||
<CardTitle className="text-sm">Reseller ({agencies.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<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">
|
||||
<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 text-right">Kunden</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Lizenzen</th>
|
||||
@@ -692,7 +692,7 @@ function ClientsSheet({
|
||||
{agency.name}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Bayi-Kundenverwaltung — {agency._count.agencyClients} Kunde(n),{' '}
|
||||
Reseller-Kundenverwaltung — {agency._count.agencyClients} Kunde(n),{' '}
|
||||
{agency._count.licenses} Lizenz(en).
|
||||
</SheetDescription>
|
||||
</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]">
|
||||
<Mail className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
+2
-2
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user