Bite-sized TDD tasks covering schema migration, auto-create hook, lunch router (admin CRUD + mixed-permission upsertPick + member reads + manifest + CSV export + recap), email templates, two cron endpoints, five-card admin UI on Logistics → Lunch tab, applicant dashboard banner + picker, project-page externals strip, and the edition-settings cleanup. Cross-references the design spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
122 KiB
PR 6: Lunch Event Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the placeholder Lunch tab on /admin/logistics with a working flow: admins configure a single per-edition lunch event (date, venue, dishes, change deadline, recipients), attendees pick a dish + log allergies, and the manifest is reviewable in-app, exportable to CSV, and emailed to admins at the change deadline.
Architecture: Four new Prisma models (LunchEvent 1:1 with Program, Dish per event, MemberLunchPick 1:1 with AttendingMember, ExternalAttendee per event with optional projectId) plus two enums (DietaryTag, Allergen). One new tRPC router (lunch) carrying admin CRUD, a mixed-permission upsertPick procedure (member-self / team-lead / admin), and member reads for the dashboard banner + team-wide visibility. Two cron endpoints (reminders + recap) reuse the /api/cron/* pattern. Email templates land inline in src/lib/email.ts. The five-card admin UI sits on the existing Logistics → Lunch tab; the member picker extends AttendingMembersCard on the applicant dashboard.
Tech Stack: Prisma 6 + PostgreSQL (additive migration), tRPC 11 with Zod, Vitest 4 sequential pool, NextAuth 5 RBAC via the existing procedure middleware, shadcn/ui for cards/tables/dialogs, nodemailer via the existing sendEmail helper.
Spec: docs/superpowers/specs/2026-04-29-pr6-lunch-event-design.md
File map
Create:
src/server/routers/lunch.ts— router with all admin + member proceduressrc/server/services/lunch-pick-sync.ts—ensureLunchPickForAttendingMemberhelpersrc/server/services/lunch-recap.ts— manifest aggregation + recap payload buildersrc/app/api/cron/lunch-reminders/route.tssrc/app/api/cron/lunch-recap/route.tssrc/components/admin/logistics/lunch-tab.tsx— orchestrates the five cardssrc/components/admin/logistics/lunch-event-config.tsxsrc/components/admin/logistics/lunch-dishes.tsxsrc/components/admin/logistics/lunch-manifest.tsxsrc/components/admin/logistics/lunch-externals.tsxsrc/components/admin/logistics/lunch-recap-actions.tsxsrc/components/applicant/lunch-banner.tsxsrc/components/applicant/lunch-pick-form.tsx— used insideAttendingMembersCardrowssrc/components/applicant/external-attendees-strip.tsx— read-only strip on the project pagetests/unit/lunch-router.test.tstests/unit/lunch-upsert-pick.test.tstests/unit/lunch-recap.test.tstests/unit/lunch-cron.test.tstests/unit/lunch-pick-sync.test.ts
Modify:
prisma/schema.prisma— new models, enums, back-refs onProgram/AttendingMember/Projectsrc/server/routers/_app.ts— mountlunchroutersrc/server/routers/finalist.ts— wireensureLunchPickForAttendingMemberinto the attendee-write pathssrc/lib/email.ts— append two new template functionssrc/app/(admin)/admin/logistics/page.tsx— un-disable the Lunch tab trigger and mount<LunchTab>src/components/applicant/attending-members-card.tsx— embed<LunchPickForm>per rowsrc/app/(applicant)/applicant/page.tsx— render<LunchBanner>above the attending-members cardsrc/app/(applicant)/applicant/projects/[projectId]/page.tsx(or equivalent) — render<ExternalAttendeesStrip>(verify exact path during Task 21)src/components/admin/settings/edition-settings-tab.tsx— drop the Lunch line from the "Coming soon" card
Task 1: Schema migration — models, enums, back-refs
Files:
-
Modify:
prisma/schema.prisma -
Generate: a new migration via
npx prisma migrate dev --name add_lunch_event -
Step 1: Add the two enums near the other domain enums in
schema.prisma(right afterWaitlistEntryStatusis a reasonable home):
enum DietaryTag {
VEGETARIAN
VEGAN
GLUTEN_FREE
PESCATARIAN
}
enum Allergen {
GLUTEN
CRUSTACEANS
EGGS
FISH
PEANUTS
SOYBEANS
MILK
TREE_NUTS
CELERY
MUSTARD
SESAME
SULPHITES
LUPIN
MOLLUSCS
}
- Step 2: Add the four models in the same logistics section (right after
FlightDetail/VisaApplication):
model LunchEvent {
id String @id @default(cuid())
programId String @unique
enabled Boolean @default(false)
eventAt DateTime?
endAt DateTime?
venue String?
notes String? @db.Text
changeCutoffHours Int @default(48)
reminderHoursBeforeDeadline Int?
cronEnabled Boolean @default(true)
extraRecipients String[] @default([])
reminderSentAt DateTime?
recapSentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
dishes Dish[]
externalAttendees ExternalAttendee[]
}
model Dish {
id String @id @default(cuid())
lunchEventId String
name String
sortOrder Int @default(0)
dietaryTags DietaryTag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
memberPicks MemberLunchPick[]
externals ExternalAttendee[]
@@index([lunchEventId])
}
model MemberLunchPick {
id String @id @default(cuid())
attendingMemberId String @unique
dishId String?
allergens Allergen[] @default([])
allergenOther String?
pickedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
@@index([dishId])
}
model ExternalAttendee {
id String @id @default(cuid())
lunchEventId String
projectId String?
name String
email String?
roleNote String?
dishId String?
allergens Allergen[] @default([])
allergenOther String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
@@index([lunchEventId])
@@index([projectId])
}
- Step 3: Add the three back-refs on existing models. Find them by searching
schema.prismafor the existing relation lines:
In model Program { ... } (just below hotel Hotel? at the bottom of the relation block):
lunchEvent LunchEvent?
In model AttendingMember { ... } (just below visaApplication VisaApplication?):
lunchPick MemberLunchPick?
In model Project { ... } (just below finalistAttendances AttendingMember[]):
externalLunchAttendees ExternalAttendee[]
- Step 4: Generate the migration
npx prisma migrate dev --name add_lunch_event
Expected: a new folder under prisma/migrations/ with a migration.sql that creates the four new tables, the two enums, and adds no columns to existing tables (back-refs are Prisma-side only).
- Step 5: Regenerate the client
npx prisma generate
- Step 6: Typecheck
npm run typecheck
Expected: clean. (No code references the new models yet.)
- Step 7: Commit
git add prisma/schema.prisma prisma/migrations
git commit -m "feat: schema for lunch event, dishes, picks, externals"
Task 2: Auto-create MemberLunchPick on attending-member writes (TDD)
Files:
-
Create:
src/server/services/lunch-pick-sync.ts -
Modify:
src/server/routers/finalist.ts(the existingconfirm,editAttendees,unconfirmpaths that touchAttendingMember) -
Create:
tests/unit/lunch-pick-sync.test.ts -
Step 1: Locate the existing attendee write paths
Run:
grep -n "AttendingMember" src/server/routers/finalist.ts | head -20
Confirm which functions create / delete AttendingMember rows. Expected hits: the confirm mutation and the editAttendees mutation. Note the file:line range you'll need to touch.
- Step 2: Failing tests
Create tests/unit/lunch-pick-sync.test.ts:
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma } from '../setup'
import {
createTestUser,
createTestProgram,
createTestCompetition,
createTestRound,
cleanupTestData,
uid,
} from '../helpers'
import { ensureLunchPickForAttendingMember } from '@/server/services/lunch-pick-sync'
describe('ensureLunchPickForAttendingMember', () => {
let programId: string
let userId: string
let projectId: string
let confirmationId: string
beforeAll(async () => {
const program = await createTestProgram()
programId = program.id
const competition = await createTestCompetition(programId)
await createTestRound(competition.id, 'LIVE_FINAL')
const user = await createTestUser('APPLICANT')
userId = user.id
const project = await prisma.project.create({
data: { programId, name: `lunch-sync-${uid()}`, category: 'IMPACT' },
})
projectId = project.id
const confirmation = await prisma.finalistConfirmation.create({
data: {
projectId,
category: 'IMPACT',
status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000),
token: `tok-${uid()}`,
},
})
confirmationId = confirmation.id
})
afterAll(async () => {
await cleanupTestData()
})
it('creates an empty MemberLunchPick when a LunchEvent exists', async () => {
await prisma.lunchEvent.create({ data: { programId } })
const member = await prisma.attendingMember.create({
data: { confirmationId, userId },
})
await ensureLunchPickForAttendingMember(prisma, member.id)
const pick = await prisma.memberLunchPick.findUnique({
where: { attendingMemberId: member.id },
})
expect(pick).not.toBeNull()
expect(pick?.dishId).toBeNull()
expect(pick?.pickedAt).toBeNull()
})
it('is idempotent — calling twice does not create a second pick', async () => {
const member = await prisma.attendingMember.findFirst({
where: { confirmation: { projectId } },
})
if (!member) throw new Error('expected member from previous test')
await ensureLunchPickForAttendingMember(prisma, member.id)
const picks = await prisma.memberLunchPick.findMany({
where: { attendingMemberId: member.id },
})
expect(picks).toHaveLength(1)
})
it('no-ops when no LunchEvent exists for the program', async () => {
const program2 = await createTestProgram()
const competition2 = await createTestCompetition(program2.id)
await createTestRound(competition2.id, 'LIVE_FINAL')
const project2 = await prisma.project.create({
data: { programId: program2.id, name: `np-${uid()}`, category: 'IMPACT' },
})
const conf2 = await prisma.finalistConfirmation.create({
data: {
projectId: project2.id,
category: 'IMPACT',
status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000),
token: `tok-${uid()}`,
},
})
const u2 = await createTestUser('APPLICANT')
const member = await prisma.attendingMember.create({
data: { confirmationId: conf2.id, userId: u2.id },
})
await ensureLunchPickForAttendingMember(prisma, member.id)
const pick = await prisma.memberLunchPick.findUnique({
where: { attendingMemberId: member.id },
})
expect(pick).toBeNull()
})
})
- Step 3: Run, expect failure (file does not exist):
npx vitest run tests/unit/lunch-pick-sync.test.ts
Expected: import error / module not found.
- Step 4: Implement the helper
Create src/server/services/lunch-pick-sync.ts:
import type { PrismaClient } from '@prisma/client'
/**
* Ensure a MemberLunchPick row exists for the given AttendingMember.
* No-ops when the parent program has no LunchEvent.
* Safe to call repeatedly — idempotent on the unique attendingMemberId.
*/
export async function ensureLunchPickForAttendingMember(
prisma: PrismaClient,
attendingMemberId: string,
): Promise<void> {
const member = await prisma.attendingMember.findUnique({
where: { id: attendingMemberId },
select: {
id: true,
confirmation: { select: { project: { select: { programId: true } } } },
lunchPick: { select: { id: true } },
},
})
if (!member) return
if (member.lunchPick) return
const programId = member.confirmation.project.programId
const lunchEvent = await prisma.lunchEvent.findUnique({
where: { programId },
select: { id: true },
})
if (!lunchEvent) return
await prisma.memberLunchPick.create({
data: { attendingMemberId: member.id },
})
}
- Step 5: Run, expect green
npx vitest run tests/unit/lunch-pick-sync.test.ts
- Step 6: Wire into
finalist.ts
In src/server/routers/finalist.ts, after every prisma.attendingMember.create({...}) call, call await ensureLunchPickForAttendingMember(ctx.prisma, member.id). Confirm the existing import block at the top of the file gets the new import:
import { ensureLunchPickForAttendingMember } from '@/server/services/lunch-pick-sync'
For each create site (typical example):
const member = await ctx.prisma.attendingMember.create({ data: { confirmationId, userId } })
await ensureLunchPickForAttendingMember(ctx.prisma, member.id)
- Step 7: Run the full test suite
npx vitest run
Expected: all green (existing finalist tests should still pass — the helper is a no-op for programs without a LunchEvent).
- Step 8: Commit
git add src/server/services/lunch-pick-sync.ts src/server/routers/finalist.ts tests/unit/lunch-pick-sync.test.ts
git commit -m "feat: auto-create MemberLunchPick on attendee writes"
Task 3: lunch.getEvent + lunch.updateEvent (TDD)
Files:
-
Create:
src/server/routers/lunch.ts -
Modify:
src/server/routers/_app.ts -
Create:
tests/unit/lunch-router.test.ts -
Step 1: Failing tests at the top of
tests/unit/lunch-router.test.ts:
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma } from '../setup'
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
import { createCaller } from '../helpers'
import * as lunchRouter from '@/server/routers/lunch'
describe('lunch.getEvent', () => {
it('lazily creates a LunchEvent on first call', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const result = await caller.getEvent({ programId: program.id })
expect(result.programId).toBe(program.id)
expect(result.enabled).toBe(false)
expect(result.changeCutoffHours).toBe(48)
const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
expect(row).not.toBeNull()
})
it('returns the same row on subsequent calls', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const a = await caller.getEvent({ programId: program.id })
const b = await caller.getEvent({ programId: program.id })
expect(a.id).toBe(b.id)
})
})
describe('lunch.updateEvent', () => {
it('patches an arbitrary subset of fields', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
await caller.getEvent({ programId: program.id })
const updated = await caller.updateEvent({
programId: program.id,
enabled: true,
eventAt: new Date('2026-06-28T12:30:00Z'),
venue: 'Hôtel Hermitage',
changeCutoffHours: 24,
extraRecipients: ['caterer@example.com'],
})
expect(updated.enabled).toBe(true)
expect(updated.venue).toBe('Hôtel Hermitage')
expect(updated.changeCutoffHours).toBe(24)
expect(updated.extraRecipients).toEqual(['caterer@example.com'])
})
it('rejects non-admin callers', async () => {
const program = await createTestProgram()
const member = await createTestUser('APPLICANT')
const caller = createCaller(lunchRouter, member)
await expect(caller.updateEvent({ programId: program.id, enabled: true })).rejects.toThrow()
})
})
afterAll(async () => {
await cleanupTestData()
})
-
Step 2: Run, expect failure — module not found.
-
Step 3: Create the router skeleton
src/server/routers/lunch.ts:
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '@/server/trpc'
export const lunchRouter = router({
getEvent: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const existing = await ctx.prisma.lunchEvent.findUnique({
where: { programId: input.programId },
})
if (existing) return existing
return ctx.prisma.lunchEvent.create({ data: { programId: input.programId } })
}),
updateEvent: adminProcedure
.input(
z.object({
programId: z.string(),
enabled: z.boolean().optional(),
eventAt: z.date().nullable().optional(),
endAt: z.date().nullable().optional(),
venue: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
changeCutoffHours: z.number().int().min(0).max(720).optional(),
reminderHoursBeforeDeadline: z.number().int().min(0).max(720).nullable().optional(),
cronEnabled: z.boolean().optional(),
extraRecipients: z.array(z.string().email()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { programId, ...patch } = input
// Ensure the event exists (lazy create) before patching
await ctx.prisma.lunchEvent.upsert({
where: { programId },
create: { programId },
update: {},
})
const updated = await ctx.prisma.lunchEvent.update({
where: { programId },
data: patch,
})
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_EVENT_UPDATED',
entityType: 'LunchEvent',
entityId: updated.id,
actorId: ctx.session.user.id,
detailsJson: patch,
},
})
return updated
}),
})
- Step 4: Mount the router in
src/server/routers/_app.ts:
import { lunchRouter } from './lunch'
// ...
export const appRouter = router({
// ...existing routers...
lunch: lunchRouter,
})
- Step 5: Run tests, expect green
npx vitest run tests/unit/lunch-router.test.ts
- Step 6: Commit
git add src/server/routers/lunch.ts src/server/routers/_app.ts tests/unit/lunch-router.test.ts
git commit -m "feat: lunch.getEvent + lunch.updateEvent procedures"
Task 4: Dish CRUD (TDD)
Files:
-
Modify:
src/server/routers/lunch.ts -
Modify:
tests/unit/lunch-router.test.ts -
Step 1: Failing tests appended to
lunch-router.test.ts:
describe('dish CRUD', () => {
it('createDish + listDishes returns dishes ordered by sortOrder', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
await caller.createDish({ lunchEventId: event.id, name: 'Sea bass', dietaryTags: ['PESCATARIAN'], sortOrder: 1 })
await caller.createDish({ 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'])
})
it('updateDish patches name + tags', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
const dish = await caller.createDish({ lunchEventId: event.id, name: 'A', dietaryTags: [] })
const updated = await caller.updateDish({ dishId: dish.id, name: 'B', dietaryTags: ['VEGAN'] })
expect(updated.name).toBe('B')
expect(updated.dietaryTags).toEqual(['VEGAN'])
})
it('deleteDish sets dishId=null on existing picks', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
const dish = await caller.createDish({ lunchEventId: event.id, name: 'X', dietaryTags: [] })
// Stand up an attending member with a pick referencing this dish
const user = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `proj-${uid()}`, category: 'IMPACT' },
})
const conf = await prisma.finalistConfirmation.create({
data: { projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}` },
})
const member = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: user.id },
})
await prisma.memberLunchPick.create({
data: { attendingMemberId: member.id, dishId: dish.id, pickedAt: new Date() },
})
await caller.deleteDish({ dishId: dish.id })
const pick = await prisma.memberLunchPick.findUnique({
where: { attendingMemberId: member.id },
})
expect(pick?.dishId).toBeNull()
})
it('reorderDishes commits new sortOrder values', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
const a = await caller.createDish({ lunchEventId: event.id, name: 'a', dietaryTags: [], sortOrder: 0 })
const b = await caller.createDish({ lunchEventId: event.id, name: 'b', dietaryTags: [], sortOrder: 1 })
await caller.reorderDishes({ ordered: [{ dishId: b.id, sortOrder: 0 }, { dishId: a.id, sortOrder: 1 }] })
const dishes = await caller.listDishes({ lunchEventId: event.id })
expect(dishes.map(d => d.name)).toEqual(['b', 'a'])
})
})
-
Step 2: Run, expect failure.
-
Step 3: Add procedures to
lunch.tsinside the existingrouter({ ... }):
const dietaryTags = z.array(z.enum(['VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN']))
listDishes: adminProcedure
.input(z.object({ lunchEventId: z.string() }))
.query(({ ctx, input }) =>
ctx.prisma.dish.findMany({
where: { lunchEventId: input.lunchEventId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
}),
),
createDish: adminProcedure
.input(z.object({
lunchEventId: z.string(),
name: z.string().min(1).max(200),
dietaryTags,
sortOrder: z.number().int().optional(),
}))
.mutation(async ({ ctx, input }) => {
const dish = await ctx.prisma.dish.create({
data: {
lunchEventId: input.lunchEventId,
name: input.name,
dietaryTags: input.dietaryTags,
sortOrder: input.sortOrder ?? 0,
},
})
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_DISH_CREATED',
entityType: 'Dish',
entityId: dish.id,
actorId: ctx.session.user.id,
detailsJson: { name: dish.name, dietaryTags: dish.dietaryTags },
},
})
return dish
}),
updateDish: adminProcedure
.input(z.object({
dishId: z.string(),
name: z.string().min(1).max(200).optional(),
dietaryTags: dietaryTags.optional(),
sortOrder: z.number().int().optional(),
}))
.mutation(async ({ ctx, input }) => {
const { dishId, ...patch } = input
const dish = await ctx.prisma.dish.update({ where: { id: dishId }, data: patch })
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_DISH_UPDATED',
entityType: 'Dish',
entityId: dish.id,
actorId: ctx.session.user.id,
detailsJson: patch,
},
})
return dish
}),
deleteDish: adminProcedure
.input(z.object({ dishId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dish = await ctx.prisma.dish.delete({ where: { id: input.dishId } })
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_DISH_DELETED',
entityType: 'Dish',
entityId: dish.id,
actorId: ctx.session.user.id,
detailsJson: { name: dish.name },
},
})
return { ok: true as const }
}),
reorderDishes: adminProcedure
.input(z.object({
ordered: z.array(z.object({ dishId: z.string(), sortOrder: z.number().int() })),
}))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.ordered.map(({ dishId, sortOrder }) =>
ctx.prisma.dish.update({ where: { id: dishId }, data: { sortOrder } }),
),
)
return { ok: true as const }
}),
-
Step 4: Run, expect green.
-
Step 5: Commit
git add src/server/routers/lunch.ts tests/unit/lunch-router.test.ts
git commit -m "feat: dish CRUD on lunch router"
Task 5: External attendees CRUD (TDD)
Files:
-
Modify:
src/server/routers/lunch.ts -
Modify:
tests/unit/lunch-router.test.ts -
Step 1: Failing tests:
describe('external attendees CRUD', () => {
it('listExternals returns standalone + project-attached entries', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
await caller.createExternal({
lunchEventId: event.id, name: 'Princess Albert',
roleNote: 'Foundation rep',
})
await caller.createExternal({
lunchEventId: event.id, name: 'Speaker Smith',
projectId: project.id, email: 's@example.com',
})
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)
})
it('updateExternal patches fields including dishId + allergens', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
const dish = await caller.createDish({ lunchEventId: event.id, name: 'Steak', dietaryTags: [] })
const ext = await caller.createExternal({ lunchEventId: event.id, name: 'X' })
const updated = await caller.updateExternal({
externalId: ext.id, dishId: dish.id, allergens: ['GLUTEN', 'TREE_NUTS'],
allergenOther: 'sulphites in red wine',
})
expect(updated.dishId).toBe(dish.id)
expect(updated.allergens).toEqual(['GLUTEN', 'TREE_NUTS'])
})
it('deleteExternal removes the row', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
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()
})
it('rejects non-admin callers', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const adminCaller = createCaller(lunchRouter, admin)
const event = await adminCaller.getEvent({ programId: program.id })
const member = await createTestUser('APPLICANT')
const memberCaller = createCaller(lunchRouter, member)
await expect(
memberCaller.createExternal({ lunchEventId: event.id, name: 'nope' }),
).rejects.toThrow()
})
})
-
Step 2: Run, expect failure.
-
Step 3: Add procedures:
const allergens = z.array(z.enum([
'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK',
'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS',
]))
listExternals: adminProcedure
.input(z.object({ lunchEventId: z.string() }))
.query(({ ctx, input }) =>
ctx.prisma.externalAttendee.findMany({
where: { lunchEventId: input.lunchEventId },
orderBy: { createdAt: 'asc' },
include: { project: { select: { id: true, name: true } } },
}),
),
createExternal: adminProcedure
.input(z.object({
lunchEventId: z.string(),
name: z.string().min(1).max(200),
email: z.string().email().optional(),
projectId: z.string().nullable().optional(),
roleNote: z.string().max(500).optional(),
dishId: z.string().nullable().optional(),
allergens: allergens.optional(),
allergenOther: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
const ext = await ctx.prisma.externalAttendee.create({ data: input })
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_EXTERNAL_CREATED', entityType: 'ExternalAttendee',
entityId: ext.id, actorId: ctx.session.user.id,
detailsJson: { name: ext.name, projectId: ext.projectId },
},
})
return ext
}),
updateExternal: adminProcedure
.input(z.object({
externalId: z.string(),
name: z.string().min(1).max(200).optional(),
email: z.string().email().nullable().optional(),
projectId: z.string().nullable().optional(),
roleNote: z.string().max(500).nullable().optional(),
dishId: z.string().nullable().optional(),
allergens: allergens.optional(),
allergenOther: z.string().max(500).nullable().optional(),
}))
.mutation(async ({ ctx, input }) => {
const { externalId, ...patch } = input
const ext = await ctx.prisma.externalAttendee.update({
where: { id: externalId }, data: patch,
})
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_EXTERNAL_UPDATED', entityType: 'ExternalAttendee',
entityId: ext.id, actorId: ctx.session.user.id, detailsJson: patch,
},
})
return ext
}),
deleteExternal: adminProcedure
.input(z.object({ externalId: z.string() }))
.mutation(async ({ ctx, input }) => {
const ext = await ctx.prisma.externalAttendee.delete({ where: { id: input.externalId } })
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_EXTERNAL_DELETED', entityType: 'ExternalAttendee',
entityId: ext.id, actorId: ctx.session.user.id,
detailsJson: { name: ext.name },
},
})
return { ok: true as const }
}),
-
Step 4: Run, expect green.
-
Step 5: Commit
git add src/server/routers/lunch.ts tests/unit/lunch-router.test.ts
git commit -m "feat: external attendees CRUD"
Task 6: upsertPick mixed-permission procedure (TDD)
Files:
-
Modify:
src/server/routers/lunch.ts -
Create:
tests/unit/lunch-upsert-pick.test.ts -
Step 1: Failing tests at
tests/unit/lunch-upsert-pick.test.ts:
import { describe, it, expect, beforeEach, afterAll } from 'vitest'
import { prisma } from '../setup'
import {
createTestUser, createTestProgram, cleanupTestData, uid, createCaller,
} from '../helpers'
import * as lunchRouter from '@/server/routers/lunch'
async function setupTeam(opts: {
cutoffHours?: number
eventAt?: Date
enabled?: boolean
}) {
const program = await createTestProgram()
const lead = await createTestUser('APPLICANT')
const member = await createTestUser('APPLICANT')
const admin = await createTestUser('SUPER_ADMIN')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
await prisma.teamMember.createMany({
data: [
{ projectId: project.id, userId: lead.id, role: 'LEAD' },
{ projectId: project.id, userId: member.id, role: 'MEMBER' },
],
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
},
})
const am = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: member.id },
})
const event = await prisma.lunchEvent.create({
data: {
programId: program.id,
enabled: opts.enabled ?? true,
eventAt: opts.eventAt ?? new Date(Date.now() + 7 * 86_400_000),
changeCutoffHours: opts.cutoffHours ?? 48,
},
})
const dish = await prisma.dish.create({
data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'] },
})
await prisma.memberLunchPick.create({ data: { attendingMemberId: am.id } })
return { program, lead, member, admin, project, attendingMember: am, dish, event }
}
afterAll(async () => { await cleanupTestData() })
describe('lunch.upsertPick', () => {
it('member can edit their own pick before deadline', async () => {
const t = await setupTeam({})
const caller = createCaller(lunchRouter, t.member)
const result = await caller.upsertPick({
attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
allergens: ['GLUTEN'], allergenOther: null,
})
expect(result.dishId).toBe(t.dish.id)
expect(result.pickedAt).not.toBeNull()
const audit = await prisma.decisionAuditLog.findFirst({
where: { eventType: 'LUNCH_PICK_UPDATED', entityId: result.id },
orderBy: { createdAt: 'desc' },
})
expect(audit?.detailsJson).toMatchObject({ actorRole: 'SELF' })
})
it('team lead can edit a teammate pick before deadline', async () => {
const t = await setupTeam({})
const caller = createCaller(lunchRouter, t.lead)
const result = await caller.upsertPick({
attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
allergens: [], allergenOther: null,
})
expect(result.dishId).toBe(t.dish.id)
const audit = await prisma.decisionAuditLog.findFirst({
where: { eventType: 'LUNCH_PICK_UPDATED', entityId: result.id },
orderBy: { createdAt: 'desc' },
})
expect(audit?.detailsJson).toMatchObject({ actorRole: 'TEAM_LEAD' })
})
it('member from a different team is forbidden', async () => {
const t = await setupTeam({})
const stranger = await createTestUser('APPLICANT')
const caller = createCaller(lunchRouter, stranger)
await expect(
caller.upsertPick({ attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
allergens: [], allergenOther: null }),
).rejects.toThrow(/FORBIDDEN/)
})
it('member cannot edit their own pick after deadline', async () => {
// event is "now + 1h", cutoffHours = 24 -> deadline already passed
const t = await setupTeam({
eventAt: new Date(Date.now() + 60 * 60 * 1000), cutoffHours: 24,
})
const caller = createCaller(lunchRouter, t.member)
await expect(
caller.upsertPick({ attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
allergens: [], allergenOther: null }),
).rejects.toThrow(/deadline/i)
})
it('admin can edit after deadline; audit records ADMIN role', async () => {
const t = await setupTeam({
eventAt: new Date(Date.now() + 60 * 60 * 1000), cutoffHours: 24,
})
const caller = createCaller(lunchRouter, t.admin)
const result = await caller.upsertPick({
attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
allergens: [], allergenOther: null,
})
expect(result.dishId).toBe(t.dish.id)
const audit = await prisma.decisionAuditLog.findFirst({
where: { eventType: 'LUNCH_PICK_UPDATED', entityId: result.id },
orderBy: { createdAt: 'desc' },
})
expect(audit?.detailsJson).toMatchObject({ actorRole: 'ADMIN' })
})
})
-
Step 2: Run, expect failure.
-
Step 3: Add the procedure
In lunch.ts:
upsertPick: protectedProcedure
.input(z.object({
attendingMemberId: z.string(),
dishId: z.string().nullable(),
allergens,
allergenOther: z.string().max(500).nullable(),
}))
.mutation(async ({ ctx, input }) => {
const am = await ctx.prisma.attendingMember.findUnique({
where: { id: input.attendingMemberId },
include: {
confirmation: {
select: {
project: {
select: {
id: true,
programId: true,
teamMembers: { select: { userId: true, role: true } },
},
},
},
},
lunchPick: true,
},
})
if (!am) throw new TRPCError({ code: 'NOT_FOUND', message: 'Attending member not found' })
const userId = ctx.session.user.id
const userRole = ctx.session.user.role
const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
const isSelf = am.userId === userId
const isLead = am.confirmation.project.teamMembers.some(
(tm) => tm.userId === userId && tm.role === 'LEAD',
)
if (!isAdmin && !isSelf && !isLead) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Not allowed to edit this pick' })
}
// Cutoff check (admins skip)
if (!isAdmin) {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { programId: am.confirmation.project.programId },
select: { eventAt: true, changeCutoffHours: true },
})
if (event?.eventAt) {
const deadline = new Date(event.eventAt.getTime() - event.changeCutoffHours * 3600_000)
if (new Date() > deadline) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Past lunch change deadline. Contact an admin.',
})
}
}
}
const actorRole: 'SELF' | 'TEAM_LEAD' | 'ADMIN' = isAdmin ? 'ADMIN' : isLead && !isSelf ? 'TEAM_LEAD' : 'SELF'
const pick = await ctx.prisma.memberLunchPick.upsert({
where: { attendingMemberId: input.attendingMemberId },
create: {
attendingMemberId: input.attendingMemberId,
dishId: input.dishId,
allergens: input.allergens,
allergenOther: input.allergenOther,
pickedAt: input.dishId ? new Date() : null,
},
update: {
dishId: input.dishId,
allergens: input.allergens,
allergenOther: input.allergenOther,
pickedAt: input.dishId ? new Date() : null,
},
})
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_PICK_UPDATED', entityType: 'MemberLunchPick',
entityId: pick.id, actorId: userId,
detailsJson: {
actorRole,
dishId: input.dishId,
allergenCount: input.allergens.length,
},
},
})
return pick
}),
-
Step 4: Run, expect green.
-
Step 5: Commit
git add src/server/routers/lunch.ts tests/unit/lunch-upsert-pick.test.ts
git commit -m "feat: lunch.upsertPick with role-aware guard + cutoff"
Task 7: Member reads — getEventForMember + getTeamPicks (TDD)
Files:
-
Modify:
src/server/routers/lunch.ts -
Modify:
tests/unit/lunch-router.test.ts -
Step 1: Failing tests:
describe('lunch.getEventForMember', () => {
it('returns event details when enabled', async () => {
const program = await createTestProgram()
const member = await createTestUser('APPLICANT')
await prisma.lunchEvent.create({
data: {
programId: program.id, enabled: true,
eventAt: new Date('2026-06-28T12:30:00Z'), venue: 'Hôtel',
},
})
const caller = createCaller(lunchRouter, member)
const result = await caller.getEventForMember({ programId: program.id })
expect(result?.venue).toBe('Hôtel')
})
it('returns null when event is disabled', async () => {
const program = await createTestProgram()
const member = await createTestUser('APPLICANT')
await prisma.lunchEvent.create({
data: { programId: program.id, enabled: false },
})
const caller = createCaller(lunchRouter, member)
const result = await caller.getEventForMember({ programId: program.id })
expect(result).toBeNull()
})
})
describe('lunch.getTeamPicks', () => {
it('returns picks for all attending members of the caller team', async () => {
// caller is a TeamMember of project P, P has two AttendingMembers with picks
const program = await createTestProgram()
const lead = await createTestUser('APPLICANT')
const m1 = await createTestUser('APPLICANT')
const m2 = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
await prisma.teamMember.createMany({
data: [
{ projectId: project.id, userId: lead.id, role: 'LEAD' },
{ projectId: project.id, userId: m1.id, role: 'MEMBER' },
{ projectId: project.id, userId: m2.id, role: 'MEMBER' },
],
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
},
})
const am1 = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: m1.id },
})
const am2 = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: m2.id },
})
const event = await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const dish = await prisma.dish.create({
data: { lunchEventId: event.id, name: 'X', dietaryTags: [] },
})
await prisma.memberLunchPick.create({
data: { attendingMemberId: am1.id, dishId: dish.id, pickedAt: new Date() },
})
await prisma.memberLunchPick.create({ data: { attendingMemberId: am2.id } })
const caller = createCaller(lunchRouter, lead)
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)
})
it('rejects non-team-member callers', async () => {
const program = await createTestProgram()
const stranger = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
const caller = createCaller(lunchRouter, stranger)
await expect(
caller.getTeamPicks({ projectId: project.id }),
).rejects.toThrow(/FORBIDDEN/)
})
})
-
Step 2: Run, expect failure.
-
Step 3: Add procedures:
getEventForMember: protectedProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { programId: input.programId },
select: {
id: true, enabled: true, eventAt: true, endAt: true,
venue: true, notes: true, changeCutoffHours: true,
},
})
if (!event || !event.enabled) return null
const changeDeadline = event.eventAt
? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3600_000)
: null
return { ...event, changeDeadline }
}),
getTeamPicks: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id
const role = ctx.session.user.role
const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
if (!isAdmin) {
const tm = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, userId },
})
if (!tm) throw new TRPCError({ code: 'FORBIDDEN' })
}
const ams = await ctx.prisma.attendingMember.findMany({
where: { confirmation: { projectId: input.projectId } },
include: {
user: { select: { id: true, name: true, email: true } },
lunchPick: { include: { dish: true } },
},
})
return ams.map((am) => ({
attendingMemberId: am.id,
userId: am.user.id,
memberName: am.user.name ?? am.user.email,
dish: am.lunchPick?.dish ?? null,
allergens: am.lunchPick?.allergens ?? [],
allergenOther: am.lunchPick?.allergenOther ?? null,
hasPicked: !!am.lunchPick?.pickedAt,
}))
}),
-
Step 4: Run, expect green.
-
Step 5: Commit
git add src/server/routers/lunch.ts tests/unit/lunch-router.test.ts
git commit -m "feat: member reads — getEventForMember + getTeamPicks"
Task 8: getManifest + exportManifestCsv (TDD)
Files:
-
Modify:
src/server/routers/lunch.ts -
Modify:
tests/unit/lunch-router.test.ts -
Step 1: Failing tests:
describe('lunch.getManifest', () => {
it('returns confirmed attending members + externals with merged shape', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const m = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
},
})
const am = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: m.id },
})
const event = await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const dish = await prisma.dish.create({
data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'] },
})
await prisma.memberLunchPick.create({
data: { attendingMemberId: am.id, dishId: dish.id, pickedAt: new Date() },
})
await prisma.externalAttendee.create({
data: { lunchEventId: event.id, name: 'External Bob', dishId: dish.id },
})
const caller = createCaller(lunchRouter, admin)
const manifest = await caller.getManifest({ programId: program.id })
expect(manifest.members).toHaveLength(1)
expect(manifest.externals).toHaveLength(1)
expect(manifest.summary.picked).toBe(1)
expect(manifest.summary.missing).toBe(0)
})
it('excludes non-CONFIRMED confirmations', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const u = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id, category: 'IMPACT', status: 'PENDING',
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
},
})
await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: u.id },
})
await prisma.lunchEvent.create({ data: { programId: program.id, enabled: true } })
const caller = createCaller(lunchRouter, admin)
const manifest = await caller.getManifest({ programId: program.id })
expect(manifest.members).toHaveLength(0)
})
})
describe('lunch.exportManifestCsv', () => {
it('returns a CSV string with header + one row per attendee', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const event = await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const dish = await prisma.dish.create({
data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: [] },
})
await prisma.externalAttendee.create({
data: { lunchEventId: event.id, name: 'X Y', dishId: dish.id, allergens: ['GLUTEN'] },
})
const caller = createCaller(lunchRouter, admin)
const csv = await caller.exportManifestCsv({ programId: program.id })
expect(csv.split('\n')[0]).toBe('Type,Team,Name,Email,Dish,Allergens,Allergen notes')
expect(csv).toContain('External,,X Y,,Risotto,GLUTEN,')
})
})
-
Step 2: Run, expect failure.
-
Step 3: Implement procedures
In lunch.ts:
getManifest: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { programId: input.programId },
include: { dishes: true },
})
if (!event) return { members: [], externals: [], dishes: [], summary: { picked: 0, missing: 0, total: 0 } }
const ams = await ctx.prisma.attendingMember.findMany({
where: {
confirmation: { project: { programId: input.programId }, status: 'CONFIRMED' },
},
include: {
user: { select: { id: true, name: true, email: true } },
confirmation: { include: { project: { select: { id: true, name: true } } } },
lunchPick: { include: { dish: true } },
},
})
const externals = await ctx.prisma.externalAttendee.findMany({
where: { lunchEventId: event.id },
include: { project: { select: { id: true, name: true } }, dish: true },
})
const members = ams.map((am) => ({
kind: 'MEMBER' as const,
attendingMemberId: am.id,
userId: am.user.id,
name: am.user.name ?? am.user.email,
email: am.user.email,
project: { id: am.confirmation.project.id, name: am.confirmation.project.name },
dish: am.lunchPick?.dish ?? null,
allergens: am.lunchPick?.allergens ?? [],
allergenOther: am.lunchPick?.allergenOther ?? null,
pickedAt: am.lunchPick?.pickedAt ?? null,
}))
const ext = externals.map((e) => ({
kind: 'EXTERNAL' as const,
externalId: e.id,
name: e.name,
email: e.email,
project: e.project,
roleNote: e.roleNote,
dish: e.dish,
allergens: e.allergens,
allergenOther: e.allergenOther,
pickedAt: e.dishId ? e.updatedAt : null,
}))
const total = members.length + ext.length
const picked = members.filter(m => m.dish).length + ext.filter(e => e.dish).length
return {
event,
dishes: event.dishes,
members,
externals: ext,
summary: { total, picked, missing: total - picked },
}
}),
exportManifestCsv: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const m = await (lunchRouter as any).createCaller(ctx).getManifest({ programId: input.programId })
const escape = (s: string | null | undefined) => {
const v = s ?? ''
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v
}
const lines = [
'Type,Team,Name,Email,Dish,Allergens,Allergen notes',
...m.members.map((row: any) => [
'Member', escape(row.project?.name), escape(row.name), escape(row.email),
escape(row.dish?.name), escape((row.allergens as string[]).join(';')),
escape(row.allergenOther),
].join(',')),
...m.externals.map((row: any) => [
'External', escape(row.project?.name), escape(row.name), escape(row.email),
escape(row.dish?.name), escape((row.allergens as string[]).join(';')),
escape(row.allergenOther),
].join(',')),
]
return lines.join('\n')
}),
Note: the (lunchRouter as any).createCaller(ctx) cycle is awkward. Cleaner: extract a buildManifest(prisma, programId) helper used by both. Refactor in the next step.
- Step 4: Refactor — extract
buildManifest
Move the body of getManifest into src/server/services/lunch-recap.ts (this file will grow more in Task 9):
import type { PrismaClient } from '@prisma/client'
export async function buildManifest(prisma: PrismaClient, programId: string) {
const event = await prisma.lunchEvent.findUnique({
where: { programId },
include: { dishes: true },
})
if (!event) return { event: null, members: [], externals: [], dishes: [], summary: { picked: 0, missing: 0, total: 0 } }
// ...rest of the body unchanged...
}
Then getManifest becomes:
getManifest: adminProcedure
.input(z.object({ programId: z.string() }))
.query(({ ctx, input }) => buildManifest(ctx.prisma, input.programId)),
And exportManifestCsv calls buildManifest(ctx.prisma, input.programId) directly.
-
Step 5: Run, expect green.
-
Step 6: Commit
git add src/server/routers/lunch.ts src/server/services/lunch-recap.ts tests/unit/lunch-router.test.ts
git commit -m "feat: lunch manifest query + CSV export"
Task 9: Recap aggregation + sendRecap + preview (TDD)
Files:
-
Modify:
src/server/services/lunch-recap.ts -
Modify:
src/server/routers/lunch.ts -
Create:
tests/unit/lunch-recap.test.ts -
Step 1: Failing tests at
tests/unit/lunch-recap.test.ts:
import { describe, it, expect, afterAll, vi } from 'vitest'
import { prisma } from '../setup'
import { createTestUser, createTestProgram, cleanupTestData, uid, createCaller } from '../helpers'
import * as lunchRouter from '@/server/routers/lunch'
import { buildRecapPayload } from '@/server/services/lunch-recap'
vi.mock('@/lib/email', async () => {
const actual = await vi.importActual<any>('@/lib/email')
return { ...actual, sendLunchRecapEmail: vi.fn(async () => undefined) }
})
afterAll(async () => { await cleanupTestData() })
describe('buildRecapPayload', () => {
it('aggregates dish + dietary + allergen counts', async () => {
const program = await createTestProgram()
const event = await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const veg = await prisma.dish.create({
data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'] },
})
const fish = await prisma.dish.create({
data: { lunchEventId: event.id, name: 'Sea bass', dietaryTags: ['PESCATARIAN'] },
})
await prisma.externalAttendee.create({
data: { lunchEventId: event.id, name: 'A', dishId: veg.id, allergens: ['GLUTEN'] },
})
await prisma.externalAttendee.create({
data: { lunchEventId: event.id, name: 'B', dishId: fish.id, allergens: ['GLUTEN', 'FISH'] },
})
const payload = await buildRecapPayload(prisma, program.id)
expect(payload.dishCounts['Risotto']).toBe(1)
expect(payload.dishCounts['Sea bass']).toBe(1)
expect(payload.dietaryCounts['VEGETARIAN']).toBe(1)
expect(payload.allergenCounts['GLUTEN']).toBe(2)
})
})
describe('lunch.sendRecap', () => {
it('sends and stamps recapSentAt', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const caller = createCaller(lunchRouter, admin)
await caller.sendRecap({ programId: program.id })
const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
expect(row?.recapSentAt).not.toBeNull()
})
it('throws PRECONDITION_FAILED on second send unless forceUpdate', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true, recapSentAt: new Date() },
})
const caller = createCaller(lunchRouter, admin)
await expect(caller.sendRecap({ programId: program.id })).rejects.toThrow(/PRECONDITION_FAILED/)
await expect(caller.sendRecap({ programId: program.id, forceUpdate: true })).resolves.toBeTruthy()
})
it('writes a LUNCH_RECAP_SENT audit row', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const caller = createCaller(lunchRouter, admin)
await caller.sendRecap({ programId: program.id })
const audit = await prisma.decisionAuditLog.findFirst({
where: { eventType: 'LUNCH_RECAP_SENT' },
orderBy: { createdAt: 'desc' },
})
expect(audit).not.toBeNull()
})
})
-
Step 2: Run, expect failure.
-
Step 3: Add
buildRecapPayloadtosrc/server/services/lunch-recap.ts:
export async function buildRecapPayload(prisma: PrismaClient, programId: string) {
const m = await buildManifest(prisma, programId)
const dishCounts: Record<string, number> = {}
const dietaryCounts: Record<string, number> = {}
const allergenCounts: Record<string, number> = {}
const allRows: Array<{ dish: { name: string; dietaryTags: string[] } | null; allergens: string[] }> = [
...m.members.map((r: any) => ({ dish: r.dish, allergens: r.allergens })),
...m.externals.map((r: any) => ({ dish: r.dish, allergens: r.allergens })),
]
for (const row of allRows) {
if (row.dish) {
dishCounts[row.dish.name] = (dishCounts[row.dish.name] ?? 0) + 1
for (const tag of row.dish.dietaryTags) {
dietaryCounts[tag] = (dietaryCounts[tag] ?? 0) + 1
}
}
for (const a of row.allergens) {
allergenCounts[a] = (allergenCounts[a] ?? 0) + 1
}
}
return { event: m.event, members: m.members, externals: m.externals, dishCounts, dietaryCounts, allergenCounts, summary: m.summary }
}
- Step 4: Add procedures
In lunch.ts:
import { buildManifest, buildRecapPayload } from '@/server/services/lunch-recap'
import { sendLunchRecapEmail } from '@/lib/email'
// ...
getRecapPreview: adminProcedure
.input(z.object({ programId: z.string() }))
.query(({ ctx, input }) => buildRecapPayload(ctx.prisma, input.programId)),
sendRecap: adminProcedure
.input(z.object({ programId: z.string(), forceUpdate: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { programId: input.programId },
})
if (!event) throw new TRPCError({ code: 'NOT_FOUND' })
if (event.recapSentAt && !input.forceUpdate) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Recap already sent. Pass forceUpdate=true to resend.',
})
}
const payload = await buildRecapPayload(ctx.prisma, input.programId)
const adminUsers = await ctx.prisma.user.findMany({
where: { role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] }, email: { not: null } },
select: { email: true },
})
const recipients = [
...adminUsers.map(u => u.email!).filter(Boolean),
...event.extraRecipients,
]
await sendLunchRecapEmail(recipients, payload)
const updated = await ctx.prisma.lunchEvent.update({
where: { programId: input.programId },
data: { recapSentAt: new Date() },
})
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_RECAP_SENT', entityType: 'LunchEvent',
entityId: event.id, actorId: ctx.session.user.id,
detailsJson: { recipientCount: recipients.length, forceUpdate: !!input.forceUpdate },
},
})
return updated
}),
-
Step 5: Run, expect green (this depends on Task 10 stubbing
sendLunchRecapEmail; if Task 10 is not yet done, add a stub function insrc/lib/email.ts:export async function sendLunchRecapEmail() {}). -
Step 6: Commit
git add src/server/services/lunch-recap.ts src/server/routers/lunch.ts tests/unit/lunch-recap.test.ts src/lib/email.ts
git commit -m "feat: lunch recap aggregation + sendRecap with forceUpdate gate"
Task 10: Email templates in src/lib/email.ts
Files:
-
Modify:
src/lib/email.ts -
Step 1: Add the reminder template
Append at the bottom of email.ts (above the closing braces / exports):
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 = `
<p>Hi ${opts.memberName ?? 'there'},</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> ${opts.venue}<br/>` : ''}
<strong>Deadline to pick:</strong> ${fmt.format(opts.changeDeadline)}</p>
<p><a href="${opts.pickUrl}">Open the picker</a></p>
`
const text = `Pick your lunch dish.\nEvent: ${opts.eventAt.toISOString()}\nDeadline: ${opts.changeDeadline.toISOString()}\n${opts.pickUrl}`
await sendEmail({ to: opts.to, subject, text, html })
}
- Step 2: Add the recap template
type LunchRecapPayload = {
event: { eventAt: Date | null; venue: string | 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 }
}
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)
.map(([name, n]) => `<li>${n}× ${name}</li>`).join('')
const allergyLines = Object.entries(payload.allergenCounts)
.map(([name, n]) => `<li>${n}× ${name}</li>`).join('')
const memberRows = payload.members.map((r) => `
<tr>
<td>${r.project?.name ?? ''}</td>
<td>${r.name}</td>
<td>${r.dish?.name ?? '—'}</td>
<td>${[...r.allergens, r.allergenOther].filter(Boolean).join(', ')}</td>
</tr>
`).join('')
const externalRows = payload.externals.map((r) => `
<tr>
<td>External${r.project?.name ? ` (with ${r.project.name})` : ''}</td>
<td>${r.name}${r.roleNote ? ` — ${r.roleNote}` : ''}</td>
<td>${r.dish?.name ?? '—'}</td>
<td>${[...r.allergens, r.allergenOther].filter(Boolean).join(', ')}</td>
</tr>
`).join('')
const html = `
<h2>Lunch manifest</h2>
<p>${payload.summary.picked}/${payload.summary.total} picked${payload.summary.missing ? ` (${payload.summary.missing} missing)` : ''}</p>
<h3>Dishes</h3><ul>${dishLines}</ul>
<h3>Allergens</h3><ul>${allergyLines || '<li>None reported</li>'}</ul>
<table border="1" cellpadding="6" cellspacing="0">
<thead><tr><th>Team</th><th>Name</th><th>Dish</th><th>Allergies</th></tr></thead>
<tbody>${memberRows}${externalRows}</tbody>
</table>
`
const text = `${payload.summary.picked}/${payload.summary.total} picked. See HTML version for details.`
for (const to of recipients) {
await sendEmail({ to, subject, text, html })
}
}
- Step 3: Typecheck
npm run typecheck
- Step 4: Commit
git add src/lib/email.ts
git commit -m "feat: lunch reminder + recap email templates"
Task 11: /api/cron/lunch-reminders endpoint (TDD)
Files:
-
Create:
src/app/api/cron/lunch-reminders/route.ts -
Create:
tests/unit/lunch-cron.test.ts -
Step 1: Failing tests at
tests/unit/lunch-cron.test.ts:
import { describe, it, expect, afterAll, vi, beforeEach } from 'vitest'
import { prisma } from '../setup'
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
vi.mock('@/lib/email', async () => {
const actual = await vi.importActual<any>('@/lib/email')
return {
...actual,
sendLunchReminderEmail: vi.fn(async () => undefined),
sendLunchRecapEmail: vi.fn(async () => undefined),
}
})
afterAll(async () => { await cleanupTestData() })
async function callRoute(path: 'lunch-reminders' | 'lunch-recap') {
const mod = await import(`@/app/api/cron/${path}/route`)
const req = new Request(`http://test/${path}`, {
method: 'POST',
headers: { 'x-cron-secret': process.env.CRON_SECRET ?? 'test-secret' },
})
return mod.POST(req)
}
describe('POST /api/cron/lunch-reminders', () => {
beforeEach(() => { vi.clearAllMocks() })
it('skips events outside the reminder window', async () => {
const program = await createTestProgram()
await prisma.lunchEvent.create({
data: {
programId: program.id, enabled: true,
eventAt: new Date(Date.now() + 30 * 86_400_000),
changeCutoffHours: 48, reminderHoursBeforeDeadline: 24,
},
})
const res = await callRoute('lunch-reminders')
expect(res.status).toBe(200)
const { sendLunchReminderEmail } = await import('@/lib/email')
expect(sendLunchReminderEmail).not.toHaveBeenCalled()
})
it('sends reminders for unpicked attendees inside the window', async () => {
const program = await createTestProgram()
const u = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
},
})
const am = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: u.id },
})
await prisma.memberLunchPick.create({ data: { attendingMemberId: am.id } })
const eventAt = new Date(Date.now() + 25 * 3600_000)
await prisma.lunchEvent.create({
data: {
programId: program.id, enabled: true,
eventAt, changeCutoffHours: 24, reminderHoursBeforeDeadline: 4,
},
})
const res = await callRoute('lunch-reminders')
expect(res.status).toBe(200)
const { sendLunchReminderEmail } = await import('@/lib/email')
expect(sendLunchReminderEmail).toHaveBeenCalledTimes(1)
const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
expect(row?.reminderSentAt).not.toBeNull()
})
it('is idempotent — second invocation does not resend', async () => {
// Same event from prior test: reminderSentAt now non-null.
// Re-running should not send again. Using the same DB state.
const { sendLunchReminderEmail } = await import('@/lib/email')
vi.clearAllMocks()
const res = await callRoute('lunch-reminders')
expect(res.status).toBe(200)
expect(sendLunchReminderEmail).not.toHaveBeenCalled()
})
it('rejects without CRON_SECRET', async () => {
const mod = await import('@/app/api/cron/lunch-reminders/route')
const req = new Request('http://test/lunch-reminders', { method: 'POST' })
const res = await mod.POST(req)
expect(res.status).toBe(401)
})
})
-
Step 2: Run, expect failure.
-
Step 3: Implement the endpoint
src/app/api/cron/lunch-reminders/route.ts:
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendLunchReminderEmail } from '@/lib/email'
export async function POST(req: Request) {
if (req.headers.get('x-cron-secret') !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
const now = new Date()
const events = await prisma.lunchEvent.findMany({
where: {
enabled: true,
reminderSentAt: null,
reminderHoursBeforeDeadline: { not: null },
eventAt: { not: null },
},
})
let sent = 0
for (const event of events) {
try {
if (!event.eventAt || event.reminderHoursBeforeDeadline == null) continue
const deadline = new Date(event.eventAt.getTime() - event.changeCutoffHours * 3600_000)
const reminderAt = new Date(deadline.getTime() - event.reminderHoursBeforeDeadline * 3600_000)
if (now < reminderAt || now >= deadline) continue
const ams = await prisma.attendingMember.findMany({
where: {
confirmation: { project: { programId: event.programId }, status: 'CONFIRMED' },
lunchPick: { is: { pickedAt: null } },
},
include: { user: true },
})
for (const am of ams) {
if (!am.user.email) continue
await sendLunchReminderEmail({
to: am.user.email,
memberName: am.user.name ?? am.user.email,
eventAt: event.eventAt,
venue: event.venue,
changeDeadline: deadline,
pickUrl: `${process.env.NEXTAUTH_URL}/applicant`,
})
sent++
}
await prisma.lunchEvent.update({
where: { id: event.id }, data: { reminderSentAt: new Date() },
})
} catch (e) {
console.error('[lunch-reminders] event failed', event.id, e)
}
}
return NextResponse.json({ ok: true, sent })
}
-
Step 4: Run, expect green.
-
Step 5: Commit
git add src/app/api/cron/lunch-reminders tests/unit/lunch-cron.test.ts
git commit -m "feat: cron endpoint — lunch reminders"
Task 12: /api/cron/lunch-recap endpoint (TDD)
Files:
-
Create:
src/app/api/cron/lunch-recap/route.ts -
Modify:
tests/unit/lunch-cron.test.ts -
Step 1: Failing tests appended:
describe('POST /api/cron/lunch-recap', () => {
beforeEach(() => { vi.clearAllMocks() })
it('skips events with cronEnabled=false', async () => {
const program = await createTestProgram()
await prisma.lunchEvent.create({
data: {
programId: program.id, enabled: true, cronEnabled: false,
eventAt: new Date(Date.now() - 86_400_000), // already past
changeCutoffHours: 24,
},
})
const res = await callRoute('lunch-recap')
expect(res.status).toBe(200)
const { sendLunchRecapEmail } = await import('@/lib/email')
expect(sendLunchRecapEmail).not.toHaveBeenCalled()
})
it('skips events with recapSentAt already set', async () => {
const program = await createTestProgram()
await prisma.lunchEvent.create({
data: {
programId: program.id, enabled: true, cronEnabled: true,
eventAt: new Date(Date.now() - 86_400_000),
changeCutoffHours: 24, recapSentAt: new Date(),
},
})
const res = await callRoute('lunch-recap')
expect(res.status).toBe(200)
const { sendLunchRecapEmail } = await import('@/lib/email')
expect(sendLunchRecapEmail).not.toHaveBeenCalled()
})
it('sends recap once and stamps recapSentAt', async () => {
const program = await createTestProgram()
await createTestUser('SUPER_ADMIN') // recipient
await prisma.lunchEvent.create({
data: {
programId: program.id, enabled: true, cronEnabled: true,
eventAt: new Date(Date.now() - 86_400_000),
changeCutoffHours: 24,
},
})
const res = await callRoute('lunch-recap')
expect(res.status).toBe(200)
const { sendLunchRecapEmail } = await import('@/lib/email')
expect(sendLunchRecapEmail).toHaveBeenCalledTimes(1)
const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
expect(row?.recapSentAt).not.toBeNull()
})
})
-
Step 2: Run, expect failure.
-
Step 3: Implement the endpoint
src/app/api/cron/lunch-recap/route.ts:
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendLunchRecapEmail } from '@/lib/email'
import { buildRecapPayload } from '@/server/services/lunch-recap'
export async function POST(req: Request) {
if (req.headers.get('x-cron-secret') !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
const now = new Date()
const events = await prisma.lunchEvent.findMany({
where: {
enabled: true, cronEnabled: true, recapSentAt: null, eventAt: { not: null },
},
})
let sent = 0
for (const event of events) {
try {
if (!event.eventAt) continue
const deadline = new Date(event.eventAt.getTime() - event.changeCutoffHours * 3600_000)
if (now < deadline) continue
const payload = await buildRecapPayload(prisma, event.programId)
const adminUsers = await prisma.user.findMany({
where: { role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] }, email: { not: null } },
select: { email: true },
})
const recipients = [
...adminUsers.map(u => u.email!).filter(Boolean),
...event.extraRecipients,
]
await sendLunchRecapEmail(recipients, payload)
await prisma.lunchEvent.update({
where: { id: event.id }, data: { recapSentAt: new Date() },
})
await prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_RECAP_SENT', entityType: 'LunchEvent',
entityId: event.id, actorId: null,
detailsJson: { recipientCount: recipients.length, source: 'cron' },
},
})
sent++
} catch (e) {
console.error('[lunch-recap] event failed', event.id, e)
}
}
return NextResponse.json({ ok: true, sent })
}
-
Step 4: Run, expect green.
-
Step 5: Commit
git add src/app/api/cron/lunch-recap tests/unit/lunch-cron.test.ts
git commit -m "feat: cron endpoint — lunch recap"
Task 13: Lunch tab scaffold + un-disable trigger
Files:
-
Create:
src/components/admin/logistics/lunch-tab.tsx -
Modify:
src/app/(admin)/admin/logistics/page.tsx -
Step 1: Create the empty tab component
src/components/admin/logistics/lunch-tab.tsx:
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
export function LunchTab({ programId }: { programId: string }) {
const { data: event, isLoading } = trpc.lunch.getEvent.useQuery({ programId })
if (isLoading || !event) {
return <Skeleton className="h-48 w-full" />
}
if (!event.enabled) {
return (
<Card>
<CardHeader>
<CardTitle>Lunch is disabled</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
Toggle Lunch on from the Event configuration card to begin setup.
</p>
{/* Event config card mounts in Task 14, replacing this stub. */}
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{/* Cards mount in Tasks 14-18. */}
<p className="text-muted-foreground text-sm">Lunch tab — cards land in upcoming tasks.</p>
</div>
)
}
- Step 2: Mount it in the logistics page
In src/app/(admin)/admin/logistics/page.tsx:
- Remove
disabledfrom the Lunch<TabsTrigger>(line ~55-58). - Drop the "(soon)" span.
- Add a new
<TabsContent value="lunch"><LunchTab programId={programId} /></TabsContent>block. - Import
LunchTab.
import { LunchTab } from '@/components/admin/logistics/lunch-tab'
// ...
<TabsTrigger value="lunch">
<Salad className="mr-2 h-4 w-4" /> Lunch
</TabsTrigger>
// ...
<TabsContent value="lunch">
<LunchTab programId={programId} />
</TabsContent>
-
Step 3: Live smoke —
npm run dev, open/admin/logistics, click Lunch. Expected: empty-state card. -
Step 4: Commit
git add src/components/admin/logistics/lunch-tab.tsx src/app/\(admin\)/admin/logistics/page.tsx
git commit -m "feat: lunch tab scaffold + un-disable trigger"
Task 14: Event configuration card
Files:
-
Create:
src/components/admin/logistics/lunch-event-config.tsx -
Modify:
src/components/admin/logistics/lunch-tab.tsx -
Step 1: Build the config card
src/components/admin/logistics/lunch-event-config.tsx — uses the same blur-to-commit pattern as edition-settings-tab.tsx:20-67. Surface fields:
enabled(Switch — master)eventAt,endAt(datetime-local inputs)venue(text)notes(textarea)changeCutoffHours,reminderHoursBeforeDeadline,cronEnabledextraRecipients[]— chip-input (small custom component using Input + Badge; press Enter to add, click to remove)
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card, CardContent, CardDescription, CardHeader, CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react'
import { toast } from 'sonner'
export function LunchEventConfig({ programId, event }: {
programId: string
event: NonNullable<ReturnType<typeof trpc.lunch.getEvent.useQuery>['data']>
}) {
const utils = trpc.useUtils()
const update = trpc.lunch.updateEvent.useMutation({
onSuccess: () => utils.lunch.getEvent.invalidate({ programId }),
onError: (e) => toast.error(e.message),
})
// Local state for chip input
const [extraInput, setExtraInput] = useState('')
return (
<Card>
<CardHeader>
<CardTitle>Event configuration</CardTitle>
<CardDescription>Per-edition lunch event settings.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* enabled */}
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<Label htmlFor="lunch-enabled">Enable lunch event</Label>
<Switch
id="lunch-enabled"
checked={event.enabled}
onCheckedChange={(v) => update.mutate({ programId, enabled: v })}
disabled={update.isPending}
/>
</div>
{/* eventAt */}
<div className="space-y-1.5">
<Label htmlFor="event-at">Event start</Label>
<Input
id="event-at" type="datetime-local"
defaultValue={event.eventAt ? event.eventAt.toISOString().slice(0, 16) : ''}
onBlur={(e) => {
const v = e.target.value
update.mutate({
programId,
eventAt: v ? new Date(v) : null,
})
}}
disabled={update.isPending}
/>
</div>
{/* endAt */}
<div className="space-y-1.5">
<Label htmlFor="end-at">Event end</Label>
<Input
id="end-at" type="datetime-local"
defaultValue={event.endAt ? event.endAt.toISOString().slice(0, 16) : ''}
onBlur={(e) => {
const v = e.target.value
update.mutate({ programId, endAt: v ? new Date(v) : null })
}}
disabled={update.isPending}
/>
</div>
{/* venue */}
<div className="space-y-1.5">
<Label htmlFor="venue">Venue</Label>
<Input
id="venue"
defaultValue={event.venue ?? ''}
onBlur={(e) => update.mutate({ programId, venue: e.target.value || null })}
disabled={update.isPending}
/>
</div>
{/* notes */}
<div className="space-y-1.5">
<Label htmlFor="notes">Notes for attendees (optional)</Label>
<Textarea
id="notes"
defaultValue={event.notes ?? ''}
onBlur={(e) => update.mutate({ programId, notes: e.target.value || null })}
disabled={update.isPending}
/>
</div>
{/* changeCutoffHours */}
<div className="space-y-1.5">
<Label htmlFor="cutoff">Change cutoff (hours before event)</Label>
<Input
id="cutoff" type="number" min={0} max={720}
defaultValue={event.changeCutoffHours}
onBlur={(e) => {
const n = Number(e.target.value)
if (Number.isFinite(n) && n !== event.changeCutoffHours) {
update.mutate({ programId, changeCutoffHours: n })
}
}}
disabled={update.isPending}
className="max-w-[12rem]"
/>
</div>
{/* reminderHoursBeforeDeadline */}
<div className="space-y-1.5">
<Label htmlFor="reminder">Reminder (hours before deadline; blank = off)</Label>
<Input
id="reminder" type="number" min={0} max={720}
defaultValue={event.reminderHoursBeforeDeadline ?? ''}
onBlur={(e) => {
const v = e.target.value
update.mutate({
programId,
reminderHoursBeforeDeadline: v === '' ? null : Number(v),
})
}}
disabled={update.isPending}
className="max-w-[12rem]"
/>
</div>
{/* cronEnabled */}
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<Label htmlFor="cron-enabled">Auto-send recap at deadline</Label>
<Switch
id="cron-enabled"
checked={event.cronEnabled}
onCheckedChange={(v) => update.mutate({ programId, cronEnabled: v })}
disabled={update.isPending}
/>
</div>
{/* extraRecipients chip input */}
<div className="space-y-1.5">
<Label>Extra recap recipients</Label>
<div className="flex flex-wrap gap-2">
{event.extraRecipients.map((email) => (
<Badge key={email} variant="secondary" className="gap-1">
{email}
<button
className="ml-1"
onClick={() =>
update.mutate({
programId,
extraRecipients: event.extraRecipients.filter(e => e !== email),
})
}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<Input
placeholder="email@example.com — press Enter to add"
value={extraInput}
onChange={(e) => setExtraInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && extraInput.trim()) {
update.mutate({
programId,
extraRecipients: [...event.extraRecipients, extraInput.trim()],
})
setExtraInput('')
}
}}
/>
</div>
</CardContent>
</Card>
)
}
-
Step 2: Mount it in
lunch-tab.tsx— replace the empty-state and the placeholder paragraph with<LunchEventConfig programId={programId} event={event} />(always render — the config is needed even whenenabled=falseto flip it on). -
Step 3: Live smoke — open Lunch tab, toggle enabled, set a date, set venue, set extra recipients, refresh — values persist.
-
Step 4: Commit
git add src/components/admin/logistics/lunch-event-config.tsx src/components/admin/logistics/lunch-tab.tsx
git commit -m "feat: lunch event configuration card"
Task 15: Dishes card
Files:
-
Create:
src/components/admin/logistics/lunch-dishes.tsx -
Modify:
src/components/admin/logistics/lunch-tab.tsx -
Step 1: Build the card
src/components/admin/logistics/lunch-dishes.tsx:
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
const DIETARY_TAGS = ['VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN'] as const
type DietaryTag = (typeof DIETARY_TAGS)[number]
export function LunchDishes({ lunchEventId }: { lunchEventId: string }) {
const utils = trpc.useUtils()
const { data: dishes } = trpc.lunch.listDishes.useQuery({ lunchEventId })
const create = trpc.lunch.createDish.useMutation({
onSuccess: () => utils.lunch.listDishes.invalidate({ lunchEventId }),
onError: (e) => toast.error(e.message),
})
const update = trpc.lunch.updateDish.useMutation({
onSuccess: () => utils.lunch.listDishes.invalidate({ lunchEventId }),
})
const del = trpc.lunch.deleteDish.useMutation({
onSuccess: () => utils.lunch.listDishes.invalidate({ lunchEventId }),
})
const [newName, setNewName] = useState('')
const [newTags, setNewTags] = useState<DietaryTag[]>([])
return (
<Card>
<CardHeader>
<CardTitle>Dishes</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{dishes?.length === 0 && (
<p className="text-muted-foreground text-sm">Add at least one dish to open picks.</p>
)}
<ul className="space-y-2">
{dishes?.map((d) => (
<li key={d.id} className="flex items-center gap-3 rounded-md border p-3">
<span className="font-medium">{d.name}</span>
<div className="flex gap-1">
{d.dietaryTags.map((t) => (
<Badge key={t} variant="outline">{t.replace('_', ' ').toLowerCase()}</Badge>
))}
</div>
<div className="ml-auto flex gap-2">
<Button size="sm" variant="ghost"
onClick={() => {
const name = prompt('Edit name', d.name)
if (name && name !== d.name) update.mutate({ dishId: d.id, name })
}}
><Pencil className="h-4 w-4" /></Button>
<Button size="sm" variant="ghost"
onClick={() => {
if (confirm(`Delete "${d.name}"? Existing picks will go back to "not picked".`)) {
del.mutate({ dishId: d.id })
}
}}
><Trash2 className="h-4 w-4" /></Button>
</div>
</li>
))}
</ul>
<div className="flex gap-2 border-t pt-4">
<Input placeholder="New dish name" value={newName}
onChange={(e) => setNewName(e.target.value)} />
<div className="flex gap-1">
{DIETARY_TAGS.map((t) => (
<Button key={t} size="sm"
variant={newTags.includes(t) ? 'default' : 'outline'}
onClick={() => setNewTags(
newTags.includes(t) ? newTags.filter(x => x !== t) : [...newTags, t]
)}
>{t.replace('_', ' ').toLowerCase()}</Button>
))}
</div>
<Button onClick={() => {
if (!newName.trim()) return
create.mutate(
{ lunchEventId, name: newName.trim(), dietaryTags: newTags, sortOrder: dishes?.length ?? 0 },
{ onSuccess: () => { setNewName(''); setNewTags([]) } },
)
}}><Plus className="mr-1 h-4 w-4" /> Add</Button>
</div>
</CardContent>
</Card>
)
}
-
Step 2: Mount in
lunch-tab.tsxunder the config card. -
Step 3: Live smoke — add 3 dishes with mixed dietary tags, edit one's name, delete one. Refresh.
-
Step 4: Commit
git add src/components/admin/logistics/lunch-dishes.tsx src/components/admin/logistics/lunch-tab.tsx
git commit -m "feat: lunch dishes card with create/edit/delete"
Task 16: Manifest card
Files:
-
Create:
src/components/admin/logistics/lunch-manifest.tsx -
Modify:
src/components/admin/logistics/lunch-tab.tsx -
Step 1: Build the card
src/components/admin/logistics/lunch-manifest.tsx — table backed by trpc.lunch.getManifest.useQuery({ programId }). Columns: Team / Attendee / Type / Dish / Allergens / Picked at. Filters above the table:
<Input>to filter by team name (client-side substring match)<Switch>"Missing picks only" — filters to rows wheredish == null
Header summary chip uses manifest.summary plus aggregated dietary/allergen counts (compute client-side).
Edit button on each row opens a slide-over with <LunchPickForm> (defined in Task 20) for members, or the externals dialog for externals (defined in Task 17). Re-use both.
'use client'
import { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Pencil, Download } from 'lucide-react'
export function LunchManifest({ programId }: { programId: string }) {
const { data } = trpc.lunch.getManifest.useQuery({ programId })
const [search, setSearch] = useState('')
const [missingOnly, setMissingOnly] = useState(false)
const rows = useMemo(() => {
if (!data) return []
const all = [
...data.members.map((m: any) => ({ ...m, sortKey: `0-${m.project?.name ?? ''}-${m.name}` })),
...data.externals.map((e: any) => ({ ...e, sortKey: `1-${e.project?.name ?? ''}-${e.name}` })),
]
return all
.filter(r => !search || (r.project?.name ?? '').toLowerCase().includes(search.toLowerCase()) || r.name.toLowerCase().includes(search.toLowerCase()))
.filter(r => !missingOnly || !r.dish)
.sort((a, b) => a.sortKey.localeCompare(b.sortKey))
}, [data, search, missingOnly])
if (!data) return null
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
Manifest
<Badge variant="outline">
{data.summary.picked}/{data.summary.total} picked · {data.summary.missing} missing
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<Input placeholder="Filter by team or name" value={search}
onChange={(e) => setSearch(e.target.value)} className="max-w-xs" />
<div className="flex items-center gap-2">
<Switch id="missing-only" checked={missingOnly} onCheckedChange={setMissingOnly} />
<Label htmlFor="missing-only">Missing picks only</Label>
</div>
<DownloadCsvButton programId={programId} />
</div>
<table className="w-full text-sm">
<thead className="text-muted-foreground border-b text-left">
<tr>
<th className="py-2">Team</th>
<th>Attendee</th>
<th>Type</th>
<th>Dish</th>
<th>Allergens</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((r: any) => (
<tr key={r.attendingMemberId ?? r.externalId} className="border-b">
<td className="py-2">{r.project?.name ?? '—'}</td>
<td>{r.name}</td>
<td>{r.kind === 'MEMBER' ? 'Member' : 'External'}</td>
<td>{r.dish?.name ?? <span className="text-muted-foreground">not picked</span>}</td>
<td>{[...r.allergens, r.allergenOther].filter(Boolean).join(', ')}</td>
<td>
<Button size="sm" variant="ghost"><Pencil className="h-4 w-4" /></Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)
}
Define DownloadCsvButton near the top of the same file (above LunchManifest):
function DownloadCsvButton({ programId }: { programId: string }) {
const utils = trpc.useUtils()
return (
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={async () => {
const csv = await utils.client.lunch.exportManifestCsv.query({ programId })
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'lunch-manifest.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}}
>
<Download className="mr-1 h-4 w-4" /> Download CSV
</Button>
)
}
The edit-pencil wiring (open slide-over, mount the appropriate form, re-invalidate the manifest on save) lands in Tasks 17 + 20 — leave the button stubbed for now.
-
Step 2: Mount in
lunch-tab.tsx. -
Step 3: Live smoke — confirm rows render, filters work, CSV downloads.
-
Step 4: Commit
git add src/components/admin/logistics/lunch-manifest.tsx src/components/admin/logistics/lunch-tab.tsx
git commit -m "feat: lunch manifest card with filters + CSV export"
Task 17: Externals card with add/edit dialog
Files:
-
Create:
src/components/admin/logistics/lunch-externals.tsx -
Modify:
src/components/admin/logistics/lunch-tab.tsx -
Modify:
src/components/admin/logistics/lunch-manifest.tsx— wire the externals edit pencil into this dialog -
Step 1: Build the card + dialog
src/components/admin/logistics/lunch-externals.tsx:
'use client'
import { useState, useImperativeHandle, forwardRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
const ALLERGENS = [
'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK',
'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS',
] as const
type Allergen = (typeof ALLERGENS)[number]
type Editing =
| { mode: 'new' }
| { mode: 'edit'; id: string }
| null
export type LunchExternalsHandle = { openEditDialog: (id: string) => void }
export const LunchExternals = forwardRef<LunchExternalsHandle, {
programId: string
lunchEventId: string
}>(function LunchExternals({ programId, lunchEventId }, ref) {
const utils = trpc.useUtils()
const { data: externals } = trpc.lunch.listExternals.useQuery({ lunchEventId })
const { data: dishes } = trpc.lunch.listDishes.useQuery({ lunchEventId })
const { data: projects } = trpc.program.listFinalistProjects.useQuery({ programId })
// ^ Add this small helper procedure on `program` router if it doesn't exist:
// listFinalistProjects: adminProcedure
// .input(z.object({ programId: z.string() }))
// .query(({ ctx, input }) => ctx.prisma.project.findMany({
// where: { programId: input.programId, finalistConfirmation: { status: 'CONFIRMED' } },
// select: { id: true, name: true },
// orderBy: { name: 'asc' },
// })),
const [editing, setEditing] = useState<Editing>(null)
useImperativeHandle(ref, () => ({
openEditDialog: (id: string) => setEditing({ mode: 'edit', id }),
}), [])
const create = trpc.lunch.createExternal.useMutation({
onSuccess: () => {
utils.lunch.listExternals.invalidate({ lunchEventId })
utils.lunch.getManifest.invalidate({ programId })
},
onError: (e) => toast.error(e.message),
})
const update = trpc.lunch.updateExternal.useMutation({
onSuccess: () => {
utils.lunch.listExternals.invalidate({ lunchEventId })
utils.lunch.getManifest.invalidate({ programId })
},
})
const del = trpc.lunch.deleteExternal.useMutation({
onSuccess: () => {
utils.lunch.listExternals.invalidate({ lunchEventId })
utils.lunch.getManifest.invalidate({ programId })
},
})
const editingRow = editing?.mode === 'edit'
? externals?.find(e => e.id === editing.id) ?? null
: null
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
External attendees
<Button size="sm" onClick={() => setEditing({ mode: 'new' })}>
<Plus className="mr-1 h-4 w-4" /> Add external
</Button>
</CardTitle>
</CardHeader>
<CardContent>
{externals?.length === 0 && (
<p className="text-muted-foreground text-sm">No external attendees yet.</p>
)}
<table className="w-full text-sm">
<tbody>
{externals?.map((e) => (
<tr key={e.id} className="border-b">
<td className="py-2">{e.name}</td>
<td>{e.project?.name ?? '—'}</td>
<td>{e.roleNote ?? ''}</td>
<td>
<Button size="sm" variant="ghost" onClick={() => setEditing({ mode: 'edit', id: e.id })}>
<Pencil className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => {
if (confirm(`Delete external attendee "${e.name}"?`)) del.mutate({ externalId: e.id })
}}>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
{editing && (
<ExternalDialog
mode={editing.mode}
initial={editingRow}
dishes={dishes ?? []}
projects={projects ?? []}
onClose={() => setEditing(null)}
onSubmit={(values) => {
if (editing.mode === 'new') {
create.mutate({ lunchEventId, ...values }, { onSuccess: () => setEditing(null) })
} else {
update.mutate({ externalId: editing.id, ...values }, { onSuccess: () => setEditing(null) })
}
}}
/>
)}
</Card>
)
})
function ExternalDialog({
mode, initial, dishes, projects, onClose, onSubmit,
}: {
mode: 'new' | 'edit'
initial: { name: string; email: string | null; projectId: string | null; roleNote: string | null; dishId: string | null; allergens: string[]; allergenOther: string | null } | null
dishes: Array<{ id: string; name: string }>
projects: Array<{ id: string; name: string }>
onClose: () => void
onSubmit: (values: {
name: string; email?: string; projectId?: string | null; roleNote?: string;
dishId?: string | null; allergens: Allergen[]; allergenOther?: string | null;
}) => void
}) {
const [name, setName] = useState(initial?.name ?? '')
const [email, setEmail] = useState(initial?.email ?? '')
const [projectId, setProjectId] = useState(initial?.projectId ?? '')
const [roleNote, setRoleNote] = useState(initial?.roleNote ?? '')
const [dishId, setDishId] = useState(initial?.dishId ?? '')
const [allergens, setAllergens] = useState<Allergen[]>((initial?.allergens as Allergen[]) ?? [])
const [allergenOther, setAllergenOther] = useState(initial?.allergenOther ?? '')
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose() }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{mode === 'new' ? 'Add external attendee' : 'Edit external attendee'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div><Label>Name *</Label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
<div><Label>Email</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div>
<Label>Project (optional)</Label>
<Select value={projectId} onValueChange={setProjectId}>
<SelectTrigger><SelectValue placeholder="Standalone" /></SelectTrigger>
<SelectContent>
<SelectItem value="">Standalone</SelectItem>
{projects.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div><Label>Role / note</Label><Input value={roleNote} onChange={(e) => setRoleNote(e.target.value)} /></div>
<div>
<Label>Dish</Label>
<Select value={dishId} onValueChange={setDishId}>
<SelectTrigger><SelectValue placeholder="Not picked" /></SelectTrigger>
<SelectContent>
<SelectItem value="">Not picked</SelectItem>
{dishes.map(d => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>Allergens</Label>
<div className="grid grid-cols-2 gap-2">
{ALLERGENS.map((a) => (
<label key={a} className="flex items-center gap-2 text-sm">
<Checkbox checked={allergens.includes(a)}
onCheckedChange={(v) => setAllergens(
v ? [...allergens, a] : allergens.filter(x => x !== a),
)}
/>
{a.replace('_', ' ').toLowerCase()}
</label>
))}
</div>
</div>
<div><Label>Other allergens / notes</Label><Textarea value={allergenOther} onChange={(e) => setAllergenOther(e.target.value)} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
disabled={!name.trim()}
onClick={() => onSubmit({
name: name.trim(),
email: email.trim() || undefined,
projectId: projectId || null,
roleNote: roleNote.trim() || undefined,
dishId: dishId || null,
allergens,
allergenOther: allergenOther.trim() || null,
})}
>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
- Step 2: Add the supporting
program.listFinalistProjectsprocedure
If it doesn't already exist, add to src/server/routers/program.ts:
listFinalistProjects: adminProcedure
.input(z.object({ programId: z.string() }))
.query(({ ctx, input }) =>
ctx.prisma.project.findMany({
where: { programId: input.programId, finalistConfirmation: { status: 'CONFIRMED' } },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
),
- Step 3: Wire externals edit pencil from the manifest
Update <LunchTab> to keep a useRef<LunchExternalsHandle>(null) and pass an onEditExternal={(id) => externalsRef.current?.openEditDialog(id)} prop down to <LunchManifest>. In <LunchManifest>, change the externals row's edit button:
<Button size="sm" variant="ghost" onClick={() => onEditExternal?.(r.externalId)}>
<Pencil className="h-4 w-4" />
</Button>
(Member rows' edit pencil opens the slide-over from Task 20 instead — leave it stubbed for now and replace in that task.)
- Step 4: Mount in
lunch-tab.tsx
const externalsRef = useRef<LunchExternalsHandle>(null)
// ...
<LunchManifest programId={programId} onEditExternal={(id) => externalsRef.current?.openEditDialog(id)} />
<LunchExternals ref={externalsRef} programId={programId} lunchEventId={event.id} />
-
Step 5: Live smoke — add a standalone external + a project-attached one. Edit. Delete. Confirm both show in the manifest. Click the manifest's externals edit-pencil — externals dialog opens.
-
Step 6: Commit
git add src/components/admin/logistics/lunch-externals.tsx \
src/components/admin/logistics/lunch-tab.tsx \
src/components/admin/logistics/lunch-manifest.tsx
git commit -m "feat: external lunch attendees card + dialog"
Task 18: Recap actions card
Files:
-
Create:
src/components/admin/logistics/lunch-recap-actions.tsx -
Modify:
src/components/admin/logistics/lunch-tab.tsx -
Step 1: Build the card
Two buttons + a footer line:
- Preview recap — opens a Dialog rendering the recap payload (counts + table).
- Send recap now — calls
trpc.lunch.sendRecap.useMutation(). OnPRECONDITION_FAILED, surfaces a confirm dialog ("You've already sent a recap. Send updated version to all recipients?"); on confirm, retries withforceUpdate: true. - Download CSV — same handler as the manifest card (extract a small util if duplicated).
- Footer: "Last sent: · Recipients: N admins + M extra" — read from
event.recapSentAt+event.extraRecipients.lengthand a count of admin users fromtrpc.user.countAdmins(add this small read if it doesn't exist; otherwise hardcode "all admins" text without a count to avoid a new endpoint).
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Send, Eye, Download } from 'lucide-react'
import { toast } from 'sonner'
export function LunchRecapActions({ programId, event }: {
programId: string
event: { recapSentAt: Date | null; extraRecipients: string[] }
}) {
const utils = trpc.useUtils()
const [previewOpen, setPreviewOpen] = useState(false)
const send = trpc.lunch.sendRecap.useMutation({
onSuccess: () => {
utils.lunch.getEvent.invalidate({ programId })
toast.success('Recap sent')
},
onError: async (e) => {
if (e.data?.code === 'PRECONDITION_FAILED') {
if (confirm("You've already sent a recap. Send updated version to all recipients?")) {
send.mutate({ programId, forceUpdate: true })
}
} else {
toast.error(e.message)
}
},
})
const { data: preview } = trpc.lunch.getRecapPreview.useQuery(
{ programId },
{ enabled: previewOpen },
)
return (
<Card>
<CardHeader>
<CardTitle>Recap</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setPreviewOpen(true)}>
<Eye className="mr-2 h-4 w-4" /> Preview recap
</Button>
<Button onClick={() => send.mutate({ programId })} disabled={send.isPending}>
<Send className="mr-2 h-4 w-4" /> Send recap now
</Button>
</div>
<p className="text-muted-foreground text-xs">
{event.recapSentAt
? `Last sent: ${event.recapSentAt.toLocaleString()}. Recipients: edition admins${event.extraRecipients.length ? ` + ${event.extraRecipients.length} extra` : ''}.`
: 'Recap has not been sent yet.'}
</p>
</CardContent>
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader><DialogTitle>Recap preview</DialogTitle></DialogHeader>
{preview && (
<div className="space-y-3 text-sm">
<p>{preview.summary.picked}/{preview.summary.total} picked.</p>
<h4 className="font-medium">Dishes</h4>
<ul>{Object.entries(preview.dishCounts).map(([n, c]) => <li key={n}>{c}× {n}</li>)}</ul>
<h4 className="font-medium">Allergens</h4>
<ul>{Object.entries(preview.allergenCounts).map(([n, c]) => <li key={n}>{c}× {n}</li>)}</ul>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPreviewOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}
-
Step 2: Mount in
lunch-tab.tsxas the last card. -
Step 3: Live smoke — preview shows aggregates, "Send" sends; second click prompts the resend confirm.
-
Step 4: Commit
git add src/components/admin/logistics/lunch-recap-actions.tsx src/components/admin/logistics/lunch-tab.tsx
git commit -m "feat: recap actions card with preview + send + resend confirm"
Task 19: Lunch banner on applicant dashboard
Files:
-
Create:
src/components/applicant/lunch-banner.tsx -
Modify:
src/app/(applicant)/applicant/page.tsx -
Step 1: Build the banner
src/components/applicant/lunch-banner.tsx — uses trpc.lunch.getEventForMember.useQuery({ programId }). Returns null when event is null (disabled or not configured).
Layout: a single-line strip showing event date/time (Intl-formatted in user locale + "Europe/Monaco" zone), venue, and a small countdown to changeDeadline. A details-disclosure for notes if non-empty.
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent } from '@/components/ui/card'
import { Calendar, MapPin } from 'lucide-react'
export function LunchBanner({ programId }: { programId: string }) {
const { data: event } = trpc.lunch.getEventForMember.useQuery({ programId })
if (!event) return null
const fmt = new Intl.DateTimeFormat(undefined, {
timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short',
})
return (
<Card>
<CardContent className="flex flex-wrap items-center gap-4 py-3 text-sm">
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
{event.eventAt ? fmt.format(new Date(event.eventAt)) : 'Date TBD'}
</div>
{event.venue && (
<div className="flex items-center gap-1.5">
<MapPin className="h-4 w-4" /> {event.venue}
</div>
)}
{event.changeDeadline && (
<div className="text-muted-foreground ml-auto">
Picks close: {fmt.format(new Date(event.changeDeadline))}
</div>
)}
{event.notes && (
<details className="basis-full">
<summary className="text-muted-foreground cursor-pointer text-xs">Notes from organizers</summary>
<p className="text-sm">{event.notes}</p>
</details>
)}
</CardContent>
</Card>
)
}
- Step 2: Mount above
<AttendingMembersCard>
In src/app/(applicant)/applicant/page.tsx, add an import and place <LunchBanner programId={projectProgramId} /> above the existing <AttendingMembersCard>. The exact programId source already used for that card (likely project.programId) reuses the same prop.
-
Step 3: Live smoke — disable lunch → banner hidden; enable + set date → banner shows.
-
Step 4: Commit
git add src/components/applicant/lunch-banner.tsx src/app/\(applicant\)/applicant/page.tsx
git commit -m "feat: lunch banner on applicant dashboard"
Task 20: Lunch picker on AttendingMembersCard
Files:
-
Create:
src/components/applicant/lunch-pick-form.tsx -
Modify:
src/components/applicant/attending-members-card.tsx -
Step 1: Build the picker form
src/components/applicant/lunch-pick-form.tsx — receives attendingMemberId, lunchEventId, current pick, current user role/identity, and the enabled flag. Calls trpc.lunch.upsertPick.useMutation() on changes. Disables editing when:
- The viewer is not self / team-lead / admin (already enforced server-side, but we also disable inputs to avoid silly UX), OR
- The deadline has passed (UI shows the read-only message).
Renders a dropdown of dishes (grouped by dietaryTags) + the EU-14 allergen checklist + an "Other" textarea + a chip showing "Picked at [time]" once pickedAt is set.
Keep this component small (≤ 200 lines). Use useState for local draft state, commit on dropdown change / checkbox toggle / textarea blur.
The viewer-vs-editor logic: this component receives canEdit: boolean from its parent. The parent (the card row) computes it from session role + the row's userId.
- Step 2: Embed in
attending-members-card.tsx
Find the existing per-row layout. Below the visa + flight subsections, append a <LunchPickForm> that:
- Reads
eventfrom atrpc.lunch.getEventForMember.useQuery({ programId })call hoisted to the parent. - Conditionally renders only when
event != null. - Computes
canEditper row:(role === SUPER_ADMIN || PROGRAM_ADMIN) || row.userId === sessionUser.id || isTeamLead(sessionUser, project).
isTeamLead derives from the existing project context that AttendingMembersCard already receives (it knows the project's TeamMembers). If it doesn't, add it now via the existing project read used by the dashboard.
- Step 3: Wire the manifest edit pencil (Task 16 stub)
Now that <LunchPickForm> exists, return to lunch-manifest.tsx and replace the stubbed edit-pencil button with a slide-over (<Sheet>) that mounts <LunchPickForm> in admin mode (canEdit={true}, no deadline gating since admin-only). On save, invalidate trpc.lunch.getManifest.
-
Step 4: Live smoke
-
Open the dashboard as a member → can pick own row only.
-
Open as team lead → can pick any row.
-
Past deadline → read-only state with "contact admin" note.
-
Open
/admin/logistics→ Lunch → click pencil on any row → slide-over opens, edit works, manifest updates. -
Step 5: Commit
git add src/components/applicant/lunch-pick-form.tsx \
src/components/applicant/attending-members-card.tsx \
src/components/admin/logistics/lunch-manifest.tsx
git commit -m "feat: lunch picker on attending-members card + admin slide-over"
Task 21: External attendees read-only strip on project page
Files:
-
Create:
src/components/applicant/external-attendees-strip.tsx -
Modify: the project detail page (locate via
find src/app -path '*applicant*projects*page.tsx'; also checksrc/app/(applicant)/applicant/project— verify the exact path before editing) -
Step 1: Add the
getProjectExternalsprocedure (TDD)
Add a failing test in tests/unit/lunch-router.test.ts:
describe('lunch.getProjectExternals', () => {
it('returns project-attached externals to a team member', async () => {
const program = await createTestProgram()
const lead = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
await prisma.teamMember.create({
data: { projectId: project.id, userId: lead.id, role: 'LEAD' },
})
const event = await prisma.lunchEvent.create({ data: { programId: program.id } })
await prisma.externalAttendee.create({
data: { lunchEventId: event.id, projectId: project.id, name: 'Sponsor X' },
})
const caller = createCaller(lunchRouter, lead)
const result = await caller.getProjectExternals({ projectId: project.id })
expect(result).toHaveLength(1)
expect(result[0].name).toBe('Sponsor X')
})
it('rejects callers who are not on the team', async () => {
const program = await createTestProgram()
const stranger = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
const caller = createCaller(lunchRouter, stranger)
await expect(
caller.getProjectExternals({ projectId: project.id }),
).rejects.toThrow(/FORBIDDEN/)
})
})
Run, expect failure. Then add to src/server/routers/lunch.ts:
getProjectExternals: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id
const role = ctx.session.user.role
const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
if (!isAdmin) {
const tm = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, userId },
})
if (!tm) throw new TRPCError({ code: 'FORBIDDEN' })
}
return ctx.prisma.externalAttendee.findMany({
where: { projectId: input.projectId },
include: { dish: true },
orderBy: { createdAt: 'asc' },
})
}),
Run tests, expect green.
- Step 2: Locate the project detail page
find src/app -type f -name 'page.tsx' | xargs grep -l "TeamMember\|project.findUnique" 2>/dev/null
Pick the page that team members use to view their project details. Confirm by checking it renders team-members / flight / visa.
- Step 3: Build the strip
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
export function ExternalAttendeesStrip({ projectId }: { projectId: string }) {
const { data } = trpc.lunch.getProjectExternals.useQuery({ projectId })
if (!data || data.length === 0) return null
return (
<Card>
<CardContent className="flex flex-wrap items-center gap-2 py-3">
<span className="text-sm font-medium">External attendees joining your team:</span>
{data.map((e) => (
<Badge key={e.id} variant="outline">
{e.name}{e.roleNote ? ` (${e.roleNote})` : ''}
</Badge>
))}
</CardContent>
</Card>
)
}
-
Step 4: Mount on the project detail page above (or below) the team-members section.
-
Step 5: Live smoke — add a project-attached external as admin, switch to team-lead account, confirm strip shows.
-
Step 6: Commit
git add src/components/applicant/external-attendees-strip.tsx \
src/server/routers/lunch.ts \
tests/unit/lunch-router.test.ts \
<project-detail-page-path>
git commit -m "feat: read-only external attendees strip on project page"
Task 22: Drop Lunch line from edition-settings "Coming soon" card
Files:
-
Modify:
src/components/admin/settings/edition-settings-tab.tsx -
Step 1: Update the import line
In src/components/admin/settings/edition-settings-tab.tsx, replace:
import { Loader2, Salad, ScrollText, Stamp, Users } from 'lucide-react'
with:
import { Loader2, ScrollText, Stamp, Users } from 'lucide-react'
- Step 2: Update the Coming-soon card
Replace the existing block (currently around :203-221):
{/* Coming soon */}
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-muted-foreground text-base">Coming soon</CardTitle>
<CardDescription>
Lunch-event configuration and editable email templates land in upcoming
updates and will surface here.
</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<div className="flex items-center gap-2">
<Salad className="h-4 w-4" /> Lunch event — dishes, allergies, RSVP deadline
</div>
<div className="flex items-center gap-2">
<ScrollText className="h-4 w-4" /> Email templates — editable subject + body
for confirmation, decline-cascade, mentor onboarding, etc.
</div>
</CardContent>
</Card>
with:
{/* Coming soon */}
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-muted-foreground text-base">Coming soon</CardTitle>
<CardDescription>
Editable email templates land in an upcoming update and will surface here.
</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<div className="flex items-center gap-2">
<ScrollText className="h-4 w-4" /> Email templates — editable subject + body
for confirmation, decline-cascade, mentor onboarding, etc.
</div>
</CardContent>
</Card>
-
Step 3: Live smoke — open
/admin/settings→ Edition. Coming-soon card should mention only email templates. -
Step 4: Commit
git add src/components/admin/settings/edition-settings-tab.tsx
git commit -m "chore: drop lunch placeholder from edition settings coming-soon card"
Task 23: Final verification
- Step 1: Full test suite
npx vitest run
Expected: all green. Roughly +30 tests over baseline (lunch-router, lunch-upsert-pick, lunch-recap, lunch-cron, lunch-pick-sync).
- Step 2: Typecheck
npm run typecheck
Expected: clean.
- Step 3: Production build
npm run build
Expected: clean.
- Step 4: Lint
npm run lint
Expected: clean.
- Step 5: End-to-end smoke (browser)
- As
SUPER_ADMIN: open/admin/logistics→ Lunch tab. - Toggle enabled. Set
eventAt, venue. Save. - Add 3 dishes with mixed dietary tags.
- Add a standalone external + a project-attached external.
- As an attending member of a CONFIRMED project: open
/applicant. Verify lunch banner. Pick a dish, log allergens. - As the team lead: edit a teammate's pick.
- Past deadline (set
changeCutoffHoursto 0 +eventAtin the past briefly): member sees read-only state. - Back as admin: hit "Send recap now" → success toast. Click again → confirm dialog → resend.
- Hit cron endpoints with
curl -X POST -H "x-cron-secret: $CRON_SECRET" $URL/api/cron/lunch-remindersand/api/cron/lunch-recap— confirm idempotency. - Download CSV — confirm file opens cleanly in a spreadsheet.
- Step 6: Final commit (if any cleanup)
git status # confirm tree is clean
Self-review checklist (for the engineer executing this plan)
- Spec coverage: every section of
docs/superpowers/specs/2026-04-29-pr6-lunch-event-design.mdmaps to a task above. - Permission matrix (spec §2) — implemented in Task 6 (
upsertPick) and Task 20 (UI gating). - Cutoff enforcement —
upsertPickserver-side guard (Task 6) + UI read-only state (Task 20). - Dish delete preserves picks — Task 4 test asserts this; Prisma
SetNullenforces. - External attendees can be standalone or project-attached — Task 5 + Task 17 + Task 21.
- Recap "send updated?" — Task 9 server-side
forceUpdateflag + Task 18 UI confirm dialog. - Cron idempotency — Tasks 11 + 12 tests assert.
- Audit log entries — every mutation procedure writes a
DecisionAuditLogrow with the spec's exacteventTypestrings. - No keyboard shortcuts introduced.
- No new public token-gated pages — picker stays inside authenticated dashboard.