import { NextRequest, NextResponse } from "next/server"; import { getSessionUser, canMutate } from "@/lib/session"; import { getInvoiceById, getTenantRequestById, updateTenantRequestStatus, updateTenantRequestEditableFields, } from "@/lib/db"; import { encryptSecrets } from "@/lib/crypto"; import { setTenantAnnotation } from "@/lib/k8s"; import { onboardingSchema } from "@/lib/validation"; import { safeError } from "@/lib/errors"; import { refundInvoice, RefundNotAllowedError } from "@/lib/billing"; import type { SessionUser, TenantRequest } from "@/types"; /** * Customer-side controls for a single tenant_request row. * * - DELETE /api/onboarding/[id] → cancel a still-pending request * - PATCH /api/onboarding/[id] → edit fields of a still-pending * request (Bug 6) * * Both endpoints share the same authorization check: the caller must * be a customer owner (or platform staff) of the request's org. We * also enforce status === 'pending' on the row — once an admin has * acted on it, the customer can no longer mutate it from the portal. * * Reading these is via the existing GET /api/onboarding?id=... handler. */ async function loadAuthorized( id: string ): Promise< | { error: NextResponse } | { req: TenantRequest; user: SessionUser } > { const user = await getSessionUser(); if (!user) { return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), }; } if (!canMutate(user)) { return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }), }; } const tr = await getTenantRequestById(id); if (!tr) { return { error: NextResponse.json({ error: "Not found" }, { status: 404 }), }; } // Customers may only read their own org's requests; platform users // may read any. Same scope as `GET /api/onboarding?id=...`. if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) { return { error: NextResponse.json({ error: "Not found" }, { status: 404 }), }; } return { req: tr, user }; } /** * DELETE /api/onboarding/[id] * * Customer cancels a still-pending request. Status flips to 'cancelled'; * the row is preserved for audit. The customer can dismiss the * cancelled card afterwards (Bug 13 reuse — same dismissal mechanism). * * Once admin has approved/provisioned/rejected, this endpoint refuses * (409). Cancelling a tenant that's already running goes through the * subscription-suspend flow on the tenant detail page, not here. */ export async function DELETE( _req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const loaded = await loadAuthorized(id); if ("error" in loaded) return loaded.error; const tr = loaded.req!; if (tr.status !== "pending") { return NextResponse.json( { error: "Only pending requests can be cancelled. Approved or provisioning instances must be managed from the tenant page.", code: "not_pending", currentStatus: tr.status, }, { status: 409 } ); } try { await updateTenantRequestStatus(id, "cancelled"); // Phase 9b: a 'pending' provision request has already had its // setup fee charged (the order-time Checkout completed before // the webhook flipped it to 'pending'). Cancelling it must // refund that payment, exactly as an admin rejection does. // Resume requests never carry a setup_invoice_id, so this only // fires for provision orders. Best-effort: a refund failure is // logged + surfaced but doesn't block the cancellation (admin // can refund manually from the invoice page). let refund: { attempted: boolean; succeeded: boolean; error?: string } = { attempted: false, succeeded: false, }; if (tr.requestType === "provision" && tr.setupInvoiceId) { refund.attempted = true; try { const inv = await getInvoiceById(tr.setupInvoiceId); if (!inv) { throw new Error(`Linked setup invoice ${tr.setupInvoiceId} not found`); } const remaining = Math.round((inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100) / 100; if (remaining <= 0) { refund.succeeded = true; // nothing left to refund } else { await refundInvoice({ invoiceId: tr.setupInvoiceId, amountChf: remaining, reason: "Order cancelled by customer", refundedBy: loaded.user!.id, }); refund.succeeded = true; } } catch (e: any) { refund.error = e instanceof RefundNotAllowedError ? e.message : (e?.message ?? "refund failed"); console.error( `Setup-fee refund failed for cancelled request ${id} (invoice ${tr.setupInvoiceId}):`, e ); } } // Customer cancels their own pending resume request: clear the // operator-side annotation so the 60-day TTL resumes counting. // Best-effort — the operator handles missing annotation gracefully. if (tr.requestType === "resume" && tr.tenantName) { try { await setTenantAnnotation( tr.tenantName, "pieced.ch/resume-request-pending", null ); } catch (e) { console.warn( "post-cancel annotation clear failed; not blocking", e ); } } return NextResponse.json({ message: "Request cancelled.", id, refund }); } catch (e: any) { console.error("Failed to cancel request:", e); return NextResponse.json( { error: safeError(e, "Failed to cancel request") }, { status: 500 } ); } } /** * PATCH /api/onboarding/[id] * * Customer edits a still-pending request. Validation is the same as on * POST /api/onboarding (shared schema). Only customer-input fields are * editable; status/tenant_name/admin_notes/etc. are server-managed. * * Note on company-level fields * ---------------------------- * For a follow-up instance (org has prior approved rows), the POST * handler intentionally ignores the wizard's billingAddress and uses * the on-file value instead. We mirror that here: company-level fields * (companyName, contactName, contactEmail, billingAddress) on a * follow-up edit are NOT updated through this endpoint. The customer * should use a future settings page (Bug 11) for those. For now, * editing only mutates per-instance fields — agent name, instance * name, packages, soulMd, agentsMd, billingNotes, packageSecrets. * * For the FIRST instance (no prior approved rows), billingAddress IS * editable here, since the customer is still defining their company's * billing data. */ export async function PATCH( req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const loaded = await loadAuthorized(id); if ("error" in loaded) return loaded.error; const tr = loaded.req!; if (tr.status !== "pending") { return NextResponse.json( { error: "Only pending requests can be edited.", code: "not_pending", currentStatus: tr.status, }, { status: 409 } ); } const body = await req.json().catch(() => null); const parsed = onboardingSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Invalid input", details: parsed.error.flatten() }, { status: 400 } ); } const input = parsed.data; // Re-encrypt package secrets if present in the patch body. When the // user re-opens the wizard to edit, the secrets array is populated // afresh from the wizard (we never decrypt and return existing // secrets — that'd be a security regression). If the user didn't // touch any secret-bearing package, the wizard sends no // packageSecrets and we leave the existing encrypted blob alone. let encryptedSecrets: Buffer | null | undefined; if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) { try { encryptedSecrets = await encryptSecrets(input.packageSecrets); } catch (e: any) { console.error("Failed to encrypt package secrets:", e); return NextResponse.json( { error: "Failed to secure credentials. Please try again." }, { status: 500 } ); } } // Only first-instance edits get billingAddress; follow-ups inherit // company billing from the on-file approved row. const isFirstInstance = !tr.tenantName; // approximation; covers the // "no prior approved row for this org" case the POST handler treats // identically. A more rigorous check would call // getMostRecentApprovedRequestForOrg, but in practice an org with // an approved row for some other tenant has a tenantName on those // rows, not on the pending one being edited — so the simple check // here is fine for the only state the endpoint accepts (pending). try { const updated = await updateTenantRequestEditableFields(id, { instanceName: input.instanceName, agentName: input.agentName, soulMd: input.soulMd, agentsMd: input.agentsMd, packages: input.packages ?? [], billingAddress: isFirstInstance ? input.billingAddress : undefined, billingNotes: input.billingNotes, encryptedSecrets, }); if (!updated) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } return NextResponse.json({ message: "Request updated.", id }); } catch (e: any) { console.error("Failed to edit request:", e); return NextResponse.json( { error: safeError(e, "Failed to edit request") }, { status: 500 } ); } }