From f529e79cd7a2f3b07358f76cad2010b725f3318a Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 4 Jun 2026 16:06:10 +0200 Subject: [PATCH] feat(comms): logistics notification types, templates, and email settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 8 constants to NotificationTypes (FINALIST_CONFIRMED/DECLINED/EXPIRED/ WAITLIST_PROMOTED/REMINDER/WITHDRAWN, TRAVEL_CONFIRMED, VISA_STATUS_UPDATE) with matching icons and priorities in NotificationIcons/NotificationPriorities - Add 4 branded email templates: getFinalistReminderTemplate, getFinalistWithdrawnTemplate, getTravelConfirmedTemplate, getVisaStatusTemplate — registered in NOTIFICATION_EMAIL_TEMPLATES (admin-alert types use generic fallback) - Add 8 logistics seed rows to seed-notification-settings.ts; upserted to dev DB (idempotent) Co-Authored-By: Claude Sonnet 4.6 --- prisma/seed-notification-settings.ts | 58 ++++ src/lib/email.ts | 295 +++++++++++++++++++++ src/server/services/in-app-notification.ts | 26 ++ 3 files changed, 379 insertions(+) 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

` + + `` + : '' + + const departureHtml = + departureLines.length > 0 + ? `

Departure

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