4413045f-d05e-4d7a-a70d-81d6be253c85
This commit is contained in:
1 parent
762f3d7373
commit
c6adc67479
18 files changed
+5263
-1
No files matched your search
@@ -0,0 +1,257 @@
|
||||
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 } : {}
|
||||
|
||||
// 1. Remove Dokploy domain + database + application (idempotent)
|
||||
if (dokploy) {
|
||||
try {
|
||||
const result = await dokploy.teardownInstance({
|
||||
dokployAppId: instance.dokployAppId ?? undefined,
|
||||
dokployDbId: instance.dokployDbId ?? undefined,
|
||||
dnsDomainId: 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) {
|
||||
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)
|
||||
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) {
|
||||
steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Mark deleted (keep the row for audit — GoBD)
|
||||
const now = new Date()
|
||||
await db.instance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'deleted',
|
||||
deletedAt: now,
|
||||
dokployAppId: null,
|
||||
dokployDbId: null,
|
||||
dnsRecordId: null,
|
||||
dokployDbPassword: null, // clear secrets
|
||||
adminLoginToken: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 4. 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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { createDokployClientFromEnv } from '@/lib/dokploy'
|
||||
import { createHetznerDnsClientFromEnv } from '@/lib/hetzner-dns'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
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.
|
||||
//
|
||||
// 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) {
|
||||
try {
|
||||
// Simple auth: shared secret in header (cron job includes it)
|
||||
const authHeader = req.headers.get('authorization')
|
||||
const expectedSecret = process.env.CRON_SECRET
|
||||
if (expectedSecret && authHeader !== `Bearer ${expectedSecret}`) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const results = {
|
||||
expiredDemos: 0,
|
||||
graceExpired: 0,
|
||||
deleted: 0,
|
||||
failed: 0,
|
||||
details: [] as string[],
|
||||
}
|
||||
|
||||
// 1. Move expired demos → grace (demoExpiresAt < now)
|
||||
const expiredDemos = await db.instance.findMany({
|
||||
where: {
|
||||
status: 'demo_active',
|
||||
demoExpiresAt: { lt: now },
|
||||
},
|
||||
})
|
||||
for (const inst of expiredDemos) {
|
||||
await db.instance.update({
|
||||
where: { id: inst.id },
|
||||
data: {
|
||||
status: 'grace',
|
||||
graceEndsAt: new Date(now.getTime() + 7 * 86400000), // 7-day grace
|
||||
},
|
||||
})
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: inst.id,
|
||||
type: 'demo.expired',
|
||||
step: 'lifecycle',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ demoExpiresAt: inst.demoExpiresAt, graceEndsAt: now.getTime() + 7 * 86400000 }),
|
||||
},
|
||||
})
|
||||
results.expiredDemos++
|
||||
results.details.push(`${inst.subdomain}: demo → grace`)
|
||||
}
|
||||
|
||||
// 2. Delete instances whose grace has elapsed (graceEndsAt < now)
|
||||
const graceExpired = await db.instance.findMany({
|
||||
where: {
|
||||
status: 'grace',
|
||||
graceEndsAt: { lt: now },
|
||||
},
|
||||
})
|
||||
results.graceExpired = graceExpired.length
|
||||
|
||||
const dokploy = createDokployClientFromEnv()
|
||||
const dns = createHetznerDnsClientFromEnv()
|
||||
|
||||
for (const inst of graceExpired) {
|
||||
try {
|
||||
await db.instance.update({ where: { id: inst.id }, data: { status: 'deleting' } })
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: inst.id, type: 'cleanup.delete.start', step: 'reconciler', result: 'pending' },
|
||||
})
|
||||
|
||||
const meta = inst.metadata ? JSON.parse(inst.metadata) as { dokployDomainId?: string } : {}
|
||||
const steps: string[] = []
|
||||
|
||||
// Teardown Dokploy resources
|
||||
if (dokploy) {
|
||||
try {
|
||||
const r = await dokploy.teardownInstance({
|
||||
dokployAppId: inst.dokployAppId ?? undefined,
|
||||
dokployDbId: inst.dokployDbId ?? undefined,
|
||||
dnsDomainId: meta.dokployDomainId,
|
||||
})
|
||||
steps.push(...r.steps)
|
||||
} catch (e) {
|
||||
steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove DNS record
|
||||
if (dns && inst.dnsRecordId && inst.dnsRecordId !== 'wildcard') {
|
||||
try {
|
||||
await dns.deleteRecord(inst.dnsRecordId)
|
||||
steps.push('dns.removed')
|
||||
} catch (e) {
|
||||
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 } },
|
||||
})
|
||||
}
|
||||
|
||||
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'})`)
|
||||
await db.instance.update({
|
||||
where: { id: inst.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
lastError: e instanceof Error ? e.message : 'cleanup failed',
|
||||
lastErrorAt: now,
|
||||
},
|
||||
})
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: inst.id, type: 'cleanup.delete.error', step: 'reconciler', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Alert on failed instances
|
||||
const failedInstances = await db.instance.count({ where: { status: 'failed' } })
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
runAt: now,
|
||||
...results,
|
||||
failedInstances,
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
createDokployClientFromEnv,
|
||||
type ProvisionInstanceInput,
|
||||
} from '@/lib/dokploy'
|
||||
import {
|
||||
createHetznerDnsClientFromEnv,
|
||||
generateSubdomain,
|
||||
isReservedSubdomain,
|
||||
} from '@/lib/hetzner-dns'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 300 // 5 min — provisioning can take a while
|
||||
|
||||
const PLATFORM_DOMAIN = process.env.PLATFORM_DOMAIN ?? 'dfmpanel.eu'
|
||||
const DEMO_DAYS = 14
|
||||
const GRACE_DAYS = 7
|
||||
|
||||
const createSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
licenseId: z.string().min(1),
|
||||
product: z.string().min(1).default('restov1'),
|
||||
plan: z.enum(['starter', 'pro', 'enterprise']).default('pro'),
|
||||
gitRepo: z.string().url().default('https://github.com/cetinmustafa83/restov1'),
|
||||
gitBranch: z.string().default('main'),
|
||||
// Extra env vars to inject into the instance (beyond the standard ones)
|
||||
envVars: z.record(z.string(), z.string()).optional(),
|
||||
// If true, start as a demo (14-day expiry). If false, start as active (paid).
|
||||
isDemo: z.boolean().default(true),
|
||||
})
|
||||
|
||||
// GET /api/aos/instances — list all instances (operator console)
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const instances = await db.instance.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
company: { select: { id: true, name: true, email: true } },
|
||||
license: { select: { id: true, key: true, product: true, plan: true } },
|
||||
_count: { select: { events: true, jobs: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const stats = {
|
||||
total: instances.length,
|
||||
provisioning: instances.filter((i) => i.status === 'provisioning').length,
|
||||
demoActive: instances.filter((i) => i.status === 'demo_active').length,
|
||||
active: instances.filter((i) => i.status === 'active').length,
|
||||
grace: instances.filter((i) => i.status === 'grace').length,
|
||||
deleting: instances.filter((i) => i.status === 'deleting').length,
|
||||
deleted: instances.filter((i) => i.status === 'deleted').length,
|
||||
failed: instances.filter((i) => i.status === 'failed').length,
|
||||
// Demos expiring in next 48h
|
||||
expiringSoon: instances.filter(
|
||||
(i) => i.demoExpiresAt && new Date(i.demoExpiresAt) < new Date(Date.now() + 48 * 3600000) && i.status === 'demo_active'
|
||||
).length,
|
||||
}
|
||||
|
||||
return NextResponse.json({ instances, stats })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/aos/instances — provision a new tenant instance
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = createSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { companyId, licenseId, product, plan, gitRepo, gitBranch, envVars, isDemo } = parsed.data
|
||||
|
||||
// Verify company + license exist and belong together
|
||||
const company = await db.company.findUnique({ where: { id: companyId } })
|
||||
if (!company) return NextResponse.json({ error: 'company not found' }, { status: 404 })
|
||||
|
||||
const license = await db.license.findUnique({ where: { id: licenseId } })
|
||||
if (!license) return NextResponse.json({ error: 'license not found' }, { status: 404 })
|
||||
if (license.companyId !== companyId) {
|
||||
return NextResponse.json({ error: 'license does not belong to this company' }, { status: 400 })
|
||||
}
|
||||
if (license.product !== product) {
|
||||
return NextResponse.json({ error: 'license product does not match instance product' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check that no live instance already exists for this license
|
||||
const existing = await db.instance.findFirst({
|
||||
where: { licenseId, status: { notIn: ['deleted'] } },
|
||||
})
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'license already has an active instance', instanceId: existing.id }, { status: 409 })
|
||||
}
|
||||
|
||||
// Generate unique subdomain (collision-checked + reserved list)
|
||||
let subdomain = generateSubdomain()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (isReservedSubdomain(subdomain)) {
|
||||
subdomain = generateSubdomain()
|
||||
continue
|
||||
}
|
||||
const collision = await db.instance.findUnique({ where: { subdomain } })
|
||||
if (!collision) break
|
||||
subdomain = generateSubdomain()
|
||||
}
|
||||
|
||||
const fullDomain = `${subdomain}.${PLATFORM_DOMAIN}`
|
||||
|
||||
// Create instance record (status = requested)
|
||||
const instance = await db.instance.create({
|
||||
data: {
|
||||
licenseId,
|
||||
companyId,
|
||||
product,
|
||||
status: 'requested',
|
||||
plan,
|
||||
subdomain,
|
||||
demoExpiresAt: isDemo ? new Date(Date.now() + DEMO_DAYS * 86400000) : null,
|
||||
},
|
||||
})
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.start',
|
||||
step: 'init',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ subdomain, product, plan, isDemo, gitRepo }),
|
||||
},
|
||||
})
|
||||
|
||||
// --- Provision via Dokploy ---
|
||||
try {
|
||||
await db.instance.update({ where: { id: instance.id }, data: { status: 'provisioning' } })
|
||||
|
||||
const dokploy = createDokployClientFromEnv()
|
||||
if (!dokploy) {
|
||||
throw new Error('Dokploy client not configured (DOKPLOY_URL / DOKPLOY_API_KEY missing)')
|
||||
}
|
||||
|
||||
// Instance env vars: license key + AOS connection + product-specific
|
||||
const instanceEnv: Record<string, string> = {
|
||||
AOS_LICENSE_SERVER_URL: process.env.AOS_PUBLIC_URL ?? `https://${PLATFORM_DOMAIN}`,
|
||||
AOS_PRODUCT: product,
|
||||
AOS_LICENSE_KEY: license.key,
|
||||
AOS_LICENSE_DOMAIN: fullDomain,
|
||||
AOS_OFFLINE_GRACE_HOURS: '72',
|
||||
NODE_ENV: 'production',
|
||||
...envVars,
|
||||
}
|
||||
|
||||
const provisionInput: ProvisionInstanceInput = {
|
||||
name: `${product}-${subdomain}`,
|
||||
subdomain: fullDomain,
|
||||
gitRepo,
|
||||
gitBranch,
|
||||
envVars: instanceEnv,
|
||||
databaseName: `${product}_${subdomain}`.replace(/-/g, '_').slice(0, 40),
|
||||
cpuLimit: plan === 'enterprise' ? '2' : plan === 'pro' ? '1' : '0.5',
|
||||
memoryLimit: plan === 'enterprise' ? '2048m' : plan === 'pro' ? '1024m' : '512m',
|
||||
}
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.dokploy.start',
|
||||
step: 'dokploy',
|
||||
result: 'pending',
|
||||
details: JSON.stringify({ gitRepo, gitBranch }),
|
||||
},
|
||||
})
|
||||
|
||||
const result = await dokploy.provisionInstance(provisionInput)
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.dokploy.created',
|
||||
step: 'dokploy',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({
|
||||
appId: result.app.applicationId,
|
||||
dbId: result.database?.databaseId,
|
||||
domainId: result.domain.domainId,
|
||||
url: result.url,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
// --- DNS A record ---
|
||||
let dnsRecordId: string | null = null
|
||||
const dns = createHetznerDnsClientFromEnv()
|
||||
if (dns) {
|
||||
try {
|
||||
const record = await dns.createARecord(subdomain)
|
||||
dnsRecordId = record.recordId
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.dns.created',
|
||||
step: 'dns',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ recordId: record.recordId, subdomain }),
|
||||
},
|
||||
})
|
||||
} catch (dnsErr) {
|
||||
// DNS is non-fatal if using wildcard; log but continue
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.dns.skipped',
|
||||
step: 'dns',
|
||||
result: 'error',
|
||||
details: JSON.stringify({ error: dnsErr instanceof Error ? dnsErr.message : 'unknown' }),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update instance with resource IDs + activate ---
|
||||
const now = new Date()
|
||||
await db.instance.update({
|
||||
where: { id: instance.id },
|
||||
data: {
|
||||
status: isDemo ? 'demo_active' : 'active',
|
||||
dokployAppId: result.app.applicationId,
|
||||
dokployDbId: result.database?.databaseId ?? null,
|
||||
dokployDbName: result.database?.database ?? null,
|
||||
dokployDbUser: result.database?.username ?? null,
|
||||
dokployDbPassword: result.database?.password ?? null,
|
||||
dnsRecordId,
|
||||
metadata: JSON.stringify({
|
||||
gitRepo,
|
||||
gitBranch,
|
||||
dokployDomainId: result.domain.domainId,
|
||||
url: result.url,
|
||||
provisionedAt: now.toISOString(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
// --- Link instance to license ---
|
||||
await db.license.update({
|
||||
where: { id: licenseId },
|
||||
data: { instance: { connect: { id: instance.id } } },
|
||||
})
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.ok',
|
||||
step: 'done',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ url: result.url, status: isDemo ? 'demo_active' : 'active' }),
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'instance.provision',
|
||||
entity: 'Instance',
|
||||
entityId: instance.id,
|
||||
after: JSON.stringify({
|
||||
subdomain,
|
||||
url: result.url,
|
||||
product,
|
||||
company: company.name,
|
||||
isDemo,
|
||||
}),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
instanceId: instance.id,
|
||||
subdomain,
|
||||
url: result.url,
|
||||
status: isDemo ? 'demo_active' : 'active',
|
||||
demoExpiresAt: instance.demoExpiresAt,
|
||||
}, { status: 201 })
|
||||
} catch (provErr) {
|
||||
// Provisioning failed — mark as failed, keep the record for retry
|
||||
await db.instance.update({
|
||||
where: { id: instance.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
lastError: provErr instanceof Error ? provErr.message : 'provisioning failed',
|
||||
lastErrorAt: new Date(),
|
||||
},
|
||||
})
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.error',
|
||||
step: 'dokploy',
|
||||
result: 'error',
|
||||
details: JSON.stringify({ error: provErr instanceof Error ? provErr.message : 'unknown' }),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({
|
||||
error: 'provisioning failed',
|
||||
details: provErr instanceof Error ? provErr.message : 'unknown',
|
||||
instanceId: instance.id,
|
||||
}, { status: 500 })
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,40 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
},
|
||||
})
|
||||
|
||||
// --- Instance lifecycle hook (PRD §5: trial cancel → instance deletion) ---
|
||||
// If cancelling during the 14-day trial (immediate), schedule the tenant
|
||||
// instance for deletion. The actual Dokploy + DNS teardown happens via
|
||||
// the instance DELETE endpoint (idempotent job), not here directly —
|
||||
// we fire-and-forget so the cancel response is fast.
|
||||
let instanceDeleted = false
|
||||
if ((immediate || inTrial) && license.id) {
|
||||
const inst = await db.instance.findFirst({
|
||||
where: { licenseId: license.id, status: { notIn: ['deleted', 'deleting'] } },
|
||||
})
|
||||
if (inst) {
|
||||
// Mark instance as grace → will be picked up by the deletion job.
|
||||
// For immediate trial cancel, set a short grace (1 hour) so the client
|
||||
// can still access data briefly if they change their mind, then it's gone.
|
||||
await db.instance.update({
|
||||
where: { id: inst.id },
|
||||
data: {
|
||||
status: 'grace',
|
||||
graceEndsAt: new Date(Date.now() + 3600000), // 1 hour
|
||||
},
|
||||
})
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: inst.id,
|
||||
type: 'license.cancel.trial',
|
||||
step: 'hook',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ licenseId: license.id, graceEndsIn: '1h' }),
|
||||
},
|
||||
})
|
||||
instanceDeleted = true
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
cancelledAt: now,
|
||||
@@ -109,8 +143,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
inTrial: !!inTrial,
|
||||
immediate: immediate || inTrial,
|
||||
cancelledModules,
|
||||
instanceScheduled: instanceDeleted,
|
||||
message: inTrial
|
||||
? 'Probezeit-Kündigung — sofort wirksam, keine Abbuchung.'
|
||||
? 'Probezeit-Kündigung — sofort wirksam, keine Abbuchung. Instanz wird gelöscht.'
|
||||
: `Kündigung eingegangen. Wirksam zum ${effectiveAt.toLocaleDateString('de-DE')} (3-Monate-Frist).`,
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
Reference in new issue
Block a user