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
|
||||
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())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
|
|
|||
|
|
@ -32,8 +32,24 @@ import {
|
|||
type Criterion,
|
||||
} from '@/components/forms/evaluation-form-builder'
|
||||
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 {
|
||||
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 {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -68,6 +84,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
const [formInitialized, setFormInitialized] = useState(false)
|
||||
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
|
||||
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
|
||||
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,
|
||||
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')
|
||||
setRoundSettings((round.settingsJson as Record<string, unknown>) || {})
|
||||
setEntryNotificationType(round.entryNotificationType || '')
|
||||
setFormInitialized(true)
|
||||
}
|
||||
}, [round, form, formInitialized])
|
||||
|
|
@ -139,7 +157,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
}, [evaluationForm, loadingForm, criteriaInitialized])
|
||||
|
||||
const onSubmit = async (data: UpdateRoundForm) => {
|
||||
// Update round with type and settings
|
||||
// Update round with type, settings, and notification
|
||||
await updateRound.mutateAsync({
|
||||
id: roundId,
|
||||
name: data.name,
|
||||
|
|
@ -148,6 +166,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ?? null,
|
||||
votingEndAt: data.votingEndAt ?? null,
|
||||
entryNotificationType: entryNotificationType || null,
|
||||
})
|
||||
|
||||
// Update evaluation form if criteria changed and no evaluations exist
|
||||
|
|
@ -334,6 +353,39 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
</CardContent>
|
||||
</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 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
|
|||
|
|
@ -34,9 +34,18 @@ import {
|
|||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
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'
|
||||
|
||||
// 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({
|
||||
programId: z.string().min(1, 'Please select a program'),
|
||||
name: z.string().min(1, 'Name is required').max(255),
|
||||
|
|
@ -61,6 +70,7 @@ function CreateRoundContent() {
|
|||
const programIdParam = searchParams.get('program')
|
||||
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
|
||||
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
|
||||
const [entryNotificationType, setEntryNotificationType] = useState<string>('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
|
||||
|
|
@ -92,6 +102,7 @@ function CreateRoundContent() {
|
|||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ?? undefined,
|
||||
votingEndAt: data.votingEndAt ?? undefined,
|
||||
entryNotificationType: entryNotificationType || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -285,6 +296,39 @@ function CreateRoundContent() {
|
|||
</CardContent>
|
||||
</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 */}
|
||||
{createRound.error && (
|
||||
<Card className="border-destructive">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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
|
||||
const CATEGORIES = {
|
||||
|
|
@ -29,6 +29,7 @@ type NotificationSetting = {
|
|||
}
|
||||
|
||||
export function NotificationSettingsForm() {
|
||||
const [testingType, setTestingType] = useState<string | null>(null)
|
||||
const { data: settings, isLoading, refetch } = trpc.notification.getEmailSettings.useQuery()
|
||||
const updateMutation = trpc.notification.updateEmailSetting.useMutation({
|
||||
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) => {
|
||||
updateMutation.mutate({ notificationType, sendEmail })
|
||||
}
|
||||
|
||||
const handleTest = (notificationType: string) => {
|
||||
setTestingType(notificationType)
|
||||
testMutation.mutate({ notificationType })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -112,7 +137,7 @@ export function NotificationSettingsForm() {
|
|||
key={setting.id}
|
||||
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">
|
||||
{setting.label}
|
||||
</Label>
|
||||
|
|
@ -122,13 +147,30 @@ export function NotificationSettingsForm() {
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={setting.sendEmail}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(setting.notificationType, checked)
|
||||
}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<div className="flex items-center gap-3 ml-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-muted-foreground hover:text-foreground"
|
||||
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>
|
||||
))}
|
||||
</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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ import { z } from 'zod'
|
|||
import { TRPCError } from '@trpc/server'
|
||||
import { router, publicProcedure } from '../trpc'
|
||||
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
|
||||
import {
|
||||
createNotification,
|
||||
notifyAdmins,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
|
||||
// Zod schemas for the application form
|
||||
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 {
|
||||
success: true,
|
||||
projectId: project.id,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ import {
|
|||
generateFallbackAssignments,
|
||||
} from '../services/ai-assignment'
|
||||
import { isOpenAIConfigured } from '@/lib/openai'
|
||||
import {
|
||||
createNotification,
|
||||
createBulkNotifications,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
|
||||
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
|
||||
}),
|
||||
|
||||
|
|
@ -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 }
|
||||
}),
|
||||
|
||||
|
|
@ -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 }
|
||||
}),
|
||||
|
||||
|
|
@ -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 }
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ import {
|
|||
getAIMentorSuggestions,
|
||||
getRoundRobinMentor,
|
||||
} from '../services/mentor-matching'
|
||||
import {
|
||||
createNotification,
|
||||
notifyProjectTeam,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
|
||||
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
|
||||
}),
|
||||
|
||||
|
|
@ -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
|
||||
}),
|
||||
|
||||
|
|
@ -378,7 +455,7 @@ export const mentorRouter = router({
|
|||
}
|
||||
|
||||
if (mentorId) {
|
||||
await ctx.prisma.mentorAssignment.create({
|
||||
const assignment = await ctx.prisma.mentorAssignment.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
mentorId,
|
||||
|
|
@ -388,7 +465,48 @@ export const mentorRouter = router({
|
|||
expertiseMatchScore,
|
||||
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++
|
||||
} else {
|
||||
failed++
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
NotificationIcons,
|
||||
NotificationPriorities,
|
||||
} from '../services/in-app-notification'
|
||||
import { sendStyledNotificationEmail, NOTIFICATION_EMAIL_TEMPLATES } from '@/lib/email'
|
||||
|
||||
export const notificationRouter = router({
|
||||
/**
|
||||
|
|
@ -218,4 +219,146 @@ export const notificationRouter = router({
|
|||
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 { sendApplicationConfirmationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
||||
import { nanoid } from 'nanoid'
|
||||
import {
|
||||
createNotification,
|
||||
notifyAdmins,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
|
||||
// Team member input for submission
|
||||
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 {
|
||||
success: true,
|
||||
projectId: project.id,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import { TRPCError } from '@trpc/server'
|
|||
import { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { getUserAvatarUrl } from '../utils/avatar-url'
|
||||
import {
|
||||
notifyProjectTeam,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
|
||||
export const projectRouter = router({
|
||||
/**
|
||||
|
|
@ -397,6 +401,90 @@ export const projectRouter = router({
|
|||
where: { projectId: id, roundId },
|
||||
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
|
||||
|
|
@ -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 }
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { z } from 'zod'
|
|||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import {
|
||||
notifyRoundJury,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
|
||||
export const roundRouter = router({
|
||||
/**
|
||||
|
|
@ -70,6 +74,7 @@ export const roundRouter = router({
|
|||
settingsJson: z.record(z.unknown()).optional(),
|
||||
votingStartAt: z.date().optional(),
|
||||
votingEndAt: z.date().optional(),
|
||||
entryNotificationType: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
|
@ -158,6 +163,7 @@ export const roundRouter = router({
|
|||
votingStartAt: z.date().optional().nullable(),
|
||||
votingEndAt: z.date().optional().nullable(),
|
||||
settingsJson: z.record(z.unknown()).optional(),
|
||||
entryNotificationType: z.string().optional().nullable(),
|
||||
})
|
||||
)
|
||||
.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
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendNotificationEmail } from '@/lib/email'
|
||||
import { sendStyledNotificationEmail } from '@/lib/email'
|
||||
|
||||
// Notification priority levels
|
||||
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'
|
||||
|
|
@ -218,7 +218,7 @@ export async function createNotification(
|
|||
})
|
||||
|
||||
// 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
|
||||
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,
|
||||
title: string,
|
||||
message: string,
|
||||
linkUrl?: string
|
||||
linkUrl?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if email is enabled for this notification type
|
||||
|
|
@ -396,16 +397,21 @@ async function maybeSendEmail(
|
|||
return
|
||||
}
|
||||
|
||||
// Send the email
|
||||
const subject = emailSetting.emailSubject || title
|
||||
const body = emailSetting.emailTemplate
|
||||
? emailSetting.emailTemplate
|
||||
.replace('{title}', title)
|
||||
.replace('{message}', message)
|
||||
.replace('{link}', linkUrl || '')
|
||||
: message
|
||||
|
||||
await sendNotificationEmail(user.email, user.name || 'User', subject, body, linkUrl)
|
||||
// Send styled email with full context
|
||||
// The styled template will use metadata for rich content
|
||||
// Subject can be overridden by admin settings
|
||||
await sendStyledNotificationEmail(
|
||||
user.email,
|
||||
user.name || 'User',
|
||||
type,
|
||||
{
|
||||
title,
|
||||
message,
|
||||
linkUrl,
|
||||
metadata,
|
||||
},
|
||||
emailSetting.emailSubject || undefined
|
||||
)
|
||||
} catch (error) {
|
||||
// Log but don't fail the notification creation
|
||||
console.error('[Notification] Failed to send email:', error)
|
||||
|
|
|
|||
Loading…
Reference in New Issue