diff --git a/prisma/seed-notification-settings.ts b/prisma/seed-notification-settings.ts
index 5723d44..0b738b6 100644
--- a/prisma/seed-notification-settings.ts
+++ b/prisma/seed-notification-settings.ts
@@ -214,6 +214,64 @@ const NOTIFICATION_EMAIL_SETTINGS = [
sendEmail: true,
},
+ // Logistics notifications
+ {
+ notificationType: 'FINALIST_CONFIRMED',
+ category: 'logistics',
+ label: 'Finalist Confirmed',
+ description: 'Admin alert when a team confirms their grand-finale attendance',
+ sendEmail: true,
+ },
+ {
+ notificationType: 'FINALIST_DECLINED',
+ category: 'logistics',
+ label: 'Finalist Declined',
+ description: 'Admin alert when a team declines or an admin declines their finalist slot',
+ sendEmail: true,
+ },
+ {
+ notificationType: 'FINALIST_EXPIRED',
+ category: 'logistics',
+ label: 'Finalist Confirmation Expired',
+ description: 'Admin alert when a pending confirmation passes its deadline without a response',
+ sendEmail: true,
+ },
+ {
+ notificationType: 'FINALIST_WAITLIST_PROMOTED',
+ category: 'logistics',
+ label: 'Waitlist Promoted',
+ description: 'Admin alert when a waitlisted team is promoted to a confirmed finalist slot',
+ sendEmail: true,
+ },
+ {
+ notificationType: 'FINALIST_REMINDER',
+ category: 'logistics',
+ label: 'Confirmation Reminder',
+ description: 'Reminder email to the team lead when the confirmation deadline is approaching',
+ sendEmail: true,
+ },
+ {
+ notificationType: 'FINALIST_WITHDRAWN',
+ category: 'logistics',
+ label: 'Finalist Slot Withdrawn',
+ description: 'Notification to the team when their confirmed grand-finale slot is withdrawn by an admin',
+ sendEmail: true,
+ },
+ {
+ notificationType: 'TRAVEL_CONFIRMED',
+ category: 'logistics',
+ label: 'Travel Confirmed',
+ description: 'Email to the attendee when their flight and travel details are confirmed',
+ sendEmail: true,
+ },
+ {
+ notificationType: 'VISA_STATUS_UPDATE',
+ category: 'logistics',
+ label: 'Visa Status Update',
+ description: 'Email to the attendee when their visa application status changes',
+ sendEmail: true,
+ },
+
// Admin notifications (in-app only by default)
{
notificationType: 'FILTERING_COMPLETE',
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 4520413..eb2148a 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -2188,6 +2188,265 @@ export function getAccountReminderTemplate(
}
}
+// =============================================================================
+// Logistics email templates
+// =============================================================================
+
+/**
+ * Reminder email sent to a team lead when the confirmation deadline is approaching.
+ */
+function getFinalistReminderTemplate(
+ name: string,
+ projectTitle: string,
+ deadline: Date,
+ confirmUrl: string,
+): EmailTemplate {
+ const greeting = name ? `Hi ${name},` : 'Hi there,'
+ const formattedDeadline = deadline.toLocaleString('en-GB', {
+ timeZone: 'Europe/Paris',
+ dateStyle: 'full',
+ timeStyle: 'short',
+ })
+
+ const content = `
+ ${sectionTitle('Reminder: Grand-Finale Attendance')}
+ ${paragraph(greeting)}
+ ${paragraph(`Your project ${escapeHtml(projectTitle)} has been selected as a finalist for the Monaco Ocean Protection Challenge Grand Finale. Your confirmation is still pending.`)}
+ ${infoBox(`Please confirm your attendance by ${escapeHtml(formattedDeadline)} (Paris time). If no response is received by the deadline, your slot may be reallocated.`, 'warning')}
+ ${ctaButton(confirmUrl, 'Confirm attendance')}
+ ${paragraph('If you have any questions, please reach out to the MOPC team.')}
+ `
+
+ const text = [
+ greeting,
+ '',
+ `Your project "${projectTitle}" has been selected as a finalist for the Monaco Ocean Protection Challenge Grand Finale. Your confirmation is still pending.`,
+ '',
+ `Please confirm your attendance by ${formattedDeadline} (Paris time). If no response is received by the deadline, your slot may be reallocated.`,
+ '',
+ `Confirm attendance: ${confirmUrl}`,
+ '',
+ 'If you have any questions, please reach out to the MOPC team.',
+ '',
+ 'The MOPC team',
+ ].join('\n')
+
+ return {
+ subject: 'Reminder: confirm your grand-finale attendance',
+ html: getEmailWrapper(content),
+ text,
+ }
+}
+
+/**
+ * Notification email sent to a team when their confirmed finalist slot is withdrawn by an admin.
+ */
+function getFinalistWithdrawnTemplate(
+ name: string,
+ projectTitle: string,
+ reason?: string,
+): EmailTemplate {
+ const greeting = name ? `Hi ${name},` : 'Hi there,'
+
+ const reasonHtml = reason
+ ? paragraph(`Reason: ${escapeHtml(reason)}`)
+ : ''
+ const reasonText = reason ? `Reason: ${reason}\n\n` : ''
+
+ const content = `
+ ${sectionTitle('Grand-Finale Slot Withdrawn')}
+ ${paragraph(greeting)}
+ ${infoBox(`We regret to inform you that your team's confirmed spot at the Grand Finale has been withdrawn for the project ${escapeHtml(projectTitle)}.`, 'warning')}
+ ${reasonHtml}
+ ${paragraph('If you believe this is an error or have questions, please contact the MOPC team as soon as possible.')}
+ `
+
+ const text = [
+ greeting,
+ '',
+ `We regret to inform you that your team's confirmed spot at the Grand Finale has been withdrawn for the project "${projectTitle}".`,
+ '',
+ reasonText + 'If you believe this is an error or have questions, please contact the MOPC team as soon as possible.',
+ '',
+ 'The MOPC team',
+ ].join('\n')
+
+ return {
+ subject: 'Your grand-finale slot has been withdrawn',
+ html: getEmailWrapper(content),
+ text,
+ }
+}
+
+/**
+ * Travel confirmation email for a grand-finale attendee, including flight and hotel details.
+ */
+function getTravelConfirmedTemplate(
+ name: string,
+ projectTitle: string,
+ flight: {
+ arrivalAt?: Date | string | null
+ arrivalFlightNumber?: string | null
+ arrivalAirport?: string | null
+ departureAt?: Date | string | null
+ departureFlightNumber?: string | null
+ departureAirport?: string | null
+ },
+ hotel?: {
+ name: string
+ address?: string | null
+ link?: string | null
+ },
+): EmailTemplate {
+ const greeting = name ? `Hi ${name},` : 'Hi there,'
+
+ const fmtDate = (d: Date | string | null | undefined) => {
+ if (!d) return null
+ const dt = d instanceof Date ? d : new Date(d)
+ return dt.toLocaleString('en-GB', { timeZone: 'Europe/Paris', dateStyle: 'full', timeStyle: 'short' })
+ }
+
+ const arrivalLines: string[] = []
+ const departureLines: string[] = []
+
+ if (flight.arrivalAt) arrivalLines.push(`Date: ${fmtDate(flight.arrivalAt)} (Paris time)`)
+ if (flight.arrivalFlightNumber) arrivalLines.push(`Flight: ${flight.arrivalFlightNumber}`)
+ if (flight.arrivalAirport) arrivalLines.push(`Airport: ${flight.arrivalAirport}`)
+
+ if (flight.departureAt) departureLines.push(`Date: ${fmtDate(flight.departureAt)} (Paris time)`)
+ if (flight.departureFlightNumber) departureLines.push(`Flight: ${flight.departureFlightNumber}`)
+ if (flight.departureAirport) departureLines.push(`Airport: ${flight.departureAirport}`)
+
+ const arrivalHtml =
+ arrivalLines.length > 0
+ ? `
Arrival
` +
+ `` +
+ arrivalLines.map((l) => `- ${escapeHtml(l)}
`).join('') +
+ `
`
+ : ''
+
+ const departureHtml =
+ departureLines.length > 0
+ ? `Departure
` +
+ `` +
+ departureLines.map((l) => `- ${escapeHtml(l)}
`).join('') +
+ `
`
+ : ''
+
+ const hotelHtml = hotel
+ ? `Hotel
` +
+ infoBox(
+ `${escapeHtml(hotel.name)}` +
+ (hotel.address ? `
${escapeHtml(hotel.address)}` : '') +
+ (hotel.link ? `
View hotel` : ''),
+ 'info',
+ )
+ : ''
+
+ const hotelText = hotel
+ ? ['\nHotel:', ` ${hotel.name}`, ...(hotel.address ? [` ${hotel.address}`] : []), ...(hotel.link ? [` ${hotel.link}`] : [])].join('\n')
+ : ''
+
+ const content = `
+ ${sectionTitle('Your travel is confirmed!')}
+ ${paragraph(greeting)}
+ ${paragraph(`Great news — your travel arrangements for the Grand Finale (${escapeHtml(projectTitle)}) have been confirmed. Here are your details:`)}
+ ${arrivalHtml}
+ ${departureHtml}
+ ${hotelHtml}
+ ${paragraph('If anything looks incorrect, please contact the MOPC team immediately.')}
+ `
+
+ const arrivalText = arrivalLines.length > 0 ? '\nArrival:\n' + arrivalLines.map((l) => ` ${l}`).join('\n') : ''
+ const departureText = departureLines.length > 0 ? '\nDeparture:\n' + departureLines.map((l) => ` ${l}`).join('\n') : ''
+
+ const text = [
+ greeting,
+ '',
+ `Great news — your travel arrangements for the Grand Finale ("${projectTitle}") have been confirmed. Here are your details:`,
+ arrivalText,
+ departureText,
+ hotelText,
+ '',
+ 'If anything looks incorrect, please contact the MOPC team immediately.',
+ '',
+ 'The MOPC team',
+ ].join('\n')
+
+ return {
+ subject: 'Your grand-finale travel is confirmed',
+ html: getEmailWrapper(content),
+ text,
+ }
+}
+
+/**
+ * Visa status update email for a grand-finale attendee.
+ */
+function getVisaStatusTemplate(
+ name: string,
+ projectTitle: string,
+ status: 'INVITATION_SENT' | 'APPOINTMENT_BOOKED' | 'GRANTED' | 'DENIED',
+ note?: string,
+): EmailTemplate {
+ const greeting = name ? `Hi ${name},` : 'Hi there,'
+
+ const statusCopy: Record = {
+ INVITATION_SENT: {
+ headline: 'Visa invitation letter sent',
+ body: 'Your official visa invitation letter for the Grand Finale has been sent. Please use it when applying for your visa at the relevant consulate or embassy.',
+ variant: 'info',
+ },
+ APPOINTMENT_BOOKED: {
+ headline: 'Visa appointment booked',
+ body: 'A visa appointment has been booked on your behalf. Please check your inbox or contact the MOPC team for the appointment details.',
+ variant: 'info',
+ },
+ GRANTED: {
+ headline: 'Visa granted — see you in Monaco!',
+ body: 'Congratulations — your visa has been granted. We look forward to welcoming you to the Monaco Ocean Protection Challenge Grand Finale.',
+ variant: 'success',
+ },
+ DENIED: {
+ headline: 'Visa application outcome',
+ body: 'Unfortunately, your visa application has not been successful at this time. Please contact the MOPC team as soon as possible so we can discuss next steps.',
+ variant: 'warning',
+ },
+ }
+
+ const { headline, body, variant } = statusCopy[status]
+ const noteHtml = note ? paragraph(`${escapeHtml(note)}`) : ''
+ const noteText = note ? `\n${note}\n` : ''
+
+ const content = `
+ ${sectionTitle('Visa update for the Grand Finale')}
+ ${paragraph(greeting)}
+ ${paragraph(`This is an update regarding your visa for the Grand Finale — project ${escapeHtml(projectTitle)}.`)}
+ ${infoBox(`${escapeHtml(headline)}
${escapeHtml(body)}`, variant)}
+ ${noteHtml}
+ ${paragraph('If you have any questions, please don\'t hesitate to contact the MOPC team.')}
+ `
+
+ const text = [
+ greeting,
+ '',
+ `This is an update regarding your visa for the Grand Finale — project "${projectTitle}".`,
+ '',
+ `${headline}`,
+ body,
+ noteText,
+ 'If you have any questions, please don\'t hesitate to contact the MOPC team.',
+ '',
+ 'The MOPC team',
+ ].join('\n')
+
+ return {
+ subject: 'Visa update for the grand finale',
+ html: getEmailWrapper(content),
+ text,
+ }
+}
+
/**
* Template registry mapping notification types to template generators
*/
@@ -2392,6 +2651,42 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record = {
(ctx.metadata?.programName as string) || 'MOPC',
ctx.linkUrl
),
+
+ // Logistics templates (team/attendee-facing)
+ FINALIST_REMINDER: (ctx) =>
+ getFinalistReminderTemplate(
+ ctx.name || '',
+ (ctx.metadata?.projectTitle as string) || 'Your project',
+ new Date((ctx.metadata?.deadline as string) || Date.now()),
+ ctx.linkUrl || '',
+ ),
+ FINALIST_WITHDRAWN: (ctx) =>
+ getFinalistWithdrawnTemplate(
+ ctx.name || '',
+ (ctx.metadata?.projectTitle as string) || 'Your project',
+ ctx.metadata?.reason as string | undefined,
+ ),
+ TRAVEL_CONFIRMED: (ctx) =>
+ getTravelConfirmedTemplate(
+ ctx.name || '',
+ (ctx.metadata?.projectTitle as string) || 'Your project',
+ {
+ arrivalAt: ctx.metadata?.arrivalAt as string | undefined,
+ arrivalFlightNumber: ctx.metadata?.arrivalFlightNumber as string | undefined,
+ arrivalAirport: ctx.metadata?.arrivalAirport as string | undefined,
+ departureAt: ctx.metadata?.departureAt as string | undefined,
+ departureFlightNumber: ctx.metadata?.departureFlightNumber as string | undefined,
+ departureAirport: ctx.metadata?.departureAirport as string | undefined,
+ },
+ ctx.metadata?.hotel as { name: string; address?: string; link?: string } | undefined,
+ ),
+ VISA_STATUS_UPDATE: (ctx) =>
+ getVisaStatusTemplate(
+ ctx.name || '',
+ (ctx.metadata?.projectTitle as string) || 'Your project',
+ (ctx.metadata?.status as 'INVITATION_SENT' | 'APPOINTMENT_BOOKED' | 'GRANTED' | 'DENIED') || 'INVITATION_SENT',
+ ctx.metadata?.note as string | undefined,
+ ),
}
/**
diff --git a/src/server/services/in-app-notification.ts b/src/server/services/in-app-notification.ts
index adc7a26..fe59d55 100644
--- a/src/server/services/in-app-notification.ts
+++ b/src/server/services/in-app-notification.ts
@@ -95,6 +95,16 @@ export const NotificationTypes = {
FINALISTS_ANNOUNCED: 'FINALISTS_ANNOUNCED',
WINNERS_ANNOUNCED: 'WINNERS_ANNOUNCED',
REPORT_AVAILABLE: 'REPORT_AVAILABLE',
+
+ // Logistics
+ FINALIST_CONFIRMED: 'FINALIST_CONFIRMED',
+ FINALIST_DECLINED: 'FINALIST_DECLINED',
+ FINALIST_EXPIRED: 'FINALIST_EXPIRED',
+ FINALIST_WAITLIST_PROMOTED: 'FINALIST_WAITLIST_PROMOTED',
+ FINALIST_REMINDER: 'FINALIST_REMINDER',
+ FINALIST_WITHDRAWN: 'FINALIST_WITHDRAWN',
+ TRAVEL_CONFIRMED: 'TRAVEL_CONFIRMED',
+ VISA_STATUS_UPDATE: 'VISA_STATUS_UPDATE',
} as const
export type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes]
@@ -129,6 +139,14 @@ export const NotificationIcons: Record = {
[NotificationTypes.AWARD_RESULTS]: 'Trophy',
[NotificationTypes.AI_RANKING_COMPLETE]: 'BarChart3',
[NotificationTypes.AI_RANKING_FAILED]: 'AlertTriangle',
+ [NotificationTypes.FINALIST_CONFIRMED]: 'CheckCircle',
+ [NotificationTypes.FINALIST_DECLINED]: 'XCircle',
+ [NotificationTypes.FINALIST_EXPIRED]: 'AlertTriangle',
+ [NotificationTypes.FINALIST_WAITLIST_PROMOTED]: 'TrendingUp',
+ [NotificationTypes.FINALIST_REMINDER]: 'Clock',
+ [NotificationTypes.FINALIST_WITHDRAWN]: 'UserMinus',
+ [NotificationTypes.TRAVEL_CONFIRMED]: 'Plane',
+ [NotificationTypes.VISA_STATUS_UPDATE]: 'FileText',
}
// Priority by notification type
@@ -155,6 +173,14 @@ export const NotificationPriorities: Record = {
[NotificationTypes.AWARD_VOTING_OPEN]: 'high',
[NotificationTypes.AI_RANKING_COMPLETE]: 'normal',
[NotificationTypes.AI_RANKING_FAILED]: 'high',
+ [NotificationTypes.FINALIST_EXPIRED]: 'urgent',
+ [NotificationTypes.FINALIST_REMINDER]: 'high',
+ [NotificationTypes.FINALIST_WITHDRAWN]: 'high',
+ [NotificationTypes.FINALIST_CONFIRMED]: 'normal',
+ [NotificationTypes.FINALIST_DECLINED]: 'high',
+ [NotificationTypes.FINALIST_WAITLIST_PROMOTED]: 'high',
+ [NotificationTypes.TRAVEL_CONFIRMED]: 'high',
+ [NotificationTypes.VISA_STATUS_UPDATE]: 'high',
}
interface CreateNotificationParams {