import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getSessionUser, canMutate } from "@/lib/session"; import { getTenant, patchTenantSpec } from "@/lib/k8s"; import { canUserSeeTenant } from "@/lib/visibility"; import { safeError } from "@/lib/errors"; const patchSchema = z.object({ suspend: z.boolean(), }); /** * PATCH /api/tenants/[name]/suspend * * Customer-side "Cancel subscription" / "Resume" toggle (Bug 31). * * Sets `spec.suspend` on the PiecedTenant CR. The operator interprets * this flag as "stop reconciling this tenant" — workloads, packages, * and channel-user changes are no longer applied. Existing data is * preserved (namespace, ConfigMaps, OpenBao secrets, CNPG database, * billing records). Resuming sets the flag back to false and the * operator picks up reconciliation on the next loop. * * Authorization * ------------- * - Customer-side: only an `owner` of the tenant's org may call this. * `canMutate` is the right gate (mirrors the rest of the customer * API surface). User-role members cannot cancel a subscription. * - Platform staff: allowed via `canMutate`'s isPlatform branch, but * in practice they should use admin tooling for this — the action * is exposed here for the customer's benefit. * * Visibility check is via `canUserSeeTenant` — same notFound() trick * as the detail page, so we don't leak existence of tenants the * caller can't see. * * Note on workload teardown * ------------------------- * As of this writing, the operator's `suspend` handling is "skip * reconciliation and set status.phase to Suspended". The underlying * StatefulSet keeps running until next reconciliation, which won't * happen while suspended. Group D will add scale-to-zero so cancelled * subscriptions actually stop incurring compute. Until then, an * operator following up with a `kubectl scale` is the workaround. * Customer data is preserved either way. */ export async function PATCH( 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 }); } // Identical pattern to the detail page — don't leak existence. if (!(await canUserSeeTenant(user, tenant))) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } const body = await req.json().catch(() => null); const parsed = patchSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Invalid input", details: parsed.error.flatten() }, { status: 400 } ); } const { suspend } = parsed.data; // No-op early exit. Avoids a needless K8s patch + status churn when // the user double-clicks the button or the UI is briefly out of sync. if (Boolean(tenant.spec.suspend) === suspend) { return NextResponse.json( { message: "No change.", suspend }, { status: 200 } ); } try { await patchTenantSpec(name, { suspend }); return NextResponse.json( { message: suspend ? "Subscription cancelled. Your data is preserved." : "Subscription resumed.", suspend, }, { status: 200 } ); } catch (e: any) { console.error("Suspend toggle failed:", e); return NextResponse.json( { error: safeError(e, "Failed to update subscription") }, { status: e.statusCode || 500 } ); } }