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:
295
src/lib/email.ts
295
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 <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,
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user