feat(comms): logistics notification types, templates, and email settings

- 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 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-04 16:06:10 +02:00
parent 0ea949309a
commit f529e79cd7
3 changed files with 379 additions and 0 deletions

View File

@@ -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 <strong>${escapeHtml(projectTitle)}</strong> has been selected as a finalist for the Monaco Ocean Protection Challenge Grand Finale. Your confirmation is still pending.`)}
${infoBox(`Please confirm your attendance <strong>by ${escapeHtml(formattedDeadline)} (Paris time)</strong>. 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(`<strong>Reason:</strong> ${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 <strong>${escapeHtml(projectTitle)}</strong>.`, '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
? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Arrival</h3>` +
`<ul style="margin:0 0 16px;padding-left:20px;color:${BRAND.textDark};font-size:14px;">` +
arrivalLines.map((l) => `<li style="margin:4px 0;">${escapeHtml(l)}</li>`).join('') +
`</ul>`
: ''
const departureHtml =
departureLines.length > 0
? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Departure</h3>` +
`<ul style="margin:0 0 16px;padding-left:20px;color:${BRAND.textDark};font-size:14px;">` +
departureLines.map((l) => `<li style="margin:4px 0;">${escapeHtml(l)}</li>`).join('') +
`</ul>`
: ''
const hotelHtml = hotel
? `<h3 style="margin:20px 0 8px;color:#0f172a;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:1px;">Hotel</h3>` +
infoBox(
`<strong>${escapeHtml(hotel.name)}</strong>` +
(hotel.address ? `<br>${escapeHtml(hotel.address)}` : '') +
(hotel.link ? `<br><a href="${hotel.link}" style="color:${BRAND.darkBlue};">View hotel</a>` : ''),
'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 (<strong>${escapeHtml(projectTitle)}</strong>) 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<typeof status, { headline: string; body: string; variant: 'info' | 'success' | 'warning' }> = {
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(`<em>${escapeHtml(note)}</em>`) : ''
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 <strong>${escapeHtml(projectTitle)}</strong>.`)}
${infoBox(`<strong>${escapeHtml(headline)}</strong><br>${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<string, TemplateGenerator> = {
(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,
),
}
/**