From b0189cad92e35e000791d0d541cf6b6a32669166 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 4 Feb 2026 00:10:51 +0100 Subject: [PATCH] Add styled notification emails and round-attached notifications - 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 --- prisma/schema.prisma | 3 + .../(admin)/admin/rounds/[id]/edit/page.tsx | 58 +- src/app/(admin)/admin/rounds/new/page.tsx | 46 +- .../settings/notification-settings-form.tsx | 60 +- src/lib/email.ts | 979 ++++++++++++++++++ src/server/routers/application.ts | 34 + src/server/routers/assignment.ts | 170 +++ src/server/routers/mentor.ts | 120 ++- src/server/routers/notification.ts | 143 +++ src/server/routers/onboarding.ts | 35 + src/server/routers/project.ts | 188 ++++ src/server/routers/round.ts | 50 + src/server/services/in-app-notification.ts | 34 +- 13 files changed, 1892 insertions(+), 28 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 427d7f6..3b6c152 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx index 0177fc7..bb8d28a 100644 --- a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx @@ -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>({}) + const [entryNotificationType, setEntryNotificationType] = useState('') // 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) || {}) + 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 }) { + {/* Team Notification */} + + + + + Team Notification + + + Notification sent to project teams when they enter this round + + + + +

+ When projects advance to this round, the selected notification will be sent to the project team automatically. +

+
+
+ {/* Evaluation Criteria */} diff --git a/src/app/(admin)/admin/rounds/new/page.tsx b/src/app/(admin)/admin/rounds/new/page.tsx index 1e3034f..9447ced 100644 --- a/src/app/(admin)/admin/rounds/new/page.tsx +++ b/src/app/(admin)/admin/rounds/new/page.tsx @@ -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>({}) + const [entryNotificationType, setEntryNotificationType] = useState('') 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() { + {/* Team Notification */} + + + + + Team Notification + + + Notification sent to project teams when they enter this round + + + + +

+ When projects advance to this round, the selected notification will be sent to the project team automatically. +

+
+
+ {/* Error */} {createRound.error && ( diff --git a/src/components/settings/notification-settings-form.tsx b/src/components/settings/notification-settings-form.tsx index e35c907..6d1996c 100644 --- a/src/components/settings/notification-settings-form.tsx +++ b/src/components/settings/notification-settings-form.tsx @@ -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(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 (
@@ -112,7 +137,7 @@ export function NotificationSettingsForm() { key={setting.id} className="flex items-center justify-between rounded-lg border p-3" > -
+
@@ -122,13 +147,30 @@ export function NotificationSettingsForm() {

)}
- - handleToggle(setting.notificationType, checked) - } - disabled={updateMutation.isPending} - /> +
+ + + handleToggle(setting.notificationType, checked) + } + disabled={updateMutation.isPending} + /> +
))} diff --git a/src/lib/email.ts b/src/lib/email.ts index 9338185..702f99e 100644 --- a/src/lib/email.ts +++ b/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 +} + +/** + * 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 = ` + + + + +
+

Exciting News

+

You're a Semi-Finalist!

+
+` + + const content = ` + ${sectionTitle(greeting)} + ${celebrationBanner} + ${paragraph(`Your project "${projectName}" 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(`Next Steps: ${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 = ` + + + + +
+

Outstanding Achievement

+

You're a Finalist!

+
+` + + const content = ` + ${sectionTitle(greeting)} + ${celebrationBanner} + ${paragraph(`Your project "${projectName}" has been selected as a Finalist in ${programName}.`)} + ${infoBox('You are now among the top projects competing for the grand prize!', 'success')} + ${nextSteps ? paragraph(`What Happens Next: ${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 = ` + + + + +
+

Your Mentor

+

${mentorName}

+ ${mentorBio ? `

${mentorBio}

` : ''} +
+` + + const content = ` + ${sectionTitle(greeting)} + ${paragraph(`Great news! A mentor has been assigned to support your project "${projectName}".`)} + ${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 "${projectName}".`)} + ${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.')} +

+ Thank you for being part of the Monaco Ocean Protection Challenge community. +

+ ` + + 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 = ` + + + + +
+

🏆

+

Winner

+

${awardName}

+
+` + + const content = ` + ${sectionTitle(greeting)} + ${trophyBanner} + ${paragraph(`We are thrilled to announce that your project "${projectName}" has been selected as the winner of the ${awardName}!`)} + ${infoBox('Your outstanding work in ocean protection has made a lasting impression on our jury.', 'success')} + ${prizeDetails ? paragraph(`Your Prize: ${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 = ` + + + + +
+

New Assignment

+

${projectName}

+
+` + + const deadlineBox = deadline ? ` + + + + +
+

Deadline

+

${deadline}

+
+` : '' + + const content = ` + ${sectionTitle(greeting)} + ${paragraph(`You have been assigned a new project to evaluate for ${roundName}.`)} + ${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 ? ` + + + + +
+

Deadline

+

${deadline}

+
+` : '' + + const content = ` + ${sectionTitle(greeting)} + ${paragraph(`You have been assigned projects to evaluate for ${roundName}.`)} + ${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 = ` + + + + +
+

Evaluation Round

+

${roundName} is Now Open

+
+` + + const deadlineBox = deadline ? ` + + + + +
+

Deadline

+

${deadline}

+
+` : '' + + 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 = ` + + + + +
+

⚠ 24 Hours Remaining

+
+` + + const content = ` + ${sectionTitle(greeting)} + ${urgentBox} + ${paragraph(`This is a reminder that ${roundName} closes in 24 hours.`)} + ${statCard('Pending Evaluations', pendingCount)} + ${infoBox(`Deadline: ${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 = ` + + + + +
+

Urgent

+

1 Hour Remaining

+
+` + + const content = ` + ${sectionTitle(greeting)} + ${urgentBanner} + ${paragraph(`${roundName} closes in 1 hour.`)} + ${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 = ` + + + + +
+

🏆

+

Special Award

+

${awardName}

+
+` + + const content = ` + ${sectionTitle(greeting)} + ${awardBanner} + ${paragraph(`Voting is now open for the ${awardName}.`)} + ${statCard('Finalists', finalistCount)} + ${deadline ? infoBox(`Voting closes: ${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 = ` + + + + +
+

Your New Mentee

+

${projectName}

+

+ Team Lead: ${teamLeadName}${teamLeadEmail ? ` (${teamLeadEmail})` : ''} +

+
+` + + 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 "${projectName}" has advanced to the next stage!`)} + ${statCard('Advanced From', roundName)} + ${nextRoundName ? paragraph(`They will now compete in ${nextRoundName}.`) : ''} + ${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 = ` + + + + +
+

🏆

+

Your Mentee Won!

+
+` + + const content = ` + ${sectionTitle(greeting)} + ${trophyBanner} + ${paragraph(`Your mentee project "${projectName}" has won the ${awardName}!`)} + ${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 = ` + + + + +
+

New Application

+

${projectName}

+

+ Applicant: ${applicantName} (${applicantEmail}) +

+
+` + + const content = ` + ${sectionTitle('New Application Received')} + ${paragraph(`A new application has been submitted to ${programName}.`)} + ${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 = { + // 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 { + 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 // ============================================================================= diff --git a/src/server/routers/application.ts b/src/server/routers/application.ts index 816f5eb..7d4a7e7 100644 --- a/src/server/routers/application.ts +++ b/src/server/routers/application.ts @@ -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, diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts index 672a10d..6edee74 100644 --- a/src/server/routers/assignment.ts +++ b/src/server/routers/assignment.ts @@ -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 + ) + + // 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 + ) + + 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 + ) + + 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 } }), }) diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 9e57cda..dc7316d 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -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++ diff --git a/src/server/routers/notification.ts b/src/server/routers/notification.ts index 98289bb..516a9cc 100644 --- a/src/server/routers/notification.ts +++ b/src/server/routers/notification.ts @@ -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> = { + // 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, + } + } + }), }) diff --git a/src/server/routers/onboarding.ts b/src/server/routers/onboarding.ts index 2c825bd..9dbc5c2 100644 --- a/src/server/routers/onboarding.ts +++ b/src/server/routers/onboarding.ts @@ -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, diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 752d077..4ec854e 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -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 = { + 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> = { + 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 = { + 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> = { + 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 } }), diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index 883bc1f..bdd729f 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -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 }), diff --git a/src/server/services/in-app-notification.ts b/src/server/services/in-app-notification.ts index 6d7cbf8..3fc4c0a 100644 --- a/src/server/services/in-app-notification.ts +++ b/src/server/services/in-app-notification.ts @@ -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 ): Promise { 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)