Add styled notification emails and round-attached notifications
Build and Push Docker Image / build (push) Successful in 8m18s Details

- Add 15+ styled email templates matching existing invite email design
- Wire up notification triggers in all routers (assignment, round, project, mentor, application, onboarding)
- Add test email button for each notification type in admin settings
- Add round-attached notifications: admins can configure which notification to send when projects enter a round
- Fall back to status-based notifications when round has no configured notification

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-04 00:10:51 +01:00
parent 3be6a743ed
commit b0189cad92
13 changed files with 1892 additions and 28 deletions

View File

@ -385,6 +385,9 @@ model Round {
requiredReviews Int @default(3) // Min evaluations per project requiredReviews Int @default(3) // Min evaluations per project
settingsJson Json? @db.JsonB // Grace periods, visibility rules, etc. settingsJson Json? @db.JsonB // Grace periods, visibility rules, etc.
// Notification sent to project team when they enter this round
entryNotificationType String? // e.g., "ADVANCED_SEMIFINAL", "ADVANCED_FINAL"
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -32,8 +32,24 @@ import {
type Criterion, type Criterion,
} from '@/components/forms/evaluation-form-builder' } from '@/components/forms/evaluation-form-builder'
import { RoundTypeSettings } from '@/components/forms/round-type-settings' import { RoundTypeSettings } from '@/components/forms/round-type-settings'
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle } from 'lucide-react' import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell } from 'lucide-react'
import { DateTimePicker } from '@/components/ui/datetime-picker' import { DateTimePicker } from '@/components/ui/datetime-picker'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
// Available notification types for teams entering a round
const TEAM_NOTIFICATION_OPTIONS = [
{ value: '', label: 'No automatic notification', description: 'Teams will not receive a notification when entering this round' },
{ value: 'ADVANCED_SEMIFINAL', label: 'Advanced to Semi-Finals', description: 'Congratulates team for advancing to semi-finals' },
{ value: 'ADVANCED_FINAL', label: 'Selected as Finalist', description: 'Congratulates team for being selected as finalist' },
{ value: 'NOT_SELECTED', label: 'Not Selected', description: 'Informs team they were not selected to continue' },
{ value: 'WINNER_ANNOUNCEMENT', label: 'Winner Announcement', description: 'Announces the team as a winner' },
]
interface PageProps { interface PageProps {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@ -68,6 +84,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
const [formInitialized, setFormInitialized] = useState(false) const [formInitialized, setFormInitialized] = useState(false)
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION') const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({}) const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
const [entryNotificationType, setEntryNotificationType] = useState<string>('')
// Fetch round data - disable refetch on focus to prevent overwriting user's edits // Fetch round data - disable refetch on focus to prevent overwriting user's edits
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery( const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery(
@ -118,9 +135,10 @@ function EditRoundContent({ roundId }: { roundId: string }) {
votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null, votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null,
votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null, votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null,
}) })
// Set round type and settings // Set round type, settings, and notification type
setRoundType((round.roundType as typeof roundType) || 'EVALUATION') setRoundType((round.roundType as typeof roundType) || 'EVALUATION')
setRoundSettings((round.settingsJson as Record<string, unknown>) || {}) setRoundSettings((round.settingsJson as Record<string, unknown>) || {})
setEntryNotificationType(round.entryNotificationType || '')
setFormInitialized(true) setFormInitialized(true)
} }
}, [round, form, formInitialized]) }, [round, form, formInitialized])
@ -139,7 +157,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
}, [evaluationForm, loadingForm, criteriaInitialized]) }, [evaluationForm, loadingForm, criteriaInitialized])
const onSubmit = async (data: UpdateRoundForm) => { const onSubmit = async (data: UpdateRoundForm) => {
// Update round with type and settings // Update round with type, settings, and notification
await updateRound.mutateAsync({ await updateRound.mutateAsync({
id: roundId, id: roundId,
name: data.name, name: data.name,
@ -148,6 +166,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
settingsJson: roundSettings, settingsJson: roundSettings,
votingStartAt: data.votingStartAt ?? null, votingStartAt: data.votingStartAt ?? null,
votingEndAt: data.votingEndAt ?? null, votingEndAt: data.votingEndAt ?? null,
entryNotificationType: entryNotificationType || null,
}) })
// Update evaluation form if criteria changed and no evaluations exist // Update evaluation form if criteria changed and no evaluations exist
@ -334,6 +353,39 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</CardContent> </CardContent>
</Card> </Card>
{/* Team Notification */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Bell className="h-5 w-5" />
Team Notification
</CardTitle>
<CardDescription>
Notification sent to project teams when they enter this round
</CardDescription>
</CardHeader>
<CardContent>
<Select
value={entryNotificationType || 'none'}
onValueChange={(val) => setEntryNotificationType(val === 'none' ? '' : val)}
>
<SelectTrigger>
<SelectValue placeholder="No automatic notification" />
</SelectTrigger>
<SelectContent>
{TEAM_NOTIFICATION_OPTIONS.map((option) => (
<SelectItem key={option.value || 'none'} value={option.value || 'none'}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground mt-2">
When projects advance to this round, the selected notification will be sent to the project team automatically.
</p>
</CardContent>
</Card>
{/* Evaluation Criteria */} {/* Evaluation Criteria */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@ -34,9 +34,18 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { RoundTypeSettings } from '@/components/forms/round-type-settings' import { RoundTypeSettings } from '@/components/forms/round-type-settings'
import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react' import { ArrowLeft, Loader2, AlertCircle, Bell } from 'lucide-react'
import { DateTimePicker } from '@/components/ui/datetime-picker' import { DateTimePicker } from '@/components/ui/datetime-picker'
// Available notification types for teams entering a round
const TEAM_NOTIFICATION_OPTIONS = [
{ value: '', label: 'No automatic notification', description: 'Teams will not receive a notification when entering this round' },
{ value: 'ADVANCED_SEMIFINAL', label: 'Advanced to Semi-Finals', description: 'Congratulates team for advancing to semi-finals' },
{ value: 'ADVANCED_FINAL', label: 'Selected as Finalist', description: 'Congratulates team for being selected as finalist' },
{ value: 'NOT_SELECTED', label: 'Not Selected', description: 'Informs team they were not selected to continue' },
{ value: 'WINNER_ANNOUNCEMENT', label: 'Winner Announcement', description: 'Announces the team as a winner' },
]
const createRoundSchema = z.object({ const createRoundSchema = z.object({
programId: z.string().min(1, 'Please select a program'), programId: z.string().min(1, 'Please select a program'),
name: z.string().min(1, 'Name is required').max(255), name: z.string().min(1, 'Name is required').max(255),
@ -61,6 +70,7 @@ function CreateRoundContent() {
const programIdParam = searchParams.get('program') const programIdParam = searchParams.get('program')
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION') const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({}) const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
const [entryNotificationType, setEntryNotificationType] = useState<string>('')
const utils = trpc.useUtils() const utils = trpc.useUtils()
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery() const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
@ -92,6 +102,7 @@ function CreateRoundContent() {
settingsJson: roundSettings, settingsJson: roundSettings,
votingStartAt: data.votingStartAt ?? undefined, votingStartAt: data.votingStartAt ?? undefined,
votingEndAt: data.votingEndAt ?? undefined, votingEndAt: data.votingEndAt ?? undefined,
entryNotificationType: entryNotificationType || undefined,
}) })
} }
@ -285,6 +296,39 @@ function CreateRoundContent() {
</CardContent> </CardContent>
</Card> </Card>
{/* Team Notification */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Bell className="h-5 w-5" />
Team Notification
</CardTitle>
<CardDescription>
Notification sent to project teams when they enter this round
</CardDescription>
</CardHeader>
<CardContent>
<Select
value={entryNotificationType || 'none'}
onValueChange={(val) => setEntryNotificationType(val === 'none' ? '' : val)}
>
<SelectTrigger>
<SelectValue placeholder="No automatic notification" />
</SelectTrigger>
<SelectContent>
{TEAM_NOTIFICATION_OPTIONS.map((option) => (
<SelectItem key={option.value || 'none'} value={option.value || 'none'}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground mt-2">
When projects advance to this round, the selected notification will be sent to the project team automatically.
</p>
</CardContent>
</Card>
{/* Error */} {/* Error */}
{createRound.error && ( {createRound.error && (
<Card className="border-destructive"> <Card className="border-destructive">

View File

@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Users, Scale, GraduationCap, Eye, Shield } from 'lucide-react' import { Users, Scale, GraduationCap, Eye, Shield, Mail, Loader2 } from 'lucide-react'
// Category icons and labels // Category icons and labels
const CATEGORIES = { const CATEGORIES = {
@ -29,6 +29,7 @@ type NotificationSetting = {
} }
export function NotificationSettingsForm() { export function NotificationSettingsForm() {
const [testingType, setTestingType] = useState<string | null>(null)
const { data: settings, isLoading, refetch } = trpc.notification.getEmailSettings.useQuery() const { data: settings, isLoading, refetch } = trpc.notification.getEmailSettings.useQuery()
const updateMutation = trpc.notification.updateEmailSetting.useMutation({ const updateMutation = trpc.notification.updateEmailSetting.useMutation({
onSuccess: () => { onSuccess: () => {
@ -40,10 +41,34 @@ export function NotificationSettingsForm() {
}, },
}) })
const testMutation = trpc.notification.sendTestEmail.useMutation({
onSuccess: (data) => {
if (data.success) {
toast.success(data.message, {
description: data.hasStyledTemplate
? 'Using styled template'
: 'Using generic template',
})
} else {
toast.error('Failed to send test email', { description: data.message })
}
setTestingType(null)
},
onError: (error) => {
toast.error(`Failed to send: ${error.message}`)
setTestingType(null)
},
})
const handleToggle = (notificationType: string, sendEmail: boolean) => { const handleToggle = (notificationType: string, sendEmail: boolean) => {
updateMutation.mutate({ notificationType, sendEmail }) updateMutation.mutate({ notificationType, sendEmail })
} }
const handleTest = (notificationType: string) => {
setTestingType(notificationType)
testMutation.mutate({ notificationType })
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -112,7 +137,7 @@ export function NotificationSettingsForm() {
key={setting.id} key={setting.id}
className="flex items-center justify-between rounded-lg border p-3" className="flex items-center justify-between rounded-lg border p-3"
> >
<div className="space-y-0.5"> <div className="space-y-0.5 flex-1 min-w-0">
<Label className="text-sm font-medium"> <Label className="text-sm font-medium">
{setting.label} {setting.label}
</Label> </Label>
@ -122,13 +147,30 @@ export function NotificationSettingsForm() {
</p> </p>
)} )}
</div> </div>
<Switch <div className="flex items-center gap-3 ml-4">
checked={setting.sendEmail} <Button
onCheckedChange={(checked) => variant="ghost"
handleToggle(setting.notificationType, checked) size="sm"
} className="h-8 px-2 text-muted-foreground hover:text-foreground"
disabled={updateMutation.isPending} onClick={() => handleTest(setting.notificationType)}
/> disabled={testingType === setting.notificationType}
title="Send test email to yourself"
>
{testingType === setting.notificationType ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Mail className="h-4 w-4" />
)}
<span className="ml-1.5 text-xs">Test</span>
</Button>
<Switch
checked={setting.sendEmail}
onCheckedChange={(checked) =>
handleToggle(setting.notificationType, checked)
}
disabled={updateMutation.isPending}
/>
</div>
</div> </div>
))} ))}
</CardContent> </CardContent>

View File

@ -566,6 +566,985 @@ Together for a healthier ocean.
} }
} }
// =============================================================================
// Notification Email Templates
// =============================================================================
/**
* Context passed to notification email templates
*/
export interface NotificationEmailContext {
name?: string
title: string
message: string
linkUrl?: string
linkLabel?: string
metadata?: Record<string, unknown>
}
/**
* Generate "Advanced to Semi-Finals" email template
*/
function getAdvancedSemifinalTemplate(
name: string,
projectName: string,
programName: string,
nextSteps?: string
): EmailTemplate {
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
const celebrationBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, #059669 0%, #0d9488 100%); border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Exciting News</p>
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">You're a Semi-Finalist!</h2>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${celebrationBanner}
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected to advance to the semi-finals of ${programName}.`)}
${infoBox('Your innovative approach to ocean protection stood out among hundreds of submissions.', 'success')}
${nextSteps ? paragraph(`<strong>Next Steps:</strong> ${nextSteps}`) : paragraph('Our team will be in touch shortly with details about the next phase of the competition.')}
`
return {
subject: `Congratulations! "${projectName}" advances to Semi-Finals`,
html: getEmailWrapper(content),
text: `
${greeting}
Your project "${projectName}" has been selected to advance to the semi-finals of ${programName}.
Your innovative approach to ocean protection stood out among hundreds of submissions.
${nextSteps || 'Our team will be in touch shortly with details about the next phase of the competition.'}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Selected as Finalist" email template
*/
function getAdvancedFinalTemplate(
name: string,
projectName: string,
programName: string,
nextSteps?: string
): EmailTemplate {
const greeting = name ? `Incredible news, ${name}!` : 'Incredible news!'
const celebrationBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, ${BRAND.darkBlue} 0%, #0891b2 100%); border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Outstanding Achievement</p>
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">You're a Finalist!</h2>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${celebrationBanner}
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected as a <strong>Finalist</strong> in ${programName}.`)}
${infoBox('You are now among the top projects competing for the grand prize!', 'success')}
${nextSteps ? paragraph(`<strong>What Happens Next:</strong> ${nextSteps}`) : paragraph('Prepare for the final stage of the competition. Our team will contact you with details about the finalist presentations and judging.')}
`
return {
subject: `You're a Finalist! "${projectName}" selected for finals`,
html: getEmailWrapper(content),
text: `
${greeting}
Your project "${projectName}" has been selected as a Finalist in ${programName}.
You are now among the top projects competing for the grand prize!
${nextSteps || 'Prepare for the final stage of the competition. Our team will contact you with details about the finalist presentations and judging.'}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Mentor Assigned" email template (for team)
*/
function getMentorAssignedTemplate(
name: string,
projectName: string,
mentorName: string,
mentorBio?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const mentorCard = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your Mentor</p>
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${mentorName}</p>
${mentorBio ? `<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px; line-height: 1.5;">${mentorBio}</p>` : ''}
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${paragraph(`Great news! A mentor has been assigned to support your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong>.`)}
${mentorCard}
${paragraph('Your mentor will provide guidance, feedback, and support as you develop your ocean protection initiative. They will reach out to you shortly to introduce themselves and schedule your first meeting.')}
${infoBox('Mentorship is a valuable opportunity - make the most of their expertise!', 'info')}
`
return {
subject: `A mentor has been assigned to "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
Great news! A mentor has been assigned to support your project "${projectName}".
Your Mentor: ${mentorName}
${mentorBio || ''}
Your mentor will provide guidance, feedback, and support as you develop your ocean protection initiative. They will reach out to you shortly to introduce themselves and schedule your first meeting.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Not Selected" email template
*/
function getNotSelectedTemplate(
name: string,
projectName: string,
roundName: string,
feedbackUrl?: string,
encouragement?: string
): EmailTemplate {
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
const content = `
${sectionTitle(greeting)}
${paragraph(`Thank you for participating in ${roundName} with your project <strong>"${projectName}"</strong>.`)}
${paragraph('After careful consideration by our jury, we regret to inform you that your project was not selected to advance to the next round.')}
${infoBox('This decision was incredibly difficult given the high quality of submissions we received this year.', 'info')}
${feedbackUrl ? ctaButton(feedbackUrl, 'View Jury Feedback') : ''}
${paragraph(encouragement || 'We encourage you to continue developing your ocean protection initiative and to apply again in future editions. Your commitment to protecting our oceans is valuable and appreciated.')}
<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}
Thank you for participating in ${roundName} with your project "${projectName}".
After careful consideration by our jury, we regret to inform you that your project was not selected to advance to the next round.
This decision was incredibly difficult given the high quality of submissions we received this year.
${feedbackUrl ? `View jury feedback: ${feedbackUrl}` : ''}
${encouragement || 'We encourage you to continue developing your ocean protection initiative and to apply again in future editions.'}
Thank you for being part of the Monaco Ocean Protection Challenge community.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Winner Announcement" email template
*/
function getWinnerAnnouncementTemplate(
name: string,
projectName: string,
awardName: string,
prizeDetails?: string
): EmailTemplate {
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
const trophyBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%); border-radius: 12px; padding: 32px; text-align: center;">
<p style="color: #ffffff; font-size: 48px; margin: 0 0 12px 0;">&#127942;</p>
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Winner</p>
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${awardName}</h2>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${trophyBanner}
${paragraph(`We are thrilled to announce that your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected as the winner of the <strong>${awardName}</strong>!`)}
${infoBox('Your outstanding work in ocean protection has made a lasting impression on our jury.', 'success')}
${prizeDetails ? paragraph(`<strong>Your Prize:</strong> ${prizeDetails}`) : ''}
${paragraph('Our team will be in touch shortly with details about the award ceremony and next steps.')}
`
return {
subject: `You Won! "${projectName}" wins ${awardName}`,
html: getEmailWrapper(content),
text: `
${greeting}
We are thrilled to announce that your project "${projectName}" has been selected as the winner of the ${awardName}!
Your outstanding work in ocean protection has made a lasting impression on our jury.
${prizeDetails ? `Your Prize: ${prizeDetails}` : ''}
Our team will be in touch shortly with details about the award ceremony and next steps.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Assigned to Project" email template (for jury)
*/
function getAssignedToProjectTemplate(
name: string,
projectName: string,
roundName: string,
deadline?: string,
assignmentsUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const projectCard = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Assignment</p>
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${projectName}</p>
</td>
</tr>
</table>
`
const deadlineBox = deadline ? `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
</td>
</tr>
</table>
` : ''
const content = `
${sectionTitle(greeting)}
${paragraph(`You have been assigned a new project to evaluate for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${projectCard}
${deadlineBox}
${paragraph('Please review the project materials and submit your evaluation before the deadline.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignment') : ''}
`
return {
subject: `New Assignment: "${projectName}" - ${roundName}`,
html: getEmailWrapper(content),
text: `
${greeting}
You have been assigned a new project to evaluate for ${roundName}.
Project: ${projectName}
${deadline ? `Deadline: ${deadline}` : ''}
Please review the project materials and submit your evaluation before the deadline.
${assignmentsUrl ? `View assignment: ${assignmentsUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Batch Assigned" email template (for jury)
*/
function getBatchAssignedTemplate(
name: string,
projectCount: number,
roundName: string,
deadline?: string,
assignmentsUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const deadlineBox = deadline ? `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
</td>
</tr>
</table>
` : ''
const content = `
${sectionTitle(greeting)}
${paragraph(`You have been assigned projects to evaluate for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${statCard('Projects Assigned', projectCount)}
${deadlineBox}
${paragraph('Please review each project and submit your evaluations before the deadline. Your expert assessment is crucial to identifying the most promising ocean protection initiatives.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View All Assignments') : ''}
`
return {
subject: `${projectCount} Projects Assigned - ${roundName}`,
html: getEmailWrapper(content),
text: `
${greeting}
You have been assigned ${projectCount} project${projectCount !== 1 ? 's' : ''} to evaluate for ${roundName}.
${deadline ? `Deadline: ${deadline}` : ''}
Please review each project and submit your evaluations before the deadline.
${assignmentsUrl ? `View assignments: ${assignmentsUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Round Now Open" email template (for jury)
*/
function getRoundNowOpenTemplate(
name: string,
roundName: string,
projectCount: number,
deadline?: string,
assignmentsUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const openBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, ${BRAND.teal} 0%, #0891b2 100%); border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Evaluation Round</p>
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${roundName} is Now Open</h2>
</td>
</tr>
</table>
`
const deadlineBox = deadline ? `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
</td>
</tr>
</table>
` : ''
const content = `
${sectionTitle(greeting)}
${openBanner}
${statCard('Projects to Evaluate', projectCount)}
${deadlineBox}
${paragraph('The evaluation round is now open. Please log in to the MOPC Portal to begin reviewing your assigned projects.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Start Evaluating') : ''}
`
return {
subject: `${roundName} is Now Open - ${projectCount} Projects Await`,
html: getEmailWrapper(content),
text: `
${greeting}
${roundName} is now open for evaluation.
You have ${projectCount} project${projectCount !== 1 ? 's' : ''} to evaluate.
${deadline ? `Deadline: ${deadline}` : ''}
Please log in to the MOPC Portal to begin reviewing your assigned projects.
${assignmentsUrl ? `Start evaluating: ${assignmentsUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "24 Hour Reminder" email template (for jury)
*/
function getReminder24HTemplate(
name: string,
pendingCount: number,
roundName: string,
deadline: string,
assignmentsUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const urgentBox = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="color: #92400e; margin: 0; font-size: 14px; font-weight: 600;">&#9888; 24 Hours Remaining</p>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${urgentBox}
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 24 hours.`)}
${statCard('Pending Evaluations', pendingCount)}
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
${paragraph('Please complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
`
return {
subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 24 hours`,
html: getEmailWrapper(content),
text: `
${greeting}
This is a reminder that ${roundName} closes in 24 hours.
You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''}.
Deadline: ${deadline}
Please complete your remaining evaluations before the deadline.
${assignmentsUrl ? `Complete evaluations: ${assignmentsUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "1 Hour Reminder" email template (for jury)
*/
function getReminder1HTemplate(
name: string,
pendingCount: number,
roundName: string,
deadline: string,
assignmentsUrl?: string
): EmailTemplate {
const greeting = name ? `${name},` : 'Attention,'
const urgentBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, ${BRAND.red} 0%, #dc2626 100%); border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Urgent</p>
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">1 Hour Remaining</h2>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${urgentBanner}
${paragraph(`<strong style="color: ${BRAND.red};">${roundName}</strong> closes in <strong>1 hour</strong>.`)}
${statCard('Evaluations Still Pending', pendingCount)}
${paragraph('Please submit your remaining evaluations immediately to ensure they are counted.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Submit Now') : ''}
`
return {
subject: `URGENT: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 1 hour`,
html: getEmailWrapper(content),
text: `
URGENT
${roundName} closes in 1 hour!
You have ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} still pending.
Please submit your remaining evaluations immediately.
${assignmentsUrl ? `Submit now: ${assignmentsUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Award Voting Open" email template (for jury)
*/
function getAwardVotingOpenTemplate(
name: string,
awardName: string,
finalistCount: number,
deadline?: string,
votingUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const awardBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: #ffffff; font-size: 36px; margin: 0 0 8px 0;">&#127942;</p>
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Special Award</p>
<h2 style="color: #ffffff; margin: 0; font-size: 22px; font-weight: 700;">${awardName}</h2>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${awardBanner}
${paragraph(`Voting is now open for the <strong style="color: ${BRAND.darkBlue};">${awardName}</strong>.`)}
${statCard('Finalists', finalistCount)}
${deadline ? infoBox(`<strong>Voting closes:</strong> ${deadline}`, 'warning') : ''}
${paragraph('Please review the finalist projects and cast your vote for the most deserving recipient.')}
${votingUrl ? ctaButton(votingUrl, 'Cast Your Vote') : ''}
`
return {
subject: `Vote Now: ${awardName}`,
html: getEmailWrapper(content),
text: `
${greeting}
Voting is now open for the ${awardName}.
${finalistCount} finalists are competing for this award.
${deadline ? `Voting closes: ${deadline}` : ''}
Please review the finalist projects and cast your vote.
${votingUrl ? `Cast your vote: ${votingUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Mentee Assigned" email template (for mentor)
*/
function getMenteeAssignedTemplate(
name: string,
projectName: string,
teamLeadName: string,
teamLeadEmail?: string,
projectUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const projectCard = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your New Mentee</p>
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${projectName}</p>
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
<strong>Team Lead:</strong> ${teamLeadName}${teamLeadEmail ? ` (${teamLeadEmail})` : ''}
</p>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${paragraph('You have been assigned as a mentor to a new project in the Monaco Ocean Protection Challenge.')}
${projectCard}
${paragraph('As a mentor, you play a crucial role in guiding this team toward success. Please reach out to introduce yourself and schedule your first meeting.')}
${infoBox('Your expertise and guidance can make a significant impact on their ocean protection initiative.', 'info')}
${projectUrl ? ctaButton(projectUrl, 'View Project Details') : ''}
`
return {
subject: `New Mentee Assignment: "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
You have been assigned as a mentor to a new project in the Monaco Ocean Protection Challenge.
Project: ${projectName}
Team Lead: ${teamLeadName}${teamLeadEmail ? ` (${teamLeadEmail})` : ''}
Please reach out to introduce yourself and schedule your first meeting.
${projectUrl ? `View project: ${projectUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Mentee Advanced" email template (for mentor)
*/
function getMenteeAdvancedTemplate(
name: string,
projectName: string,
roundName: string,
nextRoundName?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const content = `
${sectionTitle(greeting)}
${infoBox('Great news about your mentee!', 'success')}
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has advanced to the next stage!`)}
${statCard('Advanced From', roundName)}
${nextRoundName ? paragraph(`They will now compete in <strong>${nextRoundName}</strong>.`) : ''}
${paragraph('Your guidance is making a difference. Continue supporting the team as they progress in the competition.')}
`
return {
subject: `Your Mentee Advanced: "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
Great news! Your mentee project "${projectName}" has advanced to the next stage.
Advanced from: ${roundName}
${nextRoundName ? `Now competing in: ${nextRoundName}` : ''}
Your guidance is making a difference. Continue supporting the team as they progress.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Mentee Won" email template (for mentor)
*/
function getMenteeWonTemplate(
name: string,
projectName: string,
awardName: string
): EmailTemplate {
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
const trophyBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%); border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: #ffffff; font-size: 48px; margin: 0 0 12px 0;">&#127942;</p>
<p style="color: #ffffff; margin: 0; font-size: 16px; font-weight: 600;">Your Mentee Won!</p>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${trophyBanner}
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has won the <strong>${awardName}</strong>!`)}
${infoBox('Your mentorship played a vital role in their success.', 'success')}
${paragraph('Thank you for your dedication and support. The impact of your guidance extends beyond this competition.')}
`
return {
subject: `Your Mentee Won: "${projectName}" - ${awardName}`,
html: getEmailWrapper(content),
text: `
${greeting}
Your mentee project "${projectName}" has won the ${awardName}!
Your mentorship played a vital role in their success.
Thank you for your dedication and support.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "New Application" email template (for admins)
*/
function getNewApplicationTemplate(
projectName: string,
applicantName: string,
applicantEmail: string,
programName: string,
reviewUrl?: string
): EmailTemplate {
const applicationCard = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Application</p>
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 18px; font-weight: 700;">${projectName}</p>
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
<strong>Applicant:</strong> ${applicantName} (${applicantEmail})
</p>
</td>
</tr>
</table>
`
const content = `
${sectionTitle('New Application Received')}
${paragraph(`A new application has been submitted to <strong style="color: ${BRAND.darkBlue};">${programName}</strong>.`)}
${applicationCard}
${reviewUrl ? ctaButton(reviewUrl, 'Review Application') : ''}
`
return {
subject: `New Application: "${projectName}"`,
html: getEmailWrapper(content),
text: `
New Application Received
A new application has been submitted to ${programName}.
Project: ${projectName}
Applicant: ${applicantName} (${applicantEmail})
${reviewUrl ? `Review application: ${reviewUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Template registry mapping notification types to template generators
*/
type TemplateGenerator = (context: NotificationEmailContext) => EmailTemplate
export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
// Team/Applicant templates
ADVANCED_SEMIFINAL: (ctx) =>
getAdvancedSemifinalTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.programName as string) || 'MOPC',
ctx.metadata?.nextSteps as string | undefined
),
ADVANCED_FINAL: (ctx) =>
getAdvancedFinalTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.programName as string) || 'MOPC',
ctx.metadata?.nextSteps as string | undefined
),
MENTOR_ASSIGNED: (ctx) =>
getMentorAssignedTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.mentorName as string) || 'Your Mentor',
ctx.metadata?.mentorBio as string | undefined
),
NOT_SELECTED: (ctx) =>
getNotSelectedTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.roundName as string) || 'this round',
ctx.linkUrl,
ctx.metadata?.encouragement as string | undefined
),
WINNER_ANNOUNCEMENT: (ctx) =>
getWinnerAnnouncementTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.awardName as string) || 'the Award',
ctx.metadata?.prizeDetails as string | undefined
),
// Jury templates
ASSIGNED_TO_PROJECT: (ctx) =>
getAssignedToProjectTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Project',
(ctx.metadata?.roundName as string) || 'this round',
ctx.metadata?.deadline as string | undefined,
ctx.linkUrl
),
BATCH_ASSIGNED: (ctx) =>
getBatchAssignedTemplate(
ctx.name || '',
(ctx.metadata?.projectCount as number) || 1,
(ctx.metadata?.roundName as string) || 'this round',
ctx.metadata?.deadline as string | undefined,
ctx.linkUrl
),
ROUND_NOW_OPEN: (ctx) =>
getRoundNowOpenTemplate(
ctx.name || '',
(ctx.metadata?.roundName as string) || 'Evaluation Round',
(ctx.metadata?.projectCount as number) || 0,
ctx.metadata?.deadline as string | undefined,
ctx.linkUrl
),
REMINDER_24H: (ctx) =>
getReminder24HTemplate(
ctx.name || '',
(ctx.metadata?.pendingCount as number) || 0,
(ctx.metadata?.roundName as string) || 'this round',
(ctx.metadata?.deadline as string) || 'Soon',
ctx.linkUrl
),
REMINDER_1H: (ctx) =>
getReminder1HTemplate(
ctx.name || '',
(ctx.metadata?.pendingCount as number) || 0,
(ctx.metadata?.roundName as string) || 'this round',
(ctx.metadata?.deadline as string) || 'Very Soon',
ctx.linkUrl
),
AWARD_VOTING_OPEN: (ctx) =>
getAwardVotingOpenTemplate(
ctx.name || '',
(ctx.metadata?.awardName as string) || 'Special Award',
(ctx.metadata?.finalistCount as number) || 0,
ctx.metadata?.deadline as string | undefined,
ctx.linkUrl
),
// Mentor templates
MENTEE_ASSIGNED: (ctx) =>
getMenteeAssignedTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Project',
(ctx.metadata?.teamLeadName as string) || 'Team Lead',
ctx.metadata?.teamLeadEmail as string | undefined,
ctx.linkUrl
),
MENTEE_ADVANCED: (ctx) =>
getMenteeAdvancedTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Project',
(ctx.metadata?.roundName as string) || 'this round',
ctx.metadata?.nextRoundName as string | undefined
),
MENTEE_WON: (ctx) =>
getMenteeWonTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Project',
(ctx.metadata?.awardName as string) || 'Award'
),
// Admin templates
NEW_APPLICATION: (ctx) =>
getNewApplicationTemplate(
(ctx.metadata?.projectName as string) || 'New Project',
(ctx.metadata?.applicantName as string) || 'Applicant',
(ctx.metadata?.applicantEmail as string) || '',
(ctx.metadata?.programName as string) || 'MOPC',
ctx.linkUrl
),
}
/**
* Send styled notification email using the appropriate template
*/
export async function sendStyledNotificationEmail(
email: string,
name: string,
type: string,
context: NotificationEmailContext,
subjectOverride?: string
): Promise<void> {
const templateGenerator = NOTIFICATION_EMAIL_TEMPLATES[type]
let template: EmailTemplate
if (templateGenerator) {
// Use styled template
template = templateGenerator({ ...context, name })
// Apply subject override if provided
if (subjectOverride) {
template.subject = subjectOverride
}
} else {
// Fall back to generic template
template = getNotificationEmailTemplate(
name,
subjectOverride || context.title,
context.message,
context.linkUrl
)
}
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
// ============================================================================= // =============================================================================
// Email Sending Functions // Email Sending Functions
// ============================================================================= // =============================================================================

View File

@ -2,6 +2,11 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, publicProcedure } from '../trpc' import { router, publicProcedure } from '../trpc'
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client' import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
import {
createNotification,
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
// Zod schemas for the application form // Zod schemas for the application form
const teamMemberSchema = z.object({ const teamMemberSchema = z.object({
@ -308,6 +313,35 @@ export const applicationRouter = router({
}, },
}) })
// Notify applicant of successful submission
await createNotification({
userId: user.id,
type: NotificationTypes.APPLICATION_SUBMITTED,
title: 'Application Received',
message: `Your application for "${data.projectName}" has been successfully submitted to ${round.program.name}.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Application',
metadata: {
projectName: data.projectName,
programName: round.program.name,
},
})
// Notify admins of new application
await notifyAdmins({
type: NotificationTypes.NEW_APPLICATION,
title: 'New Application',
message: `New application received: "${data.projectName}" from ${data.contactName}.`,
linkUrl: `/admin/projects/${project.id}`,
linkLabel: 'Review Application',
metadata: {
projectName: data.projectName,
applicantName: data.contactName,
applicantEmail: data.contactEmail,
programName: round.program.name,
},
})
return { return {
success: true, success: true,
projectId: project.id, projectId: project.id,

View File

@ -7,6 +7,11 @@ import {
generateFallbackAssignments, generateFallbackAssignments,
} from '../services/ai-assignment' } from '../services/ai-assignment'
import { isOpenAIConfigured } from '@/lib/openai' import { isOpenAIConfigured } from '@/lib/openai'
import {
createNotification,
createBulkNotifications,
NotificationTypes,
} from '../services/in-app-notification'
export const assignmentRouter = router({ export const assignmentRouter = router({
/** /**
@ -193,6 +198,44 @@ export const assignmentRouter = router({
}, },
}) })
// Send notification to the assigned jury member
const [project, round] = await Promise.all([
ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { title: true },
}),
ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, votingEndAt: true },
}),
])
if (project && round) {
const deadline = round.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
await createNotification({
userId: input.userId,
type: NotificationTypes.ASSIGNED_TO_PROJECT,
title: 'New Project Assignment',
message: `You have been assigned to evaluate "${project.title}" for ${round.name}.`,
linkUrl: `/jury/assignments`,
linkLabel: 'View Assignment',
metadata: {
projectName: project.title,
roundName: round.name,
deadline,
assignmentId: assignment.id,
},
})
}
return assignment return assignment
}), }),
@ -233,6 +276,51 @@ export const assignmentRouter = router({
}, },
}) })
// Send notifications to assigned jury members (grouped by user)
if (result.count > 0 && input.assignments.length > 0) {
// Group assignments by user to get counts
const userAssignmentCounts = input.assignments.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
return acc
},
{} as Record<string, number>
)
// Get round info for deadline
const roundId = input.assignments[0].roundId
const round = await ctx.prisma.round.findUnique({
where: { id: roundId },
select: { name: true, votingEndAt: true },
})
const deadline = round?.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
// Send batch notification to each user
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({
userId,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
linkUrl: `/jury/assignments`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
roundName: round?.name,
deadline,
},
})
}
}
return { created: result.count } return { created: result.count }
}), }),
@ -602,6 +690,47 @@ export const assignmentRouter = router({
}, },
}) })
// Send notifications to assigned jury members
if (created.count > 0) {
const userAssignmentCounts = input.assignments.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
return acc
},
{} as Record<string, number>
)
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, votingEndAt: true },
})
const deadline = round?.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({
userId,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
linkUrl: `/jury/assignments`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
roundName: round?.name,
deadline,
},
})
}
}
return { created: created.count } return { created: created.count }
}), }),
@ -649,6 +778,47 @@ export const assignmentRouter = router({
}, },
}) })
// Send notifications to assigned jury members
if (created.count > 0) {
const userAssignmentCounts = input.assignments.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
return acc
},
{} as Record<string, number>
)
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, votingEndAt: true },
})
const deadline = round?.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({
userId,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
linkUrl: `/jury/assignments`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
roundName: round?.name,
deadline,
},
})
}
}
return { created: created.count } return { created: created.count }
}), }),
}) })

View File

@ -6,6 +6,11 @@ import {
getAIMentorSuggestions, getAIMentorSuggestions,
getRoundRobinMentor, getRoundRobinMentor,
} from '../services/mentor-matching' } from '../services/mentor-matching'
import {
createNotification,
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
export const mentorRouter = router({ export const mentorRouter = router({
/** /**
@ -160,6 +165,42 @@ export const mentorRouter = router({
}, },
}) })
// Get team lead info for mentor notification
const teamLead = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, role: 'LEAD' },
include: { user: { select: { name: true, email: true } } },
})
// Notify mentor of new mentee
await createNotification({
userId: input.mentorId,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${assignment.project.title}".`,
linkUrl: `/mentor/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
})
// Notify project team of mentor assignment
await notifyProjectTeam(input.projectId, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
})
return assignment return assignment
}), }),
@ -269,6 +310,42 @@ export const mentorRouter = router({
}, },
}) })
// Get team lead info for mentor notification
const teamLead = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, role: 'LEAD' },
include: { user: { select: { name: true, email: true } } },
})
// Notify mentor of new mentee
await createNotification({
userId: mentorId,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${assignment.project.title}".`,
linkUrl: `/mentor/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
})
// Notify project team of mentor assignment
await notifyProjectTeam(input.projectId, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
})
return assignment return assignment
}), }),
@ -378,7 +455,7 @@ export const mentorRouter = router({
} }
if (mentorId) { if (mentorId) {
await ctx.prisma.mentorAssignment.create({ const assignment = await ctx.prisma.mentorAssignment.create({
data: { data: {
projectId: project.id, projectId: project.id,
mentorId, mentorId,
@ -388,7 +465,48 @@ export const mentorRouter = router({
expertiseMatchScore, expertiseMatchScore,
aiReasoning, aiReasoning,
}, },
include: {
mentor: { select: { name: true } },
project: { select: { title: true } },
},
}) })
// Get team lead info
const teamLead = await ctx.prisma.teamMember.findFirst({
where: { projectId: project.id, role: 'LEAD' },
include: { user: { select: { name: true, email: true } } },
})
// Notify mentor
await createNotification({
userId: mentorId,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${assignment.project.title}".`,
linkUrl: `/mentor/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
})
// Notify project team
await notifyProjectTeam(project.id, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
})
assigned++ assigned++
} else { } else {
failed++ failed++

View File

@ -15,6 +15,7 @@ import {
NotificationIcons, NotificationIcons,
NotificationPriorities, NotificationPriorities,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { sendStyledNotificationEmail, NOTIFICATION_EMAIL_TEMPLATES } from '@/lib/email'
export const notificationRouter = router({ export const notificationRouter = router({
/** /**
@ -218,4 +219,146 @@ export const notificationRouter = router({
byPriority: byPriority.map((p) => ({ priority: p.priority, count: p._count })), byPriority: byPriority.map((p) => ({ priority: p.priority, count: p._count })),
} }
}), }),
/**
* Send a test notification email to the current admin
*/
sendTestEmail: adminProcedure
.input(z.object({ notificationType: z.string() }))
.mutation(async ({ ctx, input }) => {
const { notificationType } = input
// Check if this notification type has a styled template
const hasStyledTemplate = notificationType in NOTIFICATION_EMAIL_TEMPLATES
// Get setting for label
const setting = await ctx.prisma.notificationEmailSetting.findUnique({
where: { notificationType },
})
// Sample data for test emails based on category
const sampleData: Record<string, Record<string, unknown>> = {
// Team notifications
ADVANCED_SEMIFINAL: {
projectName: 'Ocean Cleanup Initiative',
programName: 'Monaco Ocean Protection Challenge 2026',
nextSteps: 'Prepare your presentation for the semi-final round.',
},
ADVANCED_FINAL: {
projectName: 'Ocean Cleanup Initiative',
programName: 'Monaco Ocean Protection Challenge 2026',
nextSteps: 'Get ready for the final presentation in Monaco.',
},
MENTOR_ASSIGNED: {
projectName: 'Ocean Cleanup Initiative',
mentorName: 'Dr. Marine Expert',
mentorBio: 'Expert in marine conservation with 20 years of experience.',
},
NOT_SELECTED: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
},
WINNER_ANNOUNCEMENT: {
projectName: 'Ocean Cleanup Initiative',
awardName: 'Grand Prize',
prizeDetails: '€50,000 and mentorship program',
},
// Jury notifications
ASSIGNED_TO_PROJECT: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
deadline: 'Friday, March 15, 2026',
},
BATCH_ASSIGNED: {
projectCount: 5,
roundName: 'Semi-Final Round',
deadline: 'Friday, March 15, 2026',
},
ROUND_NOW_OPEN: {
roundName: 'Semi-Final Round',
projectCount: 12,
deadline: 'Friday, March 15, 2026',
},
REMINDER_24H: {
pendingCount: 3,
roundName: 'Semi-Final Round',
deadline: 'Tomorrow at 5:00 PM',
},
REMINDER_1H: {
pendingCount: 2,
roundName: 'Semi-Final Round',
deadline: 'Today at 5:00 PM',
},
AWARD_VOTING_OPEN: {
awardName: 'Innovation Award',
finalistCount: 6,
deadline: 'Friday, March 15, 2026',
},
// Mentor notifications
MENTEE_ASSIGNED: {
projectName: 'Ocean Cleanup Initiative',
teamLeadName: 'John Smith',
teamLeadEmail: 'john@example.com',
},
MENTEE_ADVANCED: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
nextRoundName: 'Final Round',
},
MENTEE_WON: {
projectName: 'Ocean Cleanup Initiative',
awardName: 'Innovation Award',
},
// Admin notifications
NEW_APPLICATION: {
projectName: 'New Ocean Project',
applicantName: 'Jane Doe',
applicantEmail: 'jane@example.com',
programName: 'Monaco Ocean Protection Challenge 2026',
},
FILTERING_COMPLETE: {
roundName: 'Initial Review',
passedCount: 45,
flaggedCount: 12,
filteredCount: 8,
},
FILTERING_FAILED: {
roundName: 'Initial Review',
error: 'Connection timeout',
},
}
const metadata = sampleData[notificationType] || {}
const label = setting?.label || notificationType
try {
await sendStyledNotificationEmail(
ctx.user.email,
ctx.user.name || 'Admin',
notificationType,
{
title: `[TEST] ${label}`,
message: `This is a test email for the "${label}" notification type.`,
linkUrl: `${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/admin/settings`,
linkLabel: 'Back to Settings',
metadata,
}
)
return {
success: true,
message: `Test email sent to ${ctx.user.email}`,
hasStyledTemplate,
}
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to send test email',
hasStyledTemplate,
}
}
}),
}) })

View File

@ -4,6 +4,11 @@ import { Prisma } from '@prisma/client'
import { router, publicProcedure } from '../trpc' import { router, publicProcedure } from '../trpc'
import { sendApplicationConfirmationEmail, sendTeamMemberInviteEmail } from '@/lib/email' import { sendApplicationConfirmationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import {
createNotification,
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
// Team member input for submission // Team member input for submission
const teamMemberInputSchema = z.object({ const teamMemberInputSchema = z.object({
@ -389,6 +394,36 @@ export const onboardingRouter = router({
}, },
}) })
// In-app notification for applicant
const programName = form.program?.name || form.round?.name || 'the program'
await createNotification({
userId: contactUser.id,
type: NotificationTypes.APPLICATION_SUBMITTED,
title: 'Application Received',
message: `Your application for "${input.projectName}" has been successfully submitted.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Application',
metadata: {
projectName: input.projectName,
programName,
},
})
// Notify admins of new application
await notifyAdmins({
type: NotificationTypes.NEW_APPLICATION,
title: 'New Application',
message: `New application received: "${input.projectName}" from ${input.contactName}.`,
linkUrl: `/admin/projects/${project.id}`,
linkLabel: 'Review Application',
metadata: {
projectName: input.projectName,
applicantName: input.contactName,
applicantEmail: input.contactEmail,
programName,
},
})
return { return {
success: true, success: true,
projectId: project.id, projectId: project.id,

View File

@ -3,6 +3,10 @@ import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url' import { getUserAvatarUrl } from '../utils/avatar-url'
import {
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
export const projectRouter = router({ export const projectRouter = router({
/** /**
@ -397,6 +401,90 @@ export const projectRouter = router({
where: { projectId: id, roundId }, where: { projectId: id, roundId },
data: { status }, data: { status },
}) })
// Get round details including configured notification type
const round = await ctx.prisma.round.findUnique({
where: { id: roundId },
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
})
// Helper to get notification title based on type
const getNotificationTitle = (type: string): string => {
const titles: Record<string, string> = {
ADVANCED_SEMIFINAL: "Congratulations! You're a Semi-Finalist",
ADVANCED_FINAL: "Amazing News! You're a Finalist",
NOT_SELECTED: 'Application Status Update',
WINNER_ANNOUNCEMENT: 'Congratulations! You Won!',
}
return titles[type] || 'Project Update'
}
// Helper to get notification message based on type
const getNotificationMessage = (type: string, projectName: string): string => {
const messages: Record<string, (name: string) => string> = {
ADVANCED_SEMIFINAL: (name) => `Your project "${name}" has advanced to the semi-finals!`,
ADVANCED_FINAL: (name) => `Your project "${name}" has been selected as a finalist!`,
NOT_SELECTED: (name) => `We regret to inform you that "${name}" was not selected for the next round.`,
WINNER_ANNOUNCEMENT: (name) => `Your project "${name}" has been selected as a winner!`,
}
return messages[type]?.(projectName) || `Update regarding your project "${projectName}".`
}
// Use round's configured notification type, or fall back to status-based defaults
if (round?.entryNotificationType) {
await notifyProjectTeam(id, {
type: round.entryNotificationType,
title: getNotificationTitle(round.entryNotificationType),
message: getNotificationMessage(round.entryNotificationType, project.title),
linkUrl: `/team/projects/${id}`,
linkLabel: 'View Project',
priority: round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round.name,
programName: round.program?.name,
},
})
} else {
// Fall back to hardcoded status-based notifications
const notificationConfig: Record<
string,
{ type: string; title: string; message: string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
title: "Congratulations! You're a Semi-Finalist",
message: `Your project "${project.title}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
title: "Amazing News! You're a Finalist",
message: `Your project "${project.title}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
title: 'Application Status Update',
message: `We regret to inform you that "${project.title}" was not selected for the next round.`,
},
}
const config = notificationConfig[status]
if (config) {
await notifyProjectTeam(id, {
type: config.type,
title: config.title,
message: config.message,
linkUrl: `/team/projects/${id}`,
linkLabel: 'View Project',
priority: status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round?.name,
programName: round?.program?.name,
},
})
}
}
} }
// Audit log // Audit log
@ -590,6 +678,106 @@ export const projectRouter = router({
}, },
}) })
// Get round details including configured notification type
const [projects, round] = await Promise.all([
input.ids.length > 0
? ctx.prisma.project.findMany({
where: { id: { in: input.ids } },
select: { id: true, title: true },
})
: Promise.resolve([]),
ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
}),
])
// Helper to get notification title based on type
const getNotificationTitle = (type: string): string => {
const titles: Record<string, string> = {
ADVANCED_SEMIFINAL: "Congratulations! You're a Semi-Finalist",
ADVANCED_FINAL: "Amazing News! You're a Finalist",
NOT_SELECTED: 'Application Status Update',
WINNER_ANNOUNCEMENT: 'Congratulations! You Won!',
}
return titles[type] || 'Project Update'
}
// Helper to get notification message based on type
const getNotificationMessage = (type: string, projectName: string): string => {
const messages: Record<string, (name: string) => string> = {
ADVANCED_SEMIFINAL: (name) => `Your project "${name}" has advanced to the semi-finals!`,
ADVANCED_FINAL: (name) => `Your project "${name}" has been selected as a finalist!`,
NOT_SELECTED: (name) => `We regret to inform you that "${name}" was not selected for the next round.`,
WINNER_ANNOUNCEMENT: (name) => `Your project "${name}" has been selected as a winner!`,
}
return messages[type]?.(projectName) || `Update regarding your project "${projectName}".`
}
// Notify project teams based on round's configured notification or status-based fallback
if (projects.length > 0) {
if (round?.entryNotificationType) {
// Use round's configured notification type
for (const project of projects) {
await notifyProjectTeam(project.id, {
type: round.entryNotificationType,
title: getNotificationTitle(round.entryNotificationType),
message: getNotificationMessage(round.entryNotificationType, project.title),
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round.name,
programName: round.program?.name,
},
})
}
} else {
// Fall back to hardcoded status-based notifications
const notificationConfig: Record<
string,
{ type: string; titleFn: (name: string) => string; messageFn: (name: string) => string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
titleFn: () => "Congratulations! You're a Semi-Finalist",
messageFn: (name) => `Your project "${name}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
titleFn: () => "Amazing News! You're a Finalist",
messageFn: (name) => `Your project "${name}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
titleFn: () => 'Application Status Update',
messageFn: (name) =>
`We regret to inform you that "${name}" was not selected for the next round.`,
},
}
const config = notificationConfig[input.status]
if (config) {
for (const project of projects) {
await notifyProjectTeam(project.id, {
type: config.type,
title: config.titleFn(project.title),
message: config.messageFn(project.title),
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: input.status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round?.name,
programName: round?.program?.name,
},
})
}
}
}
}
return { updated: updated.count } return { updated: updated.count }
}), }),

View File

@ -2,6 +2,10 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure } from '../trpc'
import {
notifyRoundJury,
NotificationTypes,
} from '../services/in-app-notification'
export const roundRouter = router({ export const roundRouter = router({
/** /**
@ -70,6 +74,7 @@ export const roundRouter = router({
settingsJson: z.record(z.unknown()).optional(), settingsJson: z.record(z.unknown()).optional(),
votingStartAt: z.date().optional(), votingStartAt: z.date().optional(),
votingEndAt: z.date().optional(), votingEndAt: z.date().optional(),
entryNotificationType: z.string().optional(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@ -158,6 +163,7 @@ export const roundRouter = router({
votingStartAt: z.date().optional().nullable(), votingStartAt: z.date().optional().nullable(),
votingEndAt: z.date().optional().nullable(), votingEndAt: z.date().optional().nullable(),
settingsJson: z.record(z.unknown()).optional(), settingsJson: z.record(z.unknown()).optional(),
entryNotificationType: z.string().optional().nullable(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@ -254,6 +260,50 @@ export const roundRouter = router({
}, },
}) })
// Notify jury members when round is activated
if (input.status === 'ACTIVE' && previousRound.status !== 'ACTIVE') {
// Get round details and assignment counts per user
const roundDetails = await ctx.prisma.round.findUnique({
where: { id: input.id },
include: {
_count: { select: { assignments: true } },
},
})
// Get count of distinct jury members assigned
const juryCount = await ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { roundId: input.id },
_count: true,
})
if (roundDetails && juryCount.length > 0) {
const deadline = roundDetails.votingEndAt
? new Date(roundDetails.votingEndAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
// Notify all jury members with assignments in this round
await notifyRoundJury(input.id, {
type: NotificationTypes.ROUND_NOW_OPEN,
title: `${roundDetails.name} is Now Open`,
message: `The evaluation round is now open. Please review your assigned projects and submit your evaluations before the deadline.`,
linkUrl: `/jury/assignments`,
linkLabel: 'Start Evaluating',
priority: 'high',
metadata: {
roundName: roundDetails.name,
projectCount: roundDetails._count.assignments,
deadline,
},
})
}
}
return round return round
}), }),

View File

@ -6,7 +6,7 @@
*/ */
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { sendNotificationEmail } from '@/lib/email' import { sendStyledNotificationEmail } from '@/lib/email'
// Notification priority levels // Notification priority levels
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent' export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'
@ -218,7 +218,7 @@ export async function createNotification(
}) })
// Check if we should also send an email // Check if we should also send an email
await maybeSendEmail(userId, type, title, message, linkUrl) await maybeSendEmail(userId, type, title, message, linkUrl, metadata)
} }
/** /**
@ -267,7 +267,7 @@ export async function createBulkNotifications(params: {
// Check email settings and send emails // Check email settings and send emails
for (const userId of userIds) { for (const userId of userIds) {
await maybeSendEmail(userId, type, title, message, linkUrl) await maybeSendEmail(userId, type, title, message, linkUrl, metadata)
} }
} }
@ -373,7 +373,8 @@ async function maybeSendEmail(
type: string, type: string,
title: string, title: string,
message: string, message: string,
linkUrl?: string linkUrl?: string,
metadata?: Record<string, unknown>
): Promise<void> { ): Promise<void> {
try { try {
// Check if email is enabled for this notification type // Check if email is enabled for this notification type
@ -396,16 +397,21 @@ async function maybeSendEmail(
return return
} }
// Send the email // Send styled email with full context
const subject = emailSetting.emailSubject || title // The styled template will use metadata for rich content
const body = emailSetting.emailTemplate // Subject can be overridden by admin settings
? emailSetting.emailTemplate await sendStyledNotificationEmail(
.replace('{title}', title) user.email,
.replace('{message}', message) user.name || 'User',
.replace('{link}', linkUrl || '') type,
: message {
title,
await sendNotificationEmail(user.email, user.name || 'User', subject, body, linkUrl) message,
linkUrl,
metadata,
},
emailSetting.emailSubject || undefined
)
} catch (error) { } catch (error) {
// Log but don't fail the notification creation // Log but don't fail the notification creation
console.error('[Notification] Failed to send email:', error) console.error('[Notification] Failed to send email:', error)