4413045f-d05e-4d7a-a70d-81d6be253c85

This commit is contained in:
Z User committed 2026-10-28 16:37:26 +00:00
1 parent 762f3d7373
commit c6adc67479
18 files changed
+5263 -1

No files matched your search

+257
View File
@@ -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 })
}
}
+165
View File
@@ -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 })
}
}
+314
View File
@@ -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 })
}
}
+36 -1
View File
@@ -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) {