From 97b483c121400d5daaa87abd0806f6edaa84ef3f Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 11 Apr 2026 12:21:34 +0200 Subject: [PATCH] Adjusted SMTP --- deploy/setup-smtp.sh | 55 ++++++ package-lock.json | 20 +++ package.json | 2 + .../api/admin/requests/[id]/approve/route.ts | 19 +- .../api/admin/requests/[id]/reject/route.ts | 11 +- src/app/api/admin/requests/route.ts | 10 +- src/app/api/onboarding/route.ts | 9 + src/lib/db.ts | 72 ++++++-- src/lib/email.ts | 165 ++++++++++++++++++ 9 files changed, 339 insertions(+), 24 deletions(-) create mode 100644 deploy/setup-smtp.sh create mode 100644 src/lib/email.ts diff --git a/deploy/setup-smtp.sh b/deploy/setup-smtp.sh new file mode 100644 index 0000000..ab8d2ad --- /dev/null +++ b/deploy/setup-smtp.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Session 6.4 — SMTP secret setup for PieCed Portal +# +# 1. Store SMTP credentials in OpenBao +# 2. Apply the ExternalSecret +# 3. Patch the portal deployment to mount the secret +# +# Prerequisites: bao CLI authenticated, kubectl context set + +set -e + +# ─── Step 1: Store SMTP creds in OpenBao ─────────────────────────────────────── +echo "==> Storing SMTP credentials in OpenBao..." +bao kv put pieced/portal/smtp \ + host="smtp.gmail.com" \ + port="587" \ + user="noreply@pieced.ch" \ + password="REPLACE_WITH_APP_PASSWORD" \ + from="PieCed " \ + admin_email="admin@pieced.ch" + +echo "==> Verifying..." +bao kv get pieced/portal/smtp + +# ─── Step 2: Apply ExternalSecret ────────────────────────────────────────────── +echo "==> Applying ExternalSecret..." +kubectl apply -f deploy/portal-smtp-externalsecret.yaml + +echo "==> Waiting for ExternalSecret to sync..." +kubectl wait --for=condition=Ready externalsecret/portal-smtp -n pieced-system --timeout=60s + +echo "==> Verifying K8s secret created..." +kubectl get secret portal-smtp -n pieced-system + +# ─── Step 3: Patch portal deployment to mount SMTP secret ────────────────────── +echo "==> Patching portal deployment..." +# Add envFrom entry for portal-smtp secret +# If your deployment already uses a patch file, add this to the containers[0].envFrom array instead. +kubectl patch deployment pieced-portal -n pieced-system --type=json -p='[ + { + "op": "add", + "path": "/spec/template/spec/containers/0/envFrom/-", + "value": { + "secretRef": { + "name": "portal-smtp" + } + } + } +]' + +echo "==> Restarting portal..." +kubectl rollout restart deployment pieced-portal -n pieced-system +kubectl rollout status deployment pieced-portal -n pieced-system + +echo "==> Done! SMTP credentials are now available to the portal." diff --git a/package-lock.json b/package-lock.json index 3fda7c3..3aea9f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "0.1.0", "dependencies": { "@kubernetes/client-node": "^1.4.0", + "@types/nodemailer": "^8.0.0", "@types/pg": "^8.20.0", "next": "^15.5.15", "next-auth": "^5.0.0-beta.30", "next-intl": "^4.9.0", + "nodemailer": "^7.0.13", "pg": "^8.20.0", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -2015,6 +2017,15 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", @@ -5824,6 +5835,15 @@ } } }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/oauth4webapi": { "version": "3.8.5", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", diff --git a/package.json b/package.json index 278808a..a3a6627 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ }, "dependencies": { "@kubernetes/client-node": "^1.4.0", + "@types/nodemailer": "^8.0.0", "@types/pg": "^8.20.0", "next": "^15.5.15", "next-auth": "^5.0.0-beta.30", "next-intl": "^4.9.0", + "nodemailer": "^7.0.13", "pg": "^8.20.0", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts index 20a4fb1..137ea91 100644 --- a/src/app/api/admin/requests/[id]/approve/route.ts +++ b/src/app/api/admin/requests/[id]/approve/route.ts @@ -2,11 +2,12 @@ import { NextResponse } from "next/server"; import { requirePlatformRole } from "@/lib/session"; import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db"; import { createTenant } from "@/lib/k8s"; +import { sendApprovalEmail } from "@/lib/email"; /** * POST /api/admin/requests/[id]/approve - * Approve a tenant request: create the PiecedTenant CR and update status. - * Also supports re-approving a previously rejected request. + * Approve a tenant request: create the PiecedTenant CR, update status, notify customer. + * Also supports re-approving a previously rejected request (clears admin notes). */ export async function POST( request: Request, @@ -37,6 +38,8 @@ export async function POST( ); } + const isReApproval = tenantRequest.status === "rejected"; + // Derive tenant name from company name: lowercase, alphanumeric + hyphens const tenantName = tenantRequest.companyName .toLowerCase() @@ -61,12 +64,20 @@ export async function POST( } ); - // Update request status + // Update request status — clear admin notes on re-approval const updated = await updateTenantRequestStatus(id, "provisioning", { - adminNotes, + adminNotes: isReApproval ? null : adminNotes, tenantName, + clearAdminNotes: isReApproval, }); + // Notify customer + await sendApprovalEmail( + tenantRequest.contactEmail, + tenantRequest.contactName, + tenantRequest.companyName + ); + return NextResponse.json({ message: "Tenant approved and provisioning started.", request: updated, diff --git a/src/app/api/admin/requests/[id]/reject/route.ts b/src/app/api/admin/requests/[id]/reject/route.ts index bcfe71e..14faecb 100644 --- a/src/app/api/admin/requests/[id]/reject/route.ts +++ b/src/app/api/admin/requests/[id]/reject/route.ts @@ -1,10 +1,11 @@ import { NextResponse } from "next/server"; import { requirePlatformRole } from "@/lib/session"; import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db"; +import { sendRejectionEmail } from "@/lib/email"; /** * POST /api/admin/requests/[id]/reject - * Reject a tenant request. + * Reject a tenant request and notify the customer. */ export async function POST( request: Request, @@ -36,6 +37,14 @@ export async function POST( adminNotes, }); + // Notify customer + await sendRejectionEmail( + tenantRequest.contactEmail, + tenantRequest.contactName, + tenantRequest.companyName, + adminNotes + ); + return NextResponse.json({ message: "Request rejected.", request: updated, diff --git a/src/app/api/admin/requests/route.ts b/src/app/api/admin/requests/route.ts index b81ee0d..dec43c7 100644 --- a/src/app/api/admin/requests/route.ts +++ b/src/app/api/admin/requests/route.ts @@ -1,10 +1,12 @@ import { NextResponse } from "next/server"; import { requirePlatformRole } from "@/lib/session"; -import { listTenantRequests } from "@/lib/db"; +import { listTenantRequests, syncProvisioningStatuses } from "@/lib/db"; +import { getTenant } from "@/lib/k8s"; /** * GET /api/admin/requests * List all tenant requests. Optionally filter by ?status=pending + * Auto-syncs "provisioning" → "active" when the PiecedTenant CR is Ready. */ export async function GET(request: Request) { try { @@ -13,6 +15,12 @@ export async function GET(request: Request) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } + // Sync provisioning statuses before listing + await syncProvisioningStatuses(async (tenantName: string) => { + const tenant = await getTenant(tenantName); + return tenant?.status?.phase ?? null; + }); + const { searchParams } = new URL(request.url); const status = searchParams.get("status") as any; diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts index 5a05b80..8a969f3 100644 --- a/src/app/api/onboarding/route.ts +++ b/src/app/api/onboarding/route.ts @@ -5,6 +5,7 @@ import { getTenantRequestByOrgId, } from "@/lib/db"; import { getTenant, listTenants } from "@/lib/k8s"; +import { sendAdminNotificationEmail } from "@/lib/email"; import type { OnboardingInput } from "@/types"; import { z } from "zod"; @@ -87,6 +88,7 @@ export async function GET() { * POST /api/onboarding * Submit the onboarding wizard. Creates a tenant_request with status "pending". * The actual PiecedTenant CR is NOT created yet — admin approval required. + * Sends a notification email to the admin. */ export async function POST(request: Request) { const user = await getSessionUser(); @@ -138,6 +140,13 @@ export async function POST(request: Request) { billingNotes: input.billingNotes, }); + // Notify admin about the new request + await sendAdminNotificationEmail( + user.orgName, + user.name || user.email, + user.email + ); + return NextResponse.json( { message: "Onboarding request submitted.", request: tenantRequest }, { status: 201 } diff --git a/src/lib/db.ts b/src/lib/db.ts index 714729c..805e610 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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( `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 { 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 +): Promise { + 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) // --------------------------------------------------------------------------- diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..9620fc2 --- /dev/null +++ b/src/lib/email.ts @@ -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 " + * 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 { + 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: ` +
+

Your AI assistant is being set up

+

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.

+

+ + Go to Dashboard + +

+

+ Once your instance is ready, you'll see it on your dashboard and can start configuring it. +

+
+

PieCed IT — Hosted on-premises in Switzerland

+
+ `, + }); + } catch (err) { + console.error("Failed to send approval email:", err); + } +} + +export async function sendRejectionEmail( + to: string, + contactName: string, + companyName: string, + adminNotes?: string +): Promise { + try { + const notesBlock = adminNotes + ? `\nNote from our team:\n${adminNotes}\n` + : ""; + const notesHtml = adminNotes + ? `
+

Note from our team:

+

${adminNotes}

+
` + : ""; + + 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: ` +
+

Update on your onboarding request

+

Hello ${contactName},

+

Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for ${companyName} at this time.

+ ${notesHtml} +

If you have questions or would like to discuss this further, please reply to this email.

+
+

PieCed IT — Hosted on-premises in Switzerland

+
+ `, + }); + } catch (err) { + console.error("Failed to send rejection email:", err); + } +} + +export async function sendAdminNotificationEmail( + companyName: string, + contactName: string, + contactEmail: string +): Promise { + 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); + } +}