import { NextResponse } from 'next/server' import { db } from '@/lib/db' import { z } from 'zod' import { createDokployClientFromEnv } from '@/lib/dokploy' import { createHetznerDnsClientFromEnv } from '@/lib/hetzner-dns' export const dynamic = 'force-dynamic' export const maxDuration = 300 const patchSchema = z.object({ action: z.enum(['suspend', 'reactivate', 'extend', 'retry']), extendDays: z.number().int().min(1).max(90).optional(), }) // GET /api/aos/instances/[id] — instance detail with events + provisioning log export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params const instance = await db.instance.findUnique({ where: { id }, include: { company: { select: { id: true, name: true, email: true } }, license: { select: { id: true, key: true, product: true, plan: true, billingCycle: true, netPriceCents: true, status: true, cancelledAt: true } }, events: { orderBy: { createdAt: 'desc' }, take: 50 }, jobs: { orderBy: { createdAt: 'desc' }, take: 10 }, }, }) if (!instance) { return NextResponse.json({ error: 'instance not found' }, { status: 404 }) } return NextResponse.json({ instance }) } catch (err) { const msg = err instanceof Error ? err.message : 'internal server error' return NextResponse.json({ error: msg }, { status: 500 }) } } // PATCH /api/aos/instances/[id] — suspend / reactivate / extend demo / retry provisioning export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params const parsed = patchSchema.safeParse(await req.json()) if (!parsed.success) { return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } const { action, extendDays } = parsed.data const instance = await db.instance.findUnique({ where: { id } }) if (!instance) { return NextResponse.json({ error: 'instance not found' }, { status: 404 }) } const dokploy = createDokployClientFromEnv() if (action === 'suspend') { if (!['demo_active', 'active'].includes(instance.status)) { return NextResponse.json({ error: `cannot suspend instance in status ${instance.status}` }, { status: 400 }) } const prevStatus = instance.status // Stop the Dokploy application if (dokploy && instance.dokployAppId) { try { await dokploy.stopApplication(instance.dokployAppId) } catch (e) { // Log but don't fail — the instance record is the source of truth await db.instanceEvent.create({ data: { instanceId: id, type: 'suspend.dokploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }), }, }) } } await db.instance.update({ where: { id }, data: { status: 'grace', graceEndsAt: new Date(Date.now() + 7 * 86400000), // 7-day grace }, }) await db.instanceEvent.create({ data: { instanceId: id, type: 'suspend', step: 'done', result: 'ok', details: JSON.stringify({ prevStatus }) }, }) return NextResponse.json({ ok: true, status: 'grace' }) } if (action === 'reactivate') { if (instance.status !== 'grace') { return NextResponse.json({ error: 'can only reactivate from grace' }, { status: 400 }) } if (dokploy && instance.dokployAppId) { try { await dokploy.startApplication(instance.dokployAppId) } catch (e) { await db.instanceEvent.create({ data: { instanceId: id, type: 'reactivate.dokploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) }, }) } } // If the license is still in trial, keep demo_active; otherwise active const newStatus = instance.demoExpiresAt && new Date(instance.demoExpiresAt) > new Date() ? 'demo_active' : 'active' await db.instance.update({ where: { id }, data: { status: newStatus, graceEndsAt: null }, }) await db.instanceEvent.create({ data: { instanceId: id, type: 'reactivate', step: 'done', result: 'ok', details: JSON.stringify({ status: newStatus }) }, }) return NextResponse.json({ ok: true, status: newStatus }) } if (action === 'extend') { if (instance.status !== 'demo_active') { return NextResponse.json({ error: 'can only extend demo_active instances' }, { status: 400 }) } const days = extendDays ?? 7 const newExpiry = new Date(Date.now() + days * 86400000) await db.instance.update({ where: { id }, data: { demoExpiresAt: newExpiry }, }) await db.instanceEvent.create({ data: { instanceId: id, type: 'demo.extend', step: 'done', result: 'ok', details: JSON.stringify({ extendedBy: days, newExpiry }) }, }) return NextResponse.json({ ok: true, demoExpiresAt: newExpiry }) } if (action === 'retry') { // Reset to provisioning — the reconciler or manual call will re-provision if (instance.status !== 'failed') { return NextResponse.json({ error: 'can only retry failed instances' }, { status: 400 }) } await db.instance.update({ where: { id }, data: { status: 'requested', lastError: null, lastErrorAt: null }, }) await db.instanceEvent.create({ data: { instanceId: id, type: 'provision.retry', step: 'init', result: 'pending' }, }) return NextResponse.json({ ok: true, status: 'requested', message: 'Instance queued for re-provisioning' }) } return NextResponse.json({ error: 'unknown action' }, { status: 400 }) } catch (err) { const msg = err instanceof Error ? err.message : 'internal server error' return NextResponse.json({ error: msg }, { status: 500 }) } } // 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. export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params const instance = await db.instance.findUnique({ where: { 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({ data: { instanceId: id, type: 'delete.start', step: 'init', result: 'pending' }, }) const steps: string[] = [] const dokploy = createDokployClientFromEnv() const dns = createHetznerDnsClientFromEnv() // 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 { const result = await dokploy.teardownInstance({ dokployAppId: instance.dokployAppId ?? undefined, dokployDomainId: meta.dokployDomainId, }) steps.push(...result.steps) await db.instanceEvent.create({ 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' }) }, }) } } else { steps.push('dokploy.skipped (not configured)') } // 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) steps.push('dns.removed') await db.instanceEvent.create({ 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. 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: { 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 }) }, }) await db.auditLog.create({ data: { actor: 'admin:ui', action: 'instance.delete', entity: 'Instance', entityId: id, after: JSON.stringify({ subdomain: instance.subdomain, steps }), hash: 'sha256:' + Math.random().toString(36).slice(2), }, }) return NextResponse.json({ ok: true, status: 'deleted', steps, deletedAt: now }) } catch (err) { const msg = err instanceof Error ? err.message : 'internal server error' return NextResponse.json({ error: msg }, { status: 500 }) } }