200 lines
7.0 KiB
TypeScript
200 lines
7.0 KiB
TypeScript
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=<request-id>` 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 }
|
|
);
|
|
}
|
|
}
|