import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getSessionUser, canMutate } from "@/lib/session"; import { getTenant, setTenantAnnotation } from "@/lib/k8s"; import { canUserSeeTenant } from "@/lib/visibility"; import { createResumeRequest, getPendingResumeRequestForTenant, getTenantRequestByTenantName, } from "@/lib/db"; import { sendResumeRequestAdminNotificationEmail } from "@/lib/email"; import { safeError } from "@/lib/errors"; /** * Body schema. Both fields optional; the customer can submit a * resume request with no body at all (the JS client sends `{}`), * or with a note explaining their reactivation rationale. * * Length cap mirrors `billing_notes` (2000 chars) — same lower * bound for "free-form text we don't want abused". */ const bodySchema = z.object({ customerNotes: z .string() .trim() .max(2000) .optional() .transform((v) => (v && v.length > 0 ? v : undefined)), }); /** * POST /api/tenants/[name]/resume-request * * Owner-initiated request to reactivate a suspended tenant (Bug 37a). * Creates a pending tenant_request of type 'resume' for admin review, * and stamps the PiecedTenant CR with an annotation that pauses the * operator's 60-day deletion timer. * * Why a request flow at all * ------------------------- * Customers can self-serve cancel; resume requires admin oversight. * Reactivation may involve re-validating billing, confirming the * customer still wants to be active, or other manual steps. The * request flow gives admins a queue to review, with the same approve/ * reject UX as initial provision requests. * * Authorization * ------------- * Owners and platform admins. Platform admins shouldn't normally use * this endpoint — they have direct PATCH suspend access — but it's * permissive in case admin tooling pivots. * * Validation * ---------- * - Tenant must exist and be visible to the caller. * - Tenant must be currently suspended. Resuming an active tenant * is meaningless. * - At most one pending resume request per tenant. Enforced by the * DB's partial unique index, but we also check explicitly here to * return a friendly 409 instead of a 500. * * Side effects on success * ----------------------- * - INSERT into tenant_requests (request_type='resume', status='pending') * - PATCH annotation `pieced.ch/resume-request-pending=` on * the CR. This is the operator's signal to pause its 60-day deletion * timer until the request transitions to terminal. * * The annotation set is best-effort: if the K8s PATCH fails after the * DB insert, the row exists without the annotation. The customer * sees the request as pending; admin can still approve. The only * functional consequence is the 60-day timer doesn't pause until the * next request transition, which is fine in practice (admin response * times are dramatically shorter than 60 days). */ export async function POST( req: NextRequest, { params }: { params: Promise<{ name: string }> } ) { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } if (!canMutate(user)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const { name } = await params; const tenant = await getTenant(name); if (!tenant) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } if (!(await canUserSeeTenant(user, tenant))) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } if (!tenant.spec.suspend) { return NextResponse.json( { error: "Tenant is not suspended; nothing to resume." }, { status: 409 } ); } // Body is optional — the customer can submit a resume request // with no payload at all, or attach a free-form note. const rawBody = await req.json().catch(() => ({})); const parsed = bodySchema.safeParse(rawBody ?? {}); if (!parsed.success) { return NextResponse.json( { error: "Invalid input", details: parsed.error.flatten() }, { status: 400 } ); } const customerNotes = parsed.data.customerNotes; // Already a pending request? Don't duplicate. const existing = await getPendingResumeRequestForTenant(name); if (existing) { return NextResponse.json( { error: "A resume request for this tenant is already pending.", request: { id: existing.id, createdAt: existing.createdAt }, }, { status: 409 } ); } // Pull traceability fields (companyName, agentName) from the original // provision request. The schema marks these NOT NULL, so we have to // populate them; copying from the provision row keeps the resume // row navigable in the admin UI without making up values. const provision = await getTenantRequestByTenantName(name); try { const resumeRequest = await createResumeRequest({ tenantName: name, zitadelOrgId: tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? user.orgId, zitadelUserId: user.id, contactName: user.name, contactEmail: user.email, companyName: provision?.companyName ?? tenant.spec.displayName ?? name, agentName: provision?.agentName ?? "Assistant", customerNotes, }); // Stamp the annotation so the operator pauses its TTL. If this // fails the request still exists; surface the error so admin // tooling can re-stamp if needed, but don't roll back. try { await setTenantAnnotation( name, "pieced.ch/resume-request-pending", resumeRequest.id ); } catch (e) { console.warn( "resume request created but annotation could not be set; operator's 60-day timer will not pause until next reconcile triggered by request transition", e ); } // Notify admin distribution. Fire-and-log: failure to email // doesn't roll back the request creation. The customer's note // (if any) is included so admin can triage from the email // without opening the queue. sendResumeRequestAdminNotificationEmail({ tenantName: name, companyName: resumeRequest.companyName, contactName: resumeRequest.contactName, contactEmail: resumeRequest.contactEmail, customerNotes, }).catch((e) => console.error("resume admin notification email failed:", e) ); return NextResponse.json( { message: "Resume request submitted. An admin will review shortly.", request: { id: resumeRequest.id, status: resumeRequest.status }, }, { status: 201 } ); } catch (e: any) { // Unique violation (a pending row already exists for this tenant) // is friendly-handled above; this catches everything else. if (e.code === "23505") { return NextResponse.json( { error: "A resume request for this tenant is already pending." }, { status: 409 } ); } console.error("Resume request creation failed:", e); return NextResponse.json( { error: safeError(e, "Failed to submit resume request") }, { status: 500 } ); } }