feat: extend notification system with batch sender, bulk dialog, and logging
Add NotificationLog schema extensions (nullable userId, email, roundId, projectId, batchId fields), batch notification sender service, and bulk notification dialog UI. Include utility scripts for debugging and seeding. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,12 +6,20 @@ import { prisma } from '@/lib/prisma'
|
||||
let cachedTransporter: Transporter | null = null
|
||||
let cachedConfigHash = ''
|
||||
let cachedFrom = ''
|
||||
let cachedAt = 0
|
||||
const CACHE_TTL = 60_000 // 1 minute
|
||||
|
||||
/**
|
||||
* Get SMTP transporter using database settings with env var fallback.
|
||||
* Caches the transporter and rebuilds it when settings change.
|
||||
* Uses connection pooling for reliable bulk sends.
|
||||
*/
|
||||
async function getTransporter(): Promise<{ transporter: Transporter; from: string }> {
|
||||
// Fast path: return cached transporter if still fresh
|
||||
if (cachedTransporter && Date.now() - cachedAt < CACHE_TTL) {
|
||||
return { transporter: cachedTransporter, from: cachedFrom }
|
||||
}
|
||||
|
||||
// Read DB settings
|
||||
const dbSettings = await prisma.systemSettings.findMany({
|
||||
where: {
|
||||
@@ -43,22 +51,42 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
|
||||
// Check if config changed since last call
|
||||
const configHash = `${host}:${port}:${user}:${pass}:${from}`
|
||||
if (cachedTransporter && configHash === cachedConfigHash) {
|
||||
cachedAt = Date.now()
|
||||
return { transporter: cachedTransporter, from: cachedFrom }
|
||||
}
|
||||
|
||||
// Create new transporter
|
||||
// Close old transporter if it exists (clean up pooled connections)
|
||||
if (cachedTransporter) {
|
||||
try { cachedTransporter.close() } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Create new transporter with connection pooling for reliable bulk sends
|
||||
cachedTransporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: parseInt(port),
|
||||
secure: port === '465',
|
||||
auth: { user, pass },
|
||||
})
|
||||
pool: true,
|
||||
maxConnections: 5,
|
||||
maxMessages: 10,
|
||||
socketTimeout: 30_000,
|
||||
connectionTimeout: 15_000,
|
||||
} as nodemailer.TransportOptions)
|
||||
cachedConfigHash = configHash
|
||||
cachedFrom = from
|
||||
cachedAt = Date.now()
|
||||
|
||||
return { transporter: cachedTransporter, from: cachedFrom }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay helper for throttling bulk email sends.
|
||||
* Prevents overwhelming the SMTP server (Poste.io).
|
||||
*/
|
||||
export function emailDelay(ms = 150): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// Legacy references for backward compat — default sender from env
|
||||
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
|
||||
|
||||
@@ -1688,9 +1716,34 @@ export function getAdvancementNotificationTemplate(
|
||||
toRoundName: string,
|
||||
customMessage?: string,
|
||||
accountUrl?: string,
|
||||
fullCustomBody?: boolean,
|
||||
): EmailTemplate {
|
||||
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
||||
|
||||
const escapedMessage = customMessage
|
||||
? customMessage
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
: null
|
||||
|
||||
// Full custom body mode: only the custom message inside the branded wrapper
|
||||
if (fullCustomBody && escapedMessage) {
|
||||
const content = `
|
||||
${sectionTitle(greeting)}
|
||||
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">${escapedMessage}</div>
|
||||
${accountUrl
|
||||
? ctaButton(accountUrl, 'Create Your Account')
|
||||
: ctaButton('/applicant', 'View Your Dashboard')}
|
||||
`
|
||||
return {
|
||||
subject: `Your project has advanced: "${projectName}"`,
|
||||
html: getEmailWrapper(content),
|
||||
text: `${greeting}\n\n${customMessage}\n\n${accountUrl ? `Create your account: ${getBaseUrl()}${accountUrl}` : `Visit your dashboard: ${getBaseUrl()}/applicant`}\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||||
}
|
||||
}
|
||||
|
||||
const celebrationBanner = `
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||
<tr>
|
||||
@@ -1702,14 +1755,6 @@ export function getAdvancementNotificationTemplate(
|
||||
</table>
|
||||
`
|
||||
|
||||
const escapedMessage = customMessage
|
||||
? customMessage
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
: null
|
||||
|
||||
const content = `
|
||||
${sectionTitle(greeting)}
|
||||
${celebrationBanner}
|
||||
@@ -1757,7 +1802,8 @@ export function getRejectionNotificationTemplate(
|
||||
name: string,
|
||||
projectName: string,
|
||||
roundName: string,
|
||||
customMessage?: string
|
||||
customMessage?: string,
|
||||
fullCustomBody?: boolean,
|
||||
): EmailTemplate {
|
||||
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
||||
|
||||
@@ -1769,6 +1815,22 @@ export function getRejectionNotificationTemplate(
|
||||
.replace(/\n/g, '<br>')
|
||||
: null
|
||||
|
||||
// Full custom body mode: only the custom message inside the branded wrapper
|
||||
if (fullCustomBody && escapedMessage) {
|
||||
const content = `
|
||||
${sectionTitle(greeting)}
|
||||
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">${escapedMessage}</div>
|
||||
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
|
||||
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
||||
</p>
|
||||
`
|
||||
return {
|
||||
subject: `Update on your application: "${projectName}"`,
|
||||
html: getEmailWrapper(content),
|
||||
text: `${greeting}\n\n${customMessage}\n\nThank you for being part of the Monaco Ocean Protection Challenge community.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||||
}
|
||||
}
|
||||
|
||||
const content = `
|
||||
${sectionTitle(greeting)}
|
||||
${paragraph(`Thank you for your participation in <strong>${roundName}</strong> with your project <strong>"${projectName}"</strong>.`)}
|
||||
@@ -2055,13 +2117,15 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
||||
(ctx.metadata?.toRoundName as string) || 'next round',
|
||||
ctx.metadata?.customMessage as string | undefined,
|
||||
ctx.metadata?.accountUrl as string | undefined,
|
||||
ctx.metadata?.fullCustomBody as boolean | undefined,
|
||||
),
|
||||
REJECTION_NOTIFICATION: (ctx) =>
|
||||
getRejectionNotificationTemplate(
|
||||
ctx.name || '',
|
||||
(ctx.metadata?.projectName as string) || 'Your Project',
|
||||
(ctx.metadata?.roundName as string) || 'this round',
|
||||
ctx.metadata?.customMessage as string | undefined
|
||||
ctx.metadata?.customMessage as string | undefined,
|
||||
ctx.metadata?.fullCustomBody as boolean | undefined,
|
||||
),
|
||||
|
||||
AWARD_SELECTION_NOTIFICATION: (ctx) =>
|
||||
|
||||
Reference in New Issue
Block a user