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:
382
src/components/admin/projects/bulk-notification-dialog.tsx
Normal file
382
src/components/admin/projects/bulk-notification-dialog.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Send,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Trophy,
|
||||
Ban,
|
||||
Award,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface BulkNotificationDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationDialogProps) {
|
||||
// Section states
|
||||
const [passedOpen, setPassedOpen] = useState(true)
|
||||
const [rejectedOpen, setRejectedOpen] = useState(false)
|
||||
const [awardOpen, setAwardOpen] = useState(false)
|
||||
|
||||
// Passed section
|
||||
const [passedEnabled, setPassedEnabled] = useState(true)
|
||||
const [passedMessage, setPassedMessage] = useState('')
|
||||
const [passedFullCustom, setPassedFullCustom] = useState(false)
|
||||
|
||||
// Rejected section
|
||||
const [rejectedEnabled, setRejectedEnabled] = useState(false)
|
||||
const [rejectedMessage, setRejectedMessage] = useState('')
|
||||
const [rejectedFullCustom, setRejectedFullCustom] = useState(false)
|
||||
const [rejectedIncludeInvite, setRejectedIncludeInvite] = useState(false)
|
||||
|
||||
// Award section
|
||||
const [selectedAwardId, setSelectedAwardId] = useState<string | null>(null)
|
||||
const [awardMessage, setAwardMessage] = useState('')
|
||||
|
||||
// Global
|
||||
const [skipAlreadySent, setSkipAlreadySent] = useState(true)
|
||||
|
||||
// Loading states
|
||||
const [sendingPassed, setSendingPassed] = useState(false)
|
||||
const [sendingRejected, setSendingRejected] = useState(false)
|
||||
const [sendingAward, setSendingAward] = useState(false)
|
||||
const [sendingAll, setSendingAll] = useState(false)
|
||||
|
||||
const summary = trpc.project.getBulkNotificationSummary.useQuery(undefined, {
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const sendPassed = trpc.project.sendBulkPassedNotifications.useMutation()
|
||||
const sendRejected = trpc.project.sendBulkRejectionNotifications.useMutation()
|
||||
const sendAward = trpc.project.sendBulkAwardNotifications.useMutation()
|
||||
|
||||
const handleSendPassed = async () => {
|
||||
setSendingPassed(true)
|
||||
try {
|
||||
const result = await sendPassed.mutateAsync({
|
||||
customMessage: passedMessage || undefined,
|
||||
fullCustomBody: passedFullCustom,
|
||||
skipAlreadySent,
|
||||
})
|
||||
toast.success(`Advancement: ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped`)
|
||||
summary.refetch()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to send')
|
||||
} finally {
|
||||
setSendingPassed(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendRejected = async () => {
|
||||
setSendingRejected(true)
|
||||
try {
|
||||
const result = await sendRejected.mutateAsync({
|
||||
customMessage: rejectedMessage || undefined,
|
||||
fullCustomBody: rejectedFullCustom,
|
||||
includeInviteLink: rejectedIncludeInvite,
|
||||
skipAlreadySent,
|
||||
})
|
||||
toast.success(`Rejection: ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped`)
|
||||
summary.refetch()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to send')
|
||||
} finally {
|
||||
setSendingRejected(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendAward = async (awardId: string) => {
|
||||
setSendingAward(true)
|
||||
try {
|
||||
const result = await sendAward.mutateAsync({
|
||||
awardId,
|
||||
customMessage: awardMessage || undefined,
|
||||
skipAlreadySent,
|
||||
})
|
||||
toast.success(`Award: ${result.sent} sent, ${result.failed} failed`)
|
||||
summary.refetch()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to send')
|
||||
} finally {
|
||||
setSendingAward(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendAll = async () => {
|
||||
setSendingAll(true)
|
||||
try {
|
||||
if (passedEnabled && totalPassed > 0) {
|
||||
await handleSendPassed()
|
||||
}
|
||||
if (rejectedEnabled && (summary.data?.rejected.count ?? 0) > 0) {
|
||||
await handleSendRejected()
|
||||
}
|
||||
toast.success('All enabled notifications sent')
|
||||
} catch {
|
||||
// Individual handlers already toast errors
|
||||
} finally {
|
||||
setSendingAll(false)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPassed = summary.data?.passed.reduce((sum, g) => sum + g.projectCount, 0) ?? 0
|
||||
const isSending = sendingPassed || sendingRejected || sendingAward || sendingAll
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk Notifications</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send advancement, rejection, and award pool notifications to project teams.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{summary.isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : summary.error ? (
|
||||
<div className="text-destructive text-sm py-4">
|
||||
Failed to load summary: {summary.error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Global settings */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="skip-already-sent"
|
||||
checked={skipAlreadySent}
|
||||
onCheckedChange={setSkipAlreadySent}
|
||||
/>
|
||||
<Label htmlFor="skip-already-sent" className="text-sm">
|
||||
Skip already notified
|
||||
</Label>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{summary.data?.alreadyNotified.advancement ?? 0} advancement + {summary.data?.alreadyNotified.rejection ?? 0} rejection already sent
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PASSED section */}
|
||||
<Collapsible open={passedOpen} onOpenChange={setPassedOpen}>
|
||||
<div className="rounded-lg border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
{passedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
<Trophy className="h-4 w-4 text-green-600" />
|
||||
<span className="font-medium">Passed / Advanced</span>
|
||||
<Badge variant="secondary">{totalPassed} projects</Badge>
|
||||
</div>
|
||||
<Switch
|
||||
checked={passedEnabled}
|
||||
onCheckedChange={setPassedEnabled}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
||||
{summary.data?.passed.map((g) => (
|
||||
<div key={g.roundId} className="text-sm flex items-center gap-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
<span className="text-muted-foreground">{g.roundName}</span>
|
||||
<span className="font-medium">{g.projectCount}</span>
|
||||
<span className="text-xs text-muted-foreground">→ {g.nextRoundName}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label className="text-xs">Custom message (optional)</Label>
|
||||
<Textarea
|
||||
value={passedMessage}
|
||||
onChange={(e) => setPassedMessage(e.target.value)}
|
||||
placeholder="Add a personal note to the advancement email..."
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="passed-full-custom"
|
||||
checked={passedFullCustom}
|
||||
onCheckedChange={setPassedFullCustom}
|
||||
/>
|
||||
<Label htmlFor="passed-full-custom" className="text-xs">
|
||||
Full custom body (replace default template)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSendPassed}
|
||||
disabled={!passedEnabled || totalPassed === 0 || isSending}
|
||||
>
|
||||
{sendingPassed ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Send className="mr-2 h-3.5 w-3.5" />}
|
||||
Send Advancement
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
{/* REJECTED section */}
|
||||
<Collapsible open={rejectedOpen} onOpenChange={setRejectedOpen}>
|
||||
<div className="rounded-lg border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
{rejectedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
<Ban className="h-4 w-4 text-red-600" />
|
||||
<span className="font-medium">Rejected / Filtered Out</span>
|
||||
<Badge variant="destructive">{summary.data?.rejected.count ?? 0} projects</Badge>
|
||||
</div>
|
||||
<Switch
|
||||
checked={rejectedEnabled}
|
||||
onCheckedChange={setRejectedEnabled}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Custom message (optional)</Label>
|
||||
<Textarea
|
||||
value={rejectedMessage}
|
||||
onChange={(e) => setRejectedMessage(e.target.value)}
|
||||
placeholder="Add a personal note to the rejection email..."
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="rejected-full-custom"
|
||||
checked={rejectedFullCustom}
|
||||
onCheckedChange={setRejectedFullCustom}
|
||||
/>
|
||||
<Label htmlFor="rejected-full-custom" className="text-xs">
|
||||
Full custom body (replace default template)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="rejected-include-invite"
|
||||
checked={rejectedIncludeInvite}
|
||||
onCheckedChange={setRejectedIncludeInvite}
|
||||
/>
|
||||
<Label htmlFor="rejected-include-invite" className="text-xs">
|
||||
Include platform invite link for rejected teams
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleSendRejected}
|
||||
disabled={!rejectedEnabled || (summary.data?.rejected.count ?? 0) === 0 || isSending}
|
||||
>
|
||||
{sendingRejected ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Send className="mr-2 h-3.5 w-3.5" />}
|
||||
Send Rejections
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
{/* AWARD POOLS section */}
|
||||
<Collapsible open={awardOpen} onOpenChange={setAwardOpen}>
|
||||
<div className="rounded-lg border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
{awardOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
<Award className="h-4 w-4 text-amber-600" />
|
||||
<span className="font-medium">Award Pools</span>
|
||||
<Badge variant="outline">{summary.data?.awardPools.length ?? 0} awards</Badge>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
||||
{(summary.data?.awardPools ?? []).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No award pools configured.</p>
|
||||
) : (
|
||||
<>
|
||||
{summary.data?.awardPools.map((a) => (
|
||||
<div key={a.awardId} className="flex items-center justify-between rounded border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Award className="h-3.5 w-3.5 text-amber-500" />
|
||||
<span className="text-sm font-medium">{a.awardName}</span>
|
||||
<Badge variant="secondary" className="text-xs">{a.eligibleCount} eligible</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedAwardId(a.awardId)
|
||||
handleSendAward(a.awardId)
|
||||
}}
|
||||
disabled={a.eligibleCount === 0 || isSending}
|
||||
>
|
||||
{sendingAward && selectedAwardId === a.awardId ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Notify
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-2 pt-1">
|
||||
<Label className="text-xs">Custom message for awards (optional)</Label>
|
||||
<Textarea
|
||||
value={awardMessage}
|
||||
onChange={(e) => setAwardMessage(e.target.value)}
|
||||
placeholder="Add a note to the award notification..."
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
{/* Send All button */}
|
||||
<div className="flex justify-end pt-2 border-t">
|
||||
<Button
|
||||
onClick={handleSendAll}
|
||||
disabled={(!passedEnabled && !rejectedEnabled) || isSending}
|
||||
>
|
||||
{sendingAll ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
Send All Enabled
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
97
src/server/services/notification-sender.ts
Normal file
97
src/server/services/notification-sender.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendStyledNotificationEmail, emailDelay } from '@/lib/email'
|
||||
import type { NotificationEmailContext } from '@/lib/email'
|
||||
|
||||
export type NotificationItem = {
|
||||
email: string
|
||||
name: string
|
||||
type: string // ADVANCEMENT_NOTIFICATION, REJECTION_NOTIFICATION, etc.
|
||||
context: NotificationEmailContext
|
||||
projectId?: string
|
||||
userId?: string
|
||||
roundId?: string
|
||||
}
|
||||
|
||||
export type BatchResult = {
|
||||
sent: number
|
||||
failed: number
|
||||
batchId: string
|
||||
errors: Array<{ email: string; error: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notifications in batches with throttling and per-email logging.
|
||||
* Each email is logged to NotificationLog with SENT or FAILED status.
|
||||
*/
|
||||
export async function sendBatchNotifications(
|
||||
items: NotificationItem[],
|
||||
options?: { batchSize?: number; batchDelayMs?: number }
|
||||
): Promise<BatchResult> {
|
||||
const batchId = randomUUID()
|
||||
const batchSize = options?.batchSize ?? 10
|
||||
const batchDelayMs = options?.batchDelayMs ?? 500
|
||||
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
const errors: Array<{ email: string; error: string }> = []
|
||||
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const chunk = items.slice(i, i + batchSize)
|
||||
|
||||
for (const item of chunk) {
|
||||
try {
|
||||
await sendStyledNotificationEmail(
|
||||
item.email,
|
||||
item.name,
|
||||
item.type,
|
||||
item.context,
|
||||
)
|
||||
sent++
|
||||
|
||||
// Log success (fire-and-forget)
|
||||
prisma.notificationLog.create({
|
||||
data: {
|
||||
userId: item.userId || null,
|
||||
channel: 'EMAIL',
|
||||
type: item.type,
|
||||
status: 'SENT',
|
||||
email: item.email,
|
||||
roundId: item.roundId || null,
|
||||
projectId: item.projectId || null,
|
||||
batchId,
|
||||
},
|
||||
}).catch((err) => console.error('[notification-sender] Log write failed:', err))
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||
failed++
|
||||
errors.push({ email: item.email, error: errorMsg })
|
||||
console.error(`[notification-sender] Failed for ${item.email}:`, err)
|
||||
|
||||
// Log failure (fire-and-forget)
|
||||
prisma.notificationLog.create({
|
||||
data: {
|
||||
userId: item.userId || null,
|
||||
channel: 'EMAIL',
|
||||
type: item.type,
|
||||
status: 'FAILED',
|
||||
email: item.email,
|
||||
roundId: item.roundId || null,
|
||||
projectId: item.projectId || null,
|
||||
batchId,
|
||||
errorMsg,
|
||||
},
|
||||
}).catch((logErr) => console.error('[notification-sender] Log write failed:', logErr))
|
||||
}
|
||||
|
||||
await emailDelay()
|
||||
}
|
||||
|
||||
// Delay between chunks to avoid overwhelming SMTP
|
||||
if (i + batchSize < items.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, batchDelayMs))
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, failed, batchId, errors }
|
||||
}
|
||||
Reference in New Issue
Block a user