diff --git a/src/app/[locale]/tenants/[name]/page.tsx b/src/app/[locale]/tenants/[name]/page.tsx
index f86c862..3320014 100644
--- a/src/app/[locale]/tenants/[name]/page.tsx
+++ b/src/app/[locale]/tenants/[name]/page.tsx
@@ -142,6 +142,53 @@ export default async function TenantDetailPage({
{t("suspendedDescription")}
+ {/* Retention countdown. suspendedAt is stamped by the
+ operator on first transition to suspended; missing
+ values fall through silently rather than rendering
+ garbage (operator hasn't reconciled yet, edge case).
+ The 60-day window is the operator's
+ retentionAfterSuspend constant; if you change one,
+ change both. We don't expose the constant via API —
+ the value rarely changes and duplicating it here
+ beats fetching a single int over the network. */}
+ {tenant.status?.suspendedAt && (() => {
+ const suspendedAt = new Date(tenant.status.suspendedAt);
+ const deletionAt = new Date(suspendedAt);
+ deletionAt.setDate(deletionAt.getDate() + 60);
+ const now = new Date();
+ const msRemaining = deletionAt.getTime() - now.getTime();
+ const daysRemaining = Math.max(
+ 0,
+ Math.ceil(msRemaining / (1000 * 60 * 60 * 24))
+ );
+ // < 7 days: red/critical to draw attention. Otherwise
+ // amber, matching the banner.
+ const urgent = daysRemaining < 7;
+ return (
+
+ {t("suspendedSince", {
+ date: formatDateTime(
+ tenant.status.suspendedAt,
+ f
+ ),
+ })}
+ {" · "}
+ {daysRemaining > 0
+ ? t("suspendedDeletionIn", {
+ days: daysRemaining,
+ date: formatDateTime(
+ deletionAt.toISOString(),
+ f
+ ),
+ })
+ : t("suspendedDeletionImminent")}
+
+ );
+ })()}
diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts
index 20f43d6..e91cf18 100644
--- a/src/app/api/admin/requests/[id]/approve/route.ts
+++ b/src/app/api/admin/requests/[id]/approve/route.ts
@@ -6,7 +6,7 @@ import {
clearEncryptedSecrets,
} from "@/lib/db";
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
-import { sendApprovalEmail } from "@/lib/email";
+import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
import { decryptSecrets } from "@/lib/crypto";
import { writePackageSecrets } from "@/lib/openbao";
import {
@@ -105,11 +105,11 @@ export async function POST(
await updateTenantRequestStatus(id, "approved", { adminNotes });
- await sendApprovalEmail(
+ await sendResumeApprovalEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,
tenantRequest.companyName
- ).catch((e) => console.error("approval email failed:", e));
+ ).catch((e) => console.error("resume approval email failed:", e));
return NextResponse.json({
message: "Resume approved. Tenant is reactivating.",
diff --git a/src/app/api/admin/requests/[id]/reject/route.ts b/src/app/api/admin/requests/[id]/reject/route.ts
index 016114b..bc514f6 100644
--- a/src/app/api/admin/requests/[id]/reject/route.ts
+++ b/src/app/api/admin/requests/[id]/reject/route.ts
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
import { setTenantAnnotation } from "@/lib/k8s";
-import { sendRejectionEmail } from "@/lib/email";
+import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
/**
* POST /api/admin/requests/[id]/reject
@@ -65,13 +65,25 @@ export async function POST(
}
}
- // Notify customer
- await sendRejectionEmail(
- tenantRequest.contactEmail,
- tenantRequest.contactName,
- tenantRequest.companyName,
- adminNotes
- );
+ // Notify customer. Resume requests get a different email — the
+ // tenant already exists; copy needs to mention "stays suspended" and
+ // the 60-day retention deadline. Provision rejections use the
+ // original onboarding-rejection wording.
+ if (tenantRequest.requestType === "resume") {
+ await sendResumeRejectionEmail(
+ tenantRequest.contactEmail,
+ tenantRequest.contactName,
+ tenantRequest.companyName,
+ adminNotes
+ );
+ } else {
+ await sendRejectionEmail(
+ tenantRequest.contactEmail,
+ tenantRequest.contactName,
+ tenantRequest.companyName,
+ adminNotes
+ );
+ }
return NextResponse.json({
message: "Request rejected.",
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 0311589..c1f5d79 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -156,6 +156,121 @@ export async function sendRejectionEmail(
}
}
+/**
+ * Bug 37a: separate email for resume request approval. The tenant
+ * already exists; the message is "we're un-suspending it" rather than
+ * "we're provisioning a new instance". Avoids confusing the customer
+ * with onboarding language for a tenant they already had.
+ */
+export async function sendResumeApprovalEmail(
+ to: string,
+ contactName: string,
+ companyName: string
+): Promise {
+ const safeName = escapeHtml(contactName);
+ const safeCompany = escapeHtml(companyName);
+
+ try {
+ await getTransporter().sendMail({
+ from: getFrom(),
+ to,
+ subject: `Your PieCed AI assistant has been reactivated — ${companyName}`,
+ text: [
+ `Hello ${contactName},`,
+ "",
+ `Good news — your reactivation request for ${companyName} has been approved.`,
+ "",
+ "Your AI assistant is being brought back online and should be ready in a few minutes.",
+ "You can check the status in your dashboard at https://app.pieced.ch",
+ "",
+ "Best regards,",
+ "PieCed IT",
+ ].join("\n"),
+ html: `
+
+
Your AI assistant has been reactivated
+
Hello ${safeName},
+
Good news — your reactivation request for ${safeCompany} has been approved.
+
Your AI assistant is being brought back online and should be ready in a few minutes.
+
+
+ Go to Dashboard
+
+
+
+
PieCed IT — Hosted on-premises in Switzerland
+
+ `,
+ });
+ } catch (err) {
+ console.error("Failed to send resume approval email:", err);
+ }
+}
+
+/**
+ * Bug 37a: separate email for resume request rejection. Differs from
+ * the onboarding rejection in two ways: it explicitly mentions the
+ * tenant remains suspended, and it points the customer to the
+ * 60-day retention window so they understand the deletion clock is
+ * still ticking. The latter is important — a customer reading a
+ * generic "request rejected" email might not realise their data is
+ * still on a countdown.
+ */
+export async function sendResumeRejectionEmail(
+ to: string,
+ contactName: string,
+ companyName: string,
+ adminNotes?: string
+): Promise {
+ const safeName = escapeHtml(contactName);
+ const safeCompany = escapeHtml(companyName);
+ const safeNotes = adminNotes ? escapeHtml(adminNotes) : "";
+
+ try {
+ const notesBlock = adminNotes
+ ? `\nNote from our team:\n${adminNotes}\n`
+ : "";
+ const notesHtml = safeNotes
+ ? `
+
Note from our team:
+
${safeNotes}
+
`
+ : "";
+
+ await getTransporter().sendMail({
+ from: getFrom(),
+ to,
+ subject: `Update on your reactivation request — ${companyName}`,
+ text: [
+ `Hello ${contactName},`,
+ "",
+ `Thank you for your reactivation request for ${companyName}. Unfortunately, we were unable to approve it at this time.`,
+ notesBlock,
+ "Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.",
+ "",
+ "If you have questions, please reply to this email.",
+ "",
+ "Best regards,",
+ "PieCed IT",
+ ].join("\n"),
+ html: `
+
+
Update on your reactivation request
+
Hello ${safeName},
+
Thank you for your reactivation request for ${safeCompany}. Unfortunately, we were unable to approve it at this time.
+ ${notesHtml}
+
Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.
+
If you have questions, please reply to this email.
+
+
PieCed IT — Hosted on-premises in Switzerland
+
+ `,
+ });
+ } catch (err) {
+ console.error("Failed to send resume rejection email:", err);
+ }
+}
+
export async function sendAdminNotificationEmail(
companyName: string,
contactName: string,
diff --git a/src/messages/de.json b/src/messages/de.json
index 17346ce..29a3186 100644
--- a/src/messages/de.json
+++ b/src/messages/de.json
@@ -166,7 +166,10 @@
"resumeRequestPendingTitle": "Reaktivierungsanfrage ausstehend",
"resumeRequestPendingDescription": "Eingereicht {when}. Ein Administrator wird die Anfrage in Kürze prüfen.",
"resumeRequestPendingNoteAdmin": "Ein Inhaber hat eine Reaktivierung angefragt; Sie können direkt oben fortfahren oder die Anfrage in der Admin-Warteschlange bearbeiten.",
- "cancelConfirmRetentionWarning": "Ihre Daten bleiben nach der Kündigung 60 Tage lang erhalten. Danach werden alle Tenant-Daten – Konfiguration, Geheimnisse, Konversationen und Dateien – endgültig gelöscht."
+ "cancelConfirmRetentionWarning": "Ihre Daten bleiben nach der Kündigung 60 Tage lang erhalten. Danach werden alle Tenant-Daten – Konfiguration, Geheimnisse, Konversationen und Dateien – endgültig gelöscht.",
+ "suspendedSince": "Gekündigt am {date}",
+ "suspendedDeletionIn": "Datenlöschung in {days, plural, one {# Tag} other {# Tagen}} ({date})",
+ "suspendedDeletionImminent": "Daten werden jetzt gelöscht"
},
"usage": {
"inputTokens": "Input-Tokens",
diff --git a/src/messages/en.json b/src/messages/en.json
index 0930cb1..36bf021 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -166,7 +166,10 @@
"resumeRequestPendingTitle": "Reactivation request pending",
"resumeRequestPendingDescription": "Submitted {when}. An administrator will review it shortly.",
"resumeRequestPendingNoteAdmin": "An owner has requested reactivation; you can resume directly above or process the request from the admin queue.",
- "cancelConfirmRetentionWarning": "Your data is preserved for 60 days after cancellation. After that, all tenant data — configuration, secrets, conversations, and files — will be permanently deleted."
+ "cancelConfirmRetentionWarning": "Your data is preserved for 60 days after cancellation. After that, all tenant data — configuration, secrets, conversations, and files — will be permanently deleted.",
+ "suspendedSince": "Suspended on {date}",
+ "suspendedDeletionIn": "data deletion in {days, plural, one {# day} other {# days}} ({date})",
+ "suspendedDeletionImminent": "data is being deleted now"
},
"usage": {
"inputTokens": "Input Tokens",
diff --git a/src/messages/fr.json b/src/messages/fr.json
index 215c516..9d6d4ea 100644
--- a/src/messages/fr.json
+++ b/src/messages/fr.json
@@ -166,7 +166,10 @@
"resumeRequestPendingTitle": "Demande de réactivation en attente",
"resumeRequestPendingDescription": "Soumise {when}. Un administrateur l'examinera sous peu.",
"resumeRequestPendingNoteAdmin": "Un propriétaire a demandé la réactivation ; vous pouvez reprendre directement ci-dessus ou traiter la demande depuis la file d'attente d'administration.",
- "cancelConfirmRetentionWarning": "Vos données sont conservées pendant 60 jours après l'annulation. Passé ce délai, toutes les données du locataire — configuration, secrets, conversations et fichiers — seront définitivement supprimées."
+ "cancelConfirmRetentionWarning": "Vos données sont conservées pendant 60 jours après l'annulation. Passé ce délai, toutes les données du locataire — configuration, secrets, conversations et fichiers — seront définitivement supprimées.",
+ "suspendedSince": "Suspendu le {date}",
+ "suspendedDeletionIn": "suppression des données dans {days, plural, one {# jour} other {# jours}} ({date})",
+ "suspendedDeletionImminent": "les données sont en cours de suppression"
},
"usage": {
"inputTokens": "Tokens d'entrée",
diff --git a/src/messages/it.json b/src/messages/it.json
index c49fcf5..abe2715 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -166,7 +166,10 @@
"resumeRequestPendingTitle": "Richiesta di riattivazione in sospeso",
"resumeRequestPendingDescription": "Inviata {when}. Un amministratore la esaminerà a breve.",
"resumeRequestPendingNoteAdmin": "Un proprietario ha richiesto la riattivazione; puoi riprendere direttamente sopra o elaborare la richiesta dalla coda di amministrazione.",
- "cancelConfirmRetentionWarning": "I tuoi dati sono conservati per 60 giorni dopo l'annullamento. Trascorso tale periodo, tutti i dati del tenant — configurazione, segreti, conversazioni e file — verranno eliminati definitivamente."
+ "cancelConfirmRetentionWarning": "I tuoi dati sono conservati per 60 giorni dopo l'annullamento. Trascorso tale periodo, tutti i dati del tenant — configurazione, segreti, conversazioni e file — verranno eliminati definitivamente.",
+ "suspendedSince": "Sospeso il {date}",
+ "suspendedDeletionIn": "eliminazione dei dati tra {days, plural, one {# giorno} other {# giorni}} ({date})",
+ "suspendedDeletionImminent": "i dati vengono eliminati ora"
},
"usage": {
"inputTokens": "Token di input",
diff --git a/src/types/index.ts b/src/types/index.ts
index 86acfe3..56c2151 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -103,6 +103,16 @@ export interface PiecedTenantStatus {
litellmKeyAlias?: string;
tenantNamespace?: string;
enabledPackages?: string[];
+ /**
+ * RFC3339 timestamp of when the tenant first transitioned to
+ * suspended (Bug 37). Stamped by the operator on the first reconcile
+ * with `spec.suspend=true` and cleared when the tenant resumes. Used
+ * by the portal to render the "deleted in N days" countdown in the
+ * suspended banner. The retention policy is 60 days from this
+ * timestamp; see operator's `retentionAfterSuspend` constant for the
+ * authoritative value.
+ */
+ suspendedAt?: string;
/**
* Non-fatal issues from downstream resources surfaced by the operator
* (e.g. an OpenClawInstance sub-condition reporting failure). The