Add styled notification emails and round-attached notifications
Build and Push Docker Image / build (push) Successful in 8m18s
Details
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:
parent
3be6a743ed
commit
b0189cad92
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
979
src/lib/email.ts
979
src/lib/email.ts
|
|
@ -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;">🏆</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;">⚠ 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;">🏆</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;">🏆</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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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++
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue