diff --git a/src/app/[locale]/tenants/[name]/page.tsx b/src/app/[locale]/tenants/[name]/page.tsx
index 3320014..96e570f 100644
--- a/src/app/[locale]/tenants/[name]/page.tsx
+++ b/src/app/[locale]/tenants/[name]/page.tsx
@@ -272,6 +272,8 @@ export default async function TenantDetailPage({
? {
id: pendingResumeRequest.id,
createdAt: pendingResumeRequest.createdAt,
+ customerNotes:
+ pendingResumeRequest.customerNotes ?? null,
}
: null
}
diff --git a/src/app/api/tenants/[name]/resume-request/route.ts b/src/app/api/tenants/[name]/resume-request/route.ts
index 670d299..013d121 100644
--- a/src/app/api/tenants/[name]/resume-request/route.ts
+++ b/src/app/api/tenants/[name]/resume-request/route.ts
@@ -1,4 +1,5 @@
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";
@@ -7,8 +8,26 @@ import {
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
*
@@ -82,6 +101,18 @@ export async function POST(
);
}
+ // 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) {
@@ -110,6 +141,7 @@ export async function POST(
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
@@ -128,6 +160,20 @@ export async function POST(
);
}
+ // 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.",
diff --git a/src/components/admin/admin-panel.tsx b/src/components/admin/admin-panel.tsx
index 0de7816..823b879 100644
--- a/src/components/admin/admin-panel.tsx
+++ b/src/components/admin/admin-panel.tsx
@@ -384,6 +384,18 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
{req.tenantName}
)}
+ {/* Feature 6: customer's reactivation rationale,
+ shown inline so admin can triage without
+ opening a detail view. Truncated for
+ queue density; full content on hover. */}
+ {req.requestType === "resume" && req.customerNotes && (
+
+ {req.customerNotes}
+
+ )}
diff --git a/src/components/tenants/subscription-toggle.tsx b/src/components/tenants/subscription-toggle.tsx
index 4375517..e500325 100644
--- a/src/components/tenants/subscription-toggle.tsx
+++ b/src/components/tenants/subscription-toggle.tsx
@@ -24,11 +24,16 @@ interface Props {
isPlatform: boolean;
/**
* If a resume request is currently pending for this tenant, its
- * id and submitted-at. The component renders an info card with
- * a cancel-request button instead of the request-reactivation
- * button. Only meaningful when `suspended === true`.
+ * id, when it was submitted, and the customer's optional note.
+ * The component renders an info card with a cancel-request button
+ * instead of the request-reactivation button. Only meaningful when
+ * `suspended === true`.
*/
- pendingResumeRequest: { id: string; createdAt: string } | null;
+ pendingResumeRequest: {
+ id: string;
+ createdAt: string;
+ customerNotes: string | null;
+ } | null;
}
/**
@@ -65,6 +70,10 @@ export function SubscriptionToggle({
const [confirmResumeOpen, setConfirmResumeOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
+ // Feature 6: customer's free-form note attached to the resume
+ // request. Reset when the modal opens/closes so re-opening doesn't
+ // show stale text from a previous abandoned attempt.
+ const [resumeNotes, setResumeNotes] = useState("");
// Customer-side cancel: PATCH suspend=true. Same path as before.
// The 60-day retention copy in the modal is the new bit (Bug 37b);
@@ -106,6 +115,13 @@ export function SubscriptionToggle({
{
method: "POST",
headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ // Trim and omit on empty so the API stores NULL rather
+ // than empty string. The endpoint's zod transform also
+ // handles this; double-checking on the client lets us
+ // skip the round-trip when there's nothing to send.
+ customerNotes: resumeNotes.trim() || undefined,
+ }),
}
);
if (!res.ok) {
@@ -113,6 +129,7 @@ export function SubscriptionToggle({
throw new Error(data.error || t("subscriptionUpdateFailed"));
}
setConfirmResumeOpen(false);
+ setResumeNotes("");
router.refresh();
} catch (e: any) {
setError(e.message);
@@ -210,6 +227,15 @@ export function SubscriptionToggle({
when: formatRelative(pendingResumeRequest.createdAt, f),
})}
+ {/* Feature 6: echo the customer's note back so they can
+ see what they wrote. Useful especially when they
+ later wonder "what did I tell them?" or want to
+ confirm before cancelling and resubmitting. */}
+ {pendingResumeRequest.customerNotes && (
+