Add note to reactivation request
All checks were successful
Build and Push / build (push) Successful in 1m28s

This commit is contained in:
2026-05-02 16:43:54 +02:00
parent 8273d08f15
commit 11157b872c
11 changed files with 235 additions and 11 deletions

View File

@@ -93,6 +93,14 @@ const MIGRATION_SQL = `
-- is only meaningful for rejected and cancelled rows.
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ;
-- Feature 6: free-form customer note attached to the request.
-- Currently surfaced only by resume requests (where the customer
-- explains why they want reactivation), but the column is generic
-- so future flows could reuse it. Distinct from billing_notes
-- (provision-only, accounting-related) and admin_notes (admin's
-- reason on reject/approve). Optional — nullable.
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS customer_notes TEXT;
-- Bug 37a: resume requests use the same table as provision requests so
-- the customer dashboard and admin queue share rendering. Discriminator
-- is request_type. Default 'provision' on backfill keeps existing rows
@@ -558,14 +566,21 @@ export async function createResumeRequest(params: {
// tenant request for traceability rather than storing dummy values.
companyName: string;
agentName: string;
/**
* Feature 6: optional free-form note from the customer explaining
* why they want reactivation. Surfaced to admin in the queue and
* forwarded to the platform notification email so the admin can
* decide before opening the request.
*/
customerNotes?: string | null;
}): Promise<TenantRequest> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO tenant_requests (
zitadel_org_id, zitadel_user_id, company_name,
contact_name, contact_email, agent_name,
tenant_name, request_type, status
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'resume', 'pending')
tenant_name, request_type, status, customer_notes
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'resume', 'pending', $8)
RETURNING *`,
[
params.zitadelOrgId,
@@ -575,6 +590,7 @@ export async function createResumeRequest(params: {
params.contactEmail,
params.agentName,
params.tenantName,
params.customerNotes ?? null,
]
);
return mapRow(result.rows[0]);
@@ -876,6 +892,7 @@ function mapRow(row: any): TenantRequest {
packages: row.packages ?? [],
billingAddress: row.billing_address ?? {},
billingNotes: row.billing_notes,
customerNotes: row.customer_notes ?? null,
status: row.status as TenantRequestStatus,
adminNotes: row.admin_notes,
tenantName: row.tenant_name,

View File

@@ -319,6 +319,89 @@ export async function sendAdminNotificationEmail(
}
}
// ---------------------------------------------------------------------------
// Feature 6: resume-request admin notification
// ---------------------------------------------------------------------------
/**
* Notify the admin distribution list that a customer has requested
* reactivation of a suspended tenant. Distinct from the onboarding
* notification because the action consequences differ (admin
* approving a resume just unsuspends an existing tenant; no
* provisioning runs), and because the customer's note — explaining
* why they want reactivation — is meaningful context for the admin
* triaging the queue.
*
* Skipped silently if ADMIN_NOTIFICATION_EMAIL isn't set, matching
* the pattern of the other admin notification functions.
*/
export async function sendResumeRequestAdminNotificationEmail(params: {
tenantName: string;
companyName: string;
contactName: string;
contactEmail: string;
customerNotes?: string | null;
}): Promise<void> {
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
if (!adminEmail) return;
const safeCompany = escapeHtml(params.companyName);
const safeName = escapeHtml(params.contactName);
const safeEmail = escapeHtml(params.contactEmail);
const safeTenant = escapeHtml(params.tenantName);
const safeNotes = params.customerNotes ? escapeHtml(params.customerNotes) : "";
const noteText = params.customerNotes
? `\nCustomer's note:\n${params.customerNotes}\n`
: "";
const noteHtml = safeNotes
? `<div style="background: #2a2a2a; border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 6px; margin: 16px 0; white-space: pre-wrap;">
<p style="color: #ccc; font-size: 13px; margin: 0 0 8px 0;"><strong>Customer's note:</strong></p>
<p style="color: #e0e0e0; font-size: 13px; margin: 0;">${safeNotes}</p>
</div>`
: "";
try {
await getTransporter().sendMail({
from: getFrom(),
to: adminEmail,
subject: `Reactivation request: ${params.companyName}`,
text: [
`A customer has requested reactivation of a suspended tenant.`,
"",
`Company: ${params.companyName}`,
`Tenant: ${params.tenantName}`,
`Contact: ${params.contactName} (${params.contactEmail})`,
noteText,
`Review at https://app.pieced.ch/admin`,
]
.filter((s) => s !== "")
.join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Reactivation request</h2>
<p>A customer has requested reactivation of a suspended tenant.</p>
<table style="color: #ccc; font-size: 14px; margin: 16px 0;">
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Company:</td><td>${safeCompany}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Tenant:</td><td style="font-family: monospace;">${safeTenant}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Contact:</td><td>${safeName} (${safeEmail})</td></tr>
</table>
${noteHtml}
<p>
<a href="https://app.pieced.ch/admin" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
Review Request
</a>
</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Admin notification</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send resume request admin notification:", err);
}
}
// ---------------------------------------------------------------------------
// Feature 5: support ticket emails
// ---------------------------------------------------------------------------