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

@@ -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)
}