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:
139
src/lib/email.ts
139
src/lib/email.ts
@@ -2643,3 +2643,142 @@ export async function sendFinalistConfirmationEmail(
|
|||||||
const template = getFinalistConfirmationTemplate(name || '', projectTitle, deadline.toISOString(), confirmUrl)
|
const template = getFinalistConfirmationTemplate(name || '', projectTitle, deadline.toISOString(), confirmUrl)
|
||||||
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ describe('dish CRUD', () => {
|
|||||||
lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'], sortOrder: 0,
|
lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'], sortOrder: 0,
|
||||||
})
|
})
|
||||||
const dishes = await caller.listDishes({ lunchEventId: event.id })
|
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 () => {
|
it('updateDish patches name + tags', async () => {
|
||||||
@@ -144,7 +144,7 @@ describe('dish CRUD', () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
const dishes = await caller.listDishes({ lunchEventId: event.id })
|
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 })
|
const list = await caller.listExternals({ lunchEventId: event.id })
|
||||||
expect(list).toHaveLength(2)
|
expect(list).toHaveLength(2)
|
||||||
expect(list.find((e) => e.name === 'Princess Albert')?.projectId).toBeNull()
|
expect(list.find((e: { name: string }) => 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 === 'Speaker Smith')?.projectId).toBe(project.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updateExternal patches fields including dishId + allergens', async () => {
|
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' })
|
const ext = await caller.createExternal({ lunchEventId: event.id, name: 'tmp' })
|
||||||
await caller.deleteExternal({ externalId: ext.id })
|
await caller.deleteExternal({ externalId: ext.id })
|
||||||
const list = await caller.listExternals({ lunchEventId: event.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 () => {
|
it('rejects non-admin createExternal', async () => {
|
||||||
@@ -308,8 +308,8 @@ describe('lunch.getTeamPicks', () => {
|
|||||||
})
|
})
|
||||||
const picks = await caller.getTeamPicks({ projectId: project.id })
|
const picks = await caller.getTeamPicks({ projectId: project.id })
|
||||||
expect(picks).toHaveLength(2)
|
expect(picks).toHaveLength(2)
|
||||||
expect(picks.find((p) => p.userId === m1.id)?.hasPicked).toBe(true)
|
expect(picks.find((p: { userId: string }) => 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 === m2.id)?.hasPicked).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects non-team-member callers', async () => {
|
it('rejects non-team-member callers', async () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
uid,
|
uid,
|
||||||
} from '../helpers'
|
} from '../helpers'
|
||||||
import { lunchRouter } from '@/server/routers/lunch'
|
import { lunchRouter } from '@/server/routers/lunch'
|
||||||
|
import type { UserRole } from '@prisma/client'
|
||||||
|
|
||||||
const programIds: string[] = []
|
const programIds: string[] = []
|
||||||
const userIds: string[] = []
|
const userIds: string[] = []
|
||||||
@@ -77,7 +78,7 @@ async function setupTeam(opts: {
|
|||||||
return { program, lead, member, admin, project, attendingMember: am, dish, event }
|
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)
|
return createCaller(lunchRouter, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user