From ac0cc122d3d3765df91f4f39a6b1e0baa4b6aaa8 Mon Sep 17 00:00:00 2001 From: Corey Blais Date: Wed, 15 Apr 2026 17:16:35 -0400 Subject: [PATCH] added admin email notification --- .env.example | 1 + backend/src/app.ts | 81 +++++++++++++++++++++++++++++++++++++++++ docker-compose.prod.yml | 1 + docker-compose.yml | 1 + 4 files changed, 84 insertions(+) diff --git a/.env.example b/.env.example index 54a6911..800354d 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,4 @@ BACKEND_URL=http://localhost:5000 VITE_API_BASE_URL=http://localhost:5000/api NODE_ENV=development ADMIN_EMAILS=corey@blaishome.online +RESCUE_STATUS_NOTIFICATION_EMAIL=appadmin@flockpal.app diff --git a/backend/src/app.ts b/backend/src/app.ts index 9b81c56..c7cbb00 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -219,6 +219,7 @@ const smtpUser = process.env.SMTP_USER?.trim() ?? ''; const smtpPass = process.env.SMTP_PASS?.trim() ?? ''; const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? ''; const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal'; +const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app'; const adminEmails = new Set( (process.env.ADMIN_EMAILS ?? '') .split(',') @@ -487,6 +488,64 @@ const sendMagicLink = async ({ }; }; +const escapeHtml = (value: string) => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const sendRescueStatusNotification = async ({ + workspace, + ownerEmail, + event, +}: { + workspace: WorkspaceRow; + ownerEmail: string | null; + event: 'created' | 'converted' | 'status_changed'; +}) => { + const statusLabel = workspace.rescue_verification_status.replace(/_/g, ' '); + const eventLabel = event === 'created' ? 'created' : event === 'converted' ? 'converted to rescue' : 'status updated'; + const subject = `FlockPal rescue status: ${workspace.name} ${eventLabel}`; + const escapedWorkspaceName = escapeHtml(workspace.name); + const escapedStatusLabel = escapeHtml(statusLabel); + const escapedOwnerEmail = escapeHtml(ownerEmail ?? 'unknown'); + const escapedBillingEmail = escapeHtml(workspace.billing_email ?? 'not set'); + const lines = [ + `Rescue flock: ${workspace.name}`, + `Event: ${eventLabel}`, + `Verification status: ${statusLabel}`, + `Owner email: ${ownerEmail ?? 'unknown'}`, + `Billing email: ${workspace.billing_email ?? 'not set'}`, + `Flock ID: ${workspace.id}`, + ]; + + if (!mailTransport) { + console.log(`Rescue status notification for ${rescueStatusNotificationEmail}:\n${lines.join('\n')}`); + return { delivered: false }; + } + + await mailTransport.sendMail({ + from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail, + to: rescueStatusNotificationEmail, + subject, + text: lines.join('\n'), + html: ` +

A rescue flock was ${eventLabel}.

+ + `, + }); + + return { delivered: true }; +}; + const issueMagicLinkInvite = async ({ email, name, @@ -1006,6 +1065,12 @@ app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireSessi return; } + await sendRescueStatusNotification({ + workspace, + ownerEmail: null, + event: 'status_changed', + }); + res.json({ workspace: normalizeWorkspace(workspace) }); } catch (error) { next(error); @@ -1098,6 +1163,14 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request owner: req.auth!.user, }); + if (workspace?.workspace_type === 'rescue') { + await sendRescueStatusNotification({ + workspace, + ownerEmail: req.auth!.user.email, + event: 'created', + }); + } + res.status(201).json({ workspace: normalizeWorkspace(workspace!) }); } catch (error) { next(error); @@ -1126,6 +1199,14 @@ app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole( billingPlan, }); + if (workspace?.workspace_type === 'rescue' && req.auth!.workspace.workspace_type !== 'rescue') { + await sendRescueStatusNotification({ + workspace, + ownerEmail: req.auth!.user.email, + event: 'converted', + }); + } + res.json({ workspace: normalizeWorkspace(workspace!) }); } catch (error) { next(error); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0c88dd5..93357b0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -31,6 +31,7 @@ services: FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production} BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production} ADMIN_EMAILS: ${ADMIN_EMAILS:-} + RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-} diff --git a/docker-compose.yml b/docker-compose.yml index afda506..5ddc573 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} BACKEND_URL: ${BACKEND_URL:-http://localhost:5000} ADMIN_EMAILS: ${ADMIN_EMAILS:-} + RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}