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