From 11157b872c16afd2f354d7c254af2ba3cde5ae86 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 2 May 2026 16:43:54 +0200 Subject: [PATCH] Add note to reactivation request --- src/app/[locale]/tenants/[name]/page.tsx | 2 + .../tenants/[name]/resume-request/route.ts | 46 ++++++++++ src/components/admin/admin-panel.tsx | 12 +++ .../tenants/subscription-toggle.tsx | 59 +++++++++++-- src/lib/db.ts | 21 ++++- src/lib/email.ts | 83 +++++++++++++++++++ src/messages/de.json | 4 +- src/messages/en.json | 4 +- src/messages/fr.json | 4 +- src/messages/it.json | 4 +- src/types/index.ts | 7 ++ 11 files changed, 235 insertions(+), 11 deletions(-) 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 && ( +
+ {pendingResumeRequest.customerNotes} +
+ )}