feat: lunch reminder + recap email templates

Adds sendLunchReminderEmail and sendLunchRecapEmail. Templates use
Intl.DateTimeFormat with Europe/Monaco zone. Reuses existing
escapeHtml helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 02:37:17 +02:00
parent a671bb853c
commit 05b0412534
3 changed files with 148 additions and 8 deletions

View File

@@ -2643,3 +2643,142 @@ export async function sendFinalistConfirmationEmail(
const template = getFinalistConfirmationTemplate(name || '', projectTitle, deadline.toISOString(), confirmUrl)
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
// =============================================================================
// LUNCH (PR 6)
// =============================================================================
type LunchRecapPayload = {
event: { eventAt: Date | null; venue: string | null } | null
members: Array<{
name: string
project: { name: string } | null
dish: { name: string } | null
allergens: string[]
allergenOther: string | null
}>
externals: Array<{
name: string
project: { name: string } | null
dish: { name: string } | null
allergens: string[]
allergenOther: string | null
roleNote?: string | null
}>
dishCounts: Record<string, number>
dietaryCounts: Record<string, number>
allergenCounts: Record<string, number>
summary: { total: number; picked: number; missing: number }
}
/**
* Send a lunch reminder to one attendee whose pick is still missing.
* Failures are caught at the cron layer; this function may throw on
* individual failures so the caller can decide.
*/
export async function sendLunchReminderEmail(opts: {
to: string
memberName: string
eventAt: Date
venue: string | null
changeDeadline: Date
pickUrl: string
}): Promise<void> {
const fmt = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short',
})
const subject = `Pick your lunch dish — deadline ${fmt.format(opts.changeDeadline)} (Monaco)`
const html = `
<!DOCTYPE html>
<html><body style="font-family:system-ui,-apple-system,sans-serif;background:#f8fafc;padding:24px;">
<div style="max-width:560px;margin:0 auto;background:white;border-radius:12px;padding:32px;">
<h2 style="margin:0 0 16px;color:#0f172a;">Pick your lunch dish</h2>
<p>Hi ${escapeHtml(opts.memberName)},</p>
<p>You haven't picked your lunch dish for the Monaco Ocean Protection Challenge grand finale yet.</p>
<p>
<strong>Event:</strong> ${fmt.format(opts.eventAt)} (Europe/Monaco)<br/>
${opts.venue ? `<strong>Venue:</strong> ${escapeHtml(opts.venue)}<br/>` : ''}
<strong>Deadline to pick:</strong> ${fmt.format(opts.changeDeadline)}
</p>
<p style="margin-top:24px;">
<a href="${opts.pickUrl}" style="display:inline-block;background:#053d57;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;">Open the picker</a>
</p>
<p style="margin-top:24px;color:#64748b;font-size:12px;">
If you have any questions, reply to this email and we'll help.
</p>
</div>
</body></html>`.trim()
const text = `Pick your lunch dish.
Event: ${opts.eventAt.toISOString()}
${opts.venue ? `Venue: ${opts.venue}\n` : ''}Deadline: ${opts.changeDeadline.toISOString()}
${opts.pickUrl}`
await sendEmail({ to: opts.to, subject, text, html })
}
/**
* Send the lunch recap manifest to admins + extra recipients.
* Caller passes the assembled recap payload from `buildRecapPayload`.
*/
export async function sendLunchRecapEmail(
recipients: string[],
payload: LunchRecapPayload,
): Promise<void> {
if (recipients.length === 0) return
const fmt = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short',
})
const subject = `Lunch manifest — ${payload.event?.eventAt ? fmt.format(payload.event.eventAt) : 'TBD'}`
const dishLines = Object.entries(payload.dishCounts)
.sort(([, a], [, b]) => b - a)
.map(([name, n]) => `<li>${n}× ${escapeHtml(name)}</li>`).join('')
const dietaryLines = Object.entries(payload.dietaryCounts)
.map(([name, n]) => `<li>${n}× ${name.replace('_', ' ').toLowerCase()}</li>`).join('')
const allergenLines = Object.entries(payload.allergenCounts)
.sort(([, a], [, b]) => b - a)
.map(([name, n]) => `<li>${n}× ${name.replace('_', ' ').toLowerCase()}</li>`).join('')
const formatAllergens = (allergens: string[], other: string | null) =>
[...allergens.map(a => a.replace('_', ' ').toLowerCase()), other].filter(Boolean).join(', ')
const memberRows = payload.members.map((r) => `
<tr>
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${escapeHtml(r.project?.name ?? '')}</td>
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${escapeHtml(r.name)}</td>
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${r.dish ? escapeHtml(r.dish.name) : '<em style="color:#94a3b8;">not picked</em>'}</td>
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${escapeHtml(formatAllergens(r.allergens, r.allergenOther))}</td>
</tr>`).join('')
const externalRows = payload.externals.map((r) => `
<tr>
<td style="padding:6px 10px;border:1px solid #e2e8f0;">External${r.project?.name ? ` (with ${escapeHtml(r.project.name)})` : ''}</td>
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${escapeHtml(r.name)}${r.roleNote ? ` — <em>${escapeHtml(r.roleNote)}</em>` : ''}</td>
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${r.dish ? escapeHtml(r.dish.name) : '<em style="color:#94a3b8;">not picked</em>'}</td>
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${escapeHtml(formatAllergens(r.allergens, r.allergenOther))}</td>
</tr>`).join('')
const html = `
<!DOCTYPE html>
<html><body style="font-family:system-ui,-apple-system,sans-serif;background:#f8fafc;padding:24px;">
<div style="max-width:760px;margin:0 auto;background:white;border-radius:12px;padding:32px;">
<h2 style="margin:0 0 8px;color:#0f172a;">Lunch manifest</h2>
${payload.event?.eventAt ? `<p style="color:#475569;">${fmt.format(payload.event.eventAt)}${payload.event.venue ? ` · ${escapeHtml(payload.event.venue)}` : ''}</p>` : ''}
<p><strong>${payload.summary.picked}/${payload.summary.total} picked</strong>${payload.summary.missing ? ` (${payload.summary.missing} missing)` : ''}.</p>
<h3 style="margin-top:24px;">Dishes</h3>
<ul>${dishLines || '<li>None picked yet</li>'}</ul>
${dietaryLines ? `<h3>Dietary needs</h3><ul>${dietaryLines}</ul>` : ''}
<h3>Allergens</h3>
<ul>${allergenLines || '<li>None reported</li>'}</ul>
<h3 style="margin-top:24px;">Per-attendee</h3>
<table style="border-collapse:collapse;width:100%;font-size:14px;">
<thead><tr>
<th style="padding:6px 10px;background:#f1f5f9;border:1px solid #e2e8f0;text-align:left;">Team</th>
<th style="padding:6px 10px;background:#f1f5f9;border:1px solid #e2e8f0;text-align:left;">Name</th>
<th style="padding:6px 10px;background:#f1f5f9;border:1px solid #e2e8f0;text-align:left;">Dish</th>
<th style="padding:6px 10px;background:#f1f5f9;border:1px solid #e2e8f0;text-align:left;">Allergies</th>
</tr></thead>
<tbody>${memberRows}${externalRows}</tbody>
</table>
</div>
</body></html>`.trim()
const text = `${payload.summary.picked}/${payload.summary.total} picked. See HTML version for the full manifest.`
for (const to of recipients) {
await sendEmail({ to, subject, text, html })
}
}

View File

@@ -79,7 +79,7 @@ describe('dish CRUD', () => {
lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'], sortOrder: 0,
})
const dishes = await caller.listDishes({ lunchEventId: event.id })
expect(dishes.map((d) => d.name)).toEqual(['Risotto', 'Sea bass'])
expect(dishes.map((d: { name: string }) => d.name)).toEqual(['Risotto', 'Sea bass'])
})
it('updateDish patches name + tags', async () => {
@@ -144,7 +144,7 @@ describe('dish CRUD', () => {
],
})
const dishes = await caller.listDishes({ lunchEventId: event.id })
expect(dishes.map((d) => d.name)).toEqual(['b', 'a'])
expect(dishes.map((d: { name: string }) => d.name)).toEqual(['b', 'a'])
})
})
@@ -168,8 +168,8 @@ describe('external attendees CRUD', () => {
})
const list = await caller.listExternals({ lunchEventId: event.id })
expect(list).toHaveLength(2)
expect(list.find((e) => e.name === 'Princess Albert')?.projectId).toBeNull()
expect(list.find((e) => e.name === 'Speaker Smith')?.projectId).toBe(project.id)
expect(list.find((e: { name: string }) => e.name === 'Princess Albert')?.projectId).toBeNull()
expect(list.find((e: { name: string }) => e.name === 'Speaker Smith')?.projectId).toBe(project.id)
})
it('updateExternal patches fields including dishId + allergens', async () => {
@@ -197,7 +197,7 @@ describe('external attendees CRUD', () => {
const ext = await caller.createExternal({ lunchEventId: event.id, name: 'tmp' })
await caller.deleteExternal({ externalId: ext.id })
const list = await caller.listExternals({ lunchEventId: event.id })
expect(list.find((e) => e.id === ext.id)).toBeUndefined()
expect(list.find((e: { id: string }) => e.id === ext.id)).toBeUndefined()
})
it('rejects non-admin createExternal', async () => {
@@ -308,8 +308,8 @@ describe('lunch.getTeamPicks', () => {
})
const picks = await caller.getTeamPicks({ projectId: project.id })
expect(picks).toHaveLength(2)
expect(picks.find((p) => p.userId === m1.id)?.hasPicked).toBe(true)
expect(picks.find((p) => p.userId === m2.id)?.hasPicked).toBe(false)
expect(picks.find((p: { userId: string }) => p.userId === m1.id)?.hasPicked).toBe(true)
expect(picks.find((p: { userId: string }) => p.userId === m2.id)?.hasPicked).toBe(false)
})
it('rejects non-team-member callers', async () => {

View File

@@ -8,6 +8,7 @@ import {
uid,
} from '../helpers'
import { lunchRouter } from '@/server/routers/lunch'
import type { UserRole } from '@prisma/client'
const programIds: string[] = []
const userIds: string[] = []
@@ -77,7 +78,7 @@ async function setupTeam(opts: {
return { program, lead, member, admin, project, attendingMember: am, dish, event }
}
function callerFor(user: { id: string; email: string; role: string }) {
function callerFor(user: { id: string; email: string; role: UserRole }) {
return createCaller(lunchRouter, user)
}