diff --git a/src/lib/email.ts b/src/lib/email.ts index c0fc5e1..018a742 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -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 + dietaryCounts: Record + allergenCounts: Record + 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 { + 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 = ` + + +
+

Pick your lunch dish

+

Hi ${escapeHtml(opts.memberName)},

+

You haven't picked your lunch dish for the Monaco Ocean Protection Challenge grand finale yet.

+

+ Event: ${fmt.format(opts.eventAt)} (Europe/Monaco)
+ ${opts.venue ? `Venue: ${escapeHtml(opts.venue)}
` : ''} + Deadline to pick: ${fmt.format(opts.changeDeadline)} +

+

+ Open the picker +

+

+ If you have any questions, reply to this email and we'll help. +

+
+`.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 { + 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]) => `
  • ${n}× ${escapeHtml(name)}
  • `).join('') + const dietaryLines = Object.entries(payload.dietaryCounts) + .map(([name, n]) => `
  • ${n}× ${name.replace('_', ' ').toLowerCase()}
  • `).join('') + const allergenLines = Object.entries(payload.allergenCounts) + .sort(([, a], [, b]) => b - a) + .map(([name, n]) => `
  • ${n}× ${name.replace('_', ' ').toLowerCase()}
  • `).join('') + const formatAllergens = (allergens: string[], other: string | null) => + [...allergens.map(a => a.replace('_', ' ').toLowerCase()), other].filter(Boolean).join(', ') + const memberRows = payload.members.map((r) => ` + + ${escapeHtml(r.project?.name ?? '')} + ${escapeHtml(r.name)} + ${r.dish ? escapeHtml(r.dish.name) : 'not picked'} + ${escapeHtml(formatAllergens(r.allergens, r.allergenOther))} + `).join('') + const externalRows = payload.externals.map((r) => ` + + External${r.project?.name ? ` (with ${escapeHtml(r.project.name)})` : ''} + ${escapeHtml(r.name)}${r.roleNote ? ` — ${escapeHtml(r.roleNote)}` : ''} + ${r.dish ? escapeHtml(r.dish.name) : 'not picked'} + ${escapeHtml(formatAllergens(r.allergens, r.allergenOther))} + `).join('') + const html = ` + + +
    +

    Lunch manifest

    + ${payload.event?.eventAt ? `

    ${fmt.format(payload.event.eventAt)}${payload.event.venue ? ` · ${escapeHtml(payload.event.venue)}` : ''}

    ` : ''} +

    ${payload.summary.picked}/${payload.summary.total} picked${payload.summary.missing ? ` (${payload.summary.missing} missing)` : ''}.

    +

    Dishes

    +
      ${dishLines || '
    • None picked yet
    • '}
    + ${dietaryLines ? `

    Dietary needs

      ${dietaryLines}
    ` : ''} +

    Allergens

    +
      ${allergenLines || '
    • None reported
    • '}
    +

    Per-attendee

    + + + + + + + + ${memberRows}${externalRows} +
    TeamNameDishAllergies
    +
    +`.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 }) + } +} + diff --git a/tests/unit/lunch-router.test.ts b/tests/unit/lunch-router.test.ts index bf49839..501806e 100644 --- a/tests/unit/lunch-router.test.ts +++ b/tests/unit/lunch-router.test.ts @@ -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 () => { diff --git a/tests/unit/lunch-upsert-pick.test.ts b/tests/unit/lunch-upsert-pick.test.ts index 75cb89e..61dff57 100644 --- a/tests/unit/lunch-upsert-pick.test.ts +++ b/tests/unit/lunch-upsert-pick.test.ts @@ -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) }