Adjusted SMTP
This commit is contained in:
@@ -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
165
src/lib/email.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user