Adjusted SMTP

This commit is contained in:
2026-04-11 12:21:34 +02:00
parent 9a96d74f5c
commit 97b483c121
9 changed files with 339 additions and 24 deletions

View File

@@ -35,22 +35,22 @@ function getPool(): Pool {
const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS tenant_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
zitadel_org_id TEXT NOT NULL UNIQUE,
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
zitadel_org_id TEXT NOT NULL UNIQUE,
zitadel_user_id TEXT NOT NULL,
company_name TEXT NOT NULL,
contact_name TEXT NOT NULL,
contact_email TEXT NOT NULL,
agent_name TEXT NOT NULL DEFAULT 'Assistant',
soul_md TEXT,
packages TEXT[] DEFAULT '{}',
company_name TEXT NOT NULL,
contact_name TEXT NOT NULL,
contact_email TEXT NOT NULL,
agent_name TEXT NOT NULL DEFAULT 'Assistant',
soul_md TEXT,
packages TEXT[] DEFAULT '{}',
billing_address JSONB DEFAULT '{}',
billing_notes TEXT,
status TEXT NOT NULL DEFAULT 'pending',
admin_notes TEXT,
tenant_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
billing_notes TEXT,
status TEXT NOT NULL DEFAULT 'pending',
admin_notes TEXT,
tenant_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status);
@@ -75,8 +75,8 @@ export async function createTenantRequest(
await ensureSchema();
const result = await getPool().query<TenantRequest>(
`INSERT INTO tenant_requests
(zitadel_org_id, zitadel_user_id, company_name, contact_name,
contact_email, agent_name, soul_md, packages, billing_address, billing_notes)
(zitadel_org_id, zitadel_user_id, company_name, contact_name,
contact_email, agent_name, soul_md, packages, billing_address, billing_notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
@@ -132,12 +132,19 @@ export async function listTenantRequests(
export async function updateTenantRequestStatus(
id: string,
status: TenantRequestStatus,
extra?: { adminNotes?: string; tenantName?: string }
extra?: { adminNotes?: string | null; tenantName?: string; clearAdminNotes?: boolean }
): Promise<TenantRequest> {
await ensureSchema();
// If clearAdminNotes is true, explicitly set admin_notes to NULL
// Otherwise use COALESCE to preserve existing value when not provided
const adminNotesExpr = extra?.clearAdminNotes
? "$2"
: "COALESCE($2, admin_notes)";
const result = await getPool().query(
`UPDATE tenant_requests
SET status = $1, admin_notes = COALESCE($2, admin_notes),
SET status = $1, admin_notes = ${adminNotesExpr},
tenant_name = COALESCE($3, tenant_name), updated_at = now()
WHERE id = $4
RETURNING *`,
@@ -147,6 +154,35 @@ export async function updateTenantRequestStatus(
return mapRow(result.rows[0]);
}
/**
* Sync provisioning statuses: for all requests with status "provisioning",
* check if the PiecedTenant CR has reached "Ready" and update to "active".
* Called from the admin requests list endpoint.
*/
export async function syncProvisioningStatuses(
checkTenantPhase: (tenantName: string) => Promise<string | null>
): Promise<void> {
await ensureSchema();
const pool = getPool();
const result = await pool.query(
"SELECT id, tenant_name FROM tenant_requests WHERE status = 'provisioning' AND tenant_name IS NOT NULL"
);
for (const row of result.rows) {
try {
const phase = await checkTenantPhase(row.tenant_name);
if (phase === "Ready" || phase === "Running") {
await pool.query(
"UPDATE tenant_requests SET status = 'active', updated_at = now() WHERE id = $1",
[row.id]
);
}
} catch (e) {
console.error(`Failed to sync status for request ${row.id}:`, e);
}
}
}
// ---------------------------------------------------------------------------
// Row mapping (snake_case → camelCase)
// ---------------------------------------------------------------------------

165
src/lib/email.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Email sending utility for the PieCed portal.
*
* Uses nodemailer with SMTP credentials from environment variables
* (populated via ExternalSecret from OpenBao at pieced/portal/smtp).
*
* Env vars (from portal-smtp K8s secret):
* SMTP_HOST — e.g. smtp.gmail.com
* SMTP_PORT — e.g. 587 (default)
* SMTP_USER — e.g. noreply@pieced.ch
* SMTP_PASS — App Password
* SMTP_FROM — e.g. "PieCed <noreply@pieced.ch>"
* ADMIN_NOTIFICATION_EMAIL — e.g. admin@pieced.ch (optional)
*/
import nodemailer from "nodemailer";
let _transporter: nodemailer.Transporter | null = null;
function getTransporter(): nodemailer.Transporter {
if (!_transporter) {
const host = process.env.SMTP_HOST;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host || !user || !pass) {
throw new Error("SMTP_HOST, SMTP_USER, and SMTP_PASS must be set");
}
_transporter = nodemailer.createTransport({
host,
port: parseInt(process.env.SMTP_PORT || "587", 10),
secure: process.env.SMTP_SECURE === "true",
auth: { user, pass },
});
}
return _transporter;
}
function getFrom(): string {
return (
process.env.SMTP_FROM ||
`PieCed <${process.env.SMTP_USER}>`
);
}
export async function sendApprovalEmail(
to: string,
contactName: string,
companyName: string
): Promise<void> {
try {
await getTransporter().sendMail({
from: getFrom(),
to,
subject: `Your PieCed AI assistant is being set up — ${companyName}`,
text: [
`Hello ${contactName},`,
"",
`Great news! Your onboarding request for ${companyName} has been approved.`,
"",
"Your AI assistant instance is now being provisioned. This usually takes a few minutes.",
"You can check the status in your dashboard at https://app.pieced.ch",
"",
"Once your instance is ready, you'll see it on your dashboard and can start configuring it.",
"",
"Best regards,",
"PieCed IT",
].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;">Your AI assistant is being set up</h2>
<p>Hello ${contactName},</p>
<p>Great news! Your onboarding request for <strong>${companyName}</strong> has been approved.</p>
<p>Your AI assistant instance is now being provisioned. This usually takes a few minutes.</p>
<p>
<a href="https://app.pieced.ch" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
Go to Dashboard
</a>
</p>
<p style="color: #888; font-size: 13px; margin-top: 24px;">
Once your instance is ready, you'll see it on your dashboard and can start configuring it.
</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send approval email:", err);
}
}
export async function sendRejectionEmail(
to: string,
contactName: string,
companyName: string,
adminNotes?: string
): Promise<void> {
try {
const notesBlock = adminNotes
? `\nNote from our team:\n${adminNotes}\n`
: "";
const notesHtml = adminNotes
? `<div style="background: #2a2a2a; border-left: 3px solid #ef4444; padding: 12px 16px; border-radius: 6px; margin: 16px 0;">
<p style="color: #ccc; font-size: 13px; margin: 0;"><strong>Note from our team:</strong></p>
<p style="color: #aaa; font-size: 13px; margin: 8px 0 0 0;">${adminNotes}</p>
</div>`
: "";
await getTransporter().sendMail({
from: getFrom(),
to,
subject: `Update on your PieCed onboarding request — ${companyName}`,
text: [
`Hello ${contactName},`,
"",
`Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for ${companyName} at this time.`,
notesBlock,
"If you have questions or would like to discuss this further, please reply to this email.",
"",
"Best regards,",
"PieCed IT",
].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;">Update on your onboarding request</h2>
<p>Hello ${contactName},</p>
<p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${companyName}</strong> at this time.</p>
${notesHtml}
<p>If you have questions or would like to discuss this further, please reply to this email.</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send rejection email:", err);
}
}
export async function sendAdminNotificationEmail(
companyName: string,
contactName: string,
contactEmail: string
): Promise<void> {
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
if (!adminEmail) return;
try {
await getTransporter().sendMail({
from: getFrom(),
to: adminEmail,
subject: `New onboarding request: ${companyName}`,
text: [
`A new onboarding request has been submitted.`,
"",
`Company: ${companyName}`,
`Contact: ${contactName} (${contactEmail})`,
"",
`Review it at https://app.pieced.ch/admin`,
].join("\n"),
});
} catch (err) {
console.error("Failed to send admin notification email:", err);
}
}