3477 lines
122 KiB
Markdown
3477 lines
122 KiB
Markdown
|
|
# 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 procedures
|
|||
|
|
- `src/server/services/lunch-pick-sync.ts` — `ensureLunchPickForAttendingMember` helper
|
|||
|
|
- `src/server/services/lunch-recap.ts` — manifest aggregation + recap payload builder
|
|||
|
|
- `src/app/api/cron/lunch-reminders/route.ts`
|
|||
|
|
- `src/app/api/cron/lunch-recap/route.ts`
|
|||
|
|
- `src/components/admin/logistics/lunch-tab.tsx` — orchestrates the five cards
|
|||
|
|
- `src/components/admin/logistics/lunch-event-config.tsx`
|
|||
|
|
- `src/components/admin/logistics/lunch-dishes.tsx`
|
|||
|
|
- `src/components/admin/logistics/lunch-manifest.tsx`
|
|||
|
|
- `src/components/admin/logistics/lunch-externals.tsx`
|
|||
|
|
- `src/components/admin/logistics/lunch-recap-actions.tsx`
|
|||
|
|
- `src/components/applicant/lunch-banner.tsx`
|
|||
|
|
- `src/components/applicant/lunch-pick-form.tsx` — used inside `AttendingMembersCard` rows
|
|||
|
|
- `src/components/applicant/external-attendees-strip.tsx` — read-only strip on the project page
|
|||
|
|
- `tests/unit/lunch-router.test.ts`
|
|||
|
|
- `tests/unit/lunch-upsert-pick.test.ts`
|
|||
|
|
- `tests/unit/lunch-recap.test.ts`
|
|||
|
|
- `tests/unit/lunch-cron.test.ts`
|
|||
|
|
- `tests/unit/lunch-pick-sync.test.ts`
|
|||
|
|
|
|||
|
|
**Modify:**
|
|||
|
|
- `prisma/schema.prisma` — new models, enums, back-refs on `Program` / `AttendingMember` / `Project`
|
|||
|
|
- `src/server/routers/_app.ts` — mount `lunch` router
|
|||
|
|
- `src/server/routers/finalist.ts` — wire `ensureLunchPickForAttendingMember` into the attendee-write paths
|
|||
|
|
- `src/lib/email.ts` — append two new template functions
|
|||
|
|
- `src/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 row
|
|||
|
|
- `src/app/(applicant)/applicant/page.tsx` — render `<LunchBanner>` above the attending-members card
|
|||
|
|
- `src/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 after `WaitlistEntryStatus` is a reasonable home):
|
|||
|
|
|
|||
|
|
```prisma
|
|||
|
|
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`):
|
|||
|
|
|
|||
|
|
```prisma
|
|||
|
|
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.prisma` for the existing relation lines:
|
|||
|
|
|
|||
|
|
In `model Program { ... }` (just below `hotel Hotel?` at the bottom of the relation block):
|
|||
|
|
|
|||
|
|
```prisma
|
|||
|
|
lunchEvent LunchEvent?
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `model AttendingMember { ... }` (just below `visaApplication VisaApplication?`):
|
|||
|
|
|
|||
|
|
```prisma
|
|||
|
|
lunchPick MemberLunchPick?
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `model Project { ... }` (just below `finalistAttendances AttendingMember[]`):
|
|||
|
|
|
|||
|
|
```prisma
|
|||
|
|
externalLunchAttendees ExternalAttendee[]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Generate the migration**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npx prisma generate
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Typecheck**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npm run typecheck
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: clean. (No code references the new models yet.)
|
|||
|
|
|
|||
|
|
- [ ] **Step 7: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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 existing `confirm`, `editAttendees`, `unconfirm` paths that touch `AttendingMember`)
|
|||
|
|
- Create: `tests/unit/lunch-pick-sync.test.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Locate the existing attendee write paths**
|
|||
|
|
|
|||
|
|
Run:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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):
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
import { ensureLunchPickForAttendingMember } from '@/server/services/lunch-pick-sync'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
For each create site (typical example):
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
const member = await ctx.prisma.attendingMember.create({ data: { confirmationId, userId } })
|
|||
|
|
await ensureLunchPickForAttendingMember(ctx.prisma, member.id)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 7: Run the full test suite**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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`:
|
|||
|
|
|
|||
|
|
```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`:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
import { lunchRouter } from './lunch'
|
|||
|
|
// ...
|
|||
|
|
export const appRouter = router({
|
|||
|
|
// ...existing routers...
|
|||
|
|
lunch: lunchRouter,
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Run tests, expect green**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npx vitest run tests/unit/lunch-router.test.ts
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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.ts`** inside the existing `router({ ... })`:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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**:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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`:
|
|||
|
|
|
|||
|
|
```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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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**:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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):
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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 `buildRecapPayload`** to `src/server/services/lunch-recap.ts`:
|
|||
|
|
|
|||
|
|
```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`:
|
|||
|
|
|
|||
|
|
```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 in `src/lib/email.ts`: `export async function sendLunchRecapEmail() {}`).
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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):
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npm run typecheck
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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`:
|
|||
|
|
|
|||
|
|
```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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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 `disabled` from the Lunch `<TabsTrigger>` (line ~55-58).
|
|||
|
|
- Drop the "(soon)" span.
|
|||
|
|
- Add a new `<TabsContent value="lunch"><LunchTab programId={programId} /></TabsContent>` block.
|
|||
|
|
- Import `LunchTab`.
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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`, `cronEnabled`
|
|||
|
|
- `extraRecipients[]` — chip-input (small custom component using Input + Badge; press Enter to add, click to remove)
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
'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 when `enabled=false` to 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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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.tsx`** under the config card.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Live smoke** — add 3 dishes with mixed dietary tags, edit one's name, delete one. Refresh.
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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 where `dish == 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.
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
'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`):
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```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.listFinalistProjects` procedure**
|
|||
|
|
|
|||
|
|
If it doesn't already exist, add to `src/server/routers/program.ts`:
|
|||
|
|
|
|||
|
|
```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:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
<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`**
|
|||
|
|
|
|||
|
|
```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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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()`. On `PRECONDITION_FAILED`, surfaces a confirm dialog ("You've already sent a recap. Send updated version to all recipients?"); on confirm, retries with `forceUpdate: true`.
|
|||
|
|
- **Download CSV** — same handler as the manifest card (extract a small util if duplicated).
|
|||
|
|
- Footer: "Last sent: <timestamp> · Recipients: N admins + M extra" — read from `event.recapSentAt` + `event.extraRecipients.length` and a count of admin users from `trpc.user.countAdmins` (add this small read if it doesn't exist; otherwise hardcode "all admins" text without a count to avoid a new endpoint).
|
|||
|
|
|
|||
|
|
```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 { 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.tsx`** as the last card.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Live smoke** — preview shows aggregates, "Send" sends; second click prompts the resend confirm.
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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.
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
'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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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 `event` from a `trpc.lunch.getEventForMember.useQuery({ programId })` call hoisted to the parent.
|
|||
|
|
- Conditionally renders only when `event != null`.
|
|||
|
|
- Computes `canEdit` per 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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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 check `src/app/(applicant)/applicant/project` — verify the exact path before editing)
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Add the `getProjectExternals` procedure (TDD)**
|
|||
|
|
|
|||
|
|
Add a failing test in `tests/unit/lunch-router.test.ts`:
|
|||
|
|
|
|||
|
|
```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`:
|
|||
|
|
|
|||
|
|
```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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
'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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { Loader2, Salad, ScrollText, Stamp, Users } from 'lucide-react'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
with:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { Loader2, ScrollText, Stamp, Users } from 'lucide-react'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Update the Coming-soon card**
|
|||
|
|
|
|||
|
|
Replace the existing block (currently around `:203-221`):
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
{/* 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:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
{/* 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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npm run typecheck
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: clean.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Production build**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npm run build
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: clean.
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Lint**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npm run lint
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: clean.
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: End-to-end smoke (browser)**
|
|||
|
|
|
|||
|
|
1. As `SUPER_ADMIN`: open `/admin/logistics` → Lunch tab.
|
|||
|
|
2. Toggle enabled. Set `eventAt`, venue. Save.
|
|||
|
|
3. Add 3 dishes with mixed dietary tags.
|
|||
|
|
4. Add a standalone external + a project-attached external.
|
|||
|
|
5. As an attending member of a CONFIRMED project: open `/applicant`. Verify lunch banner. Pick a dish, log allergens.
|
|||
|
|
6. As the team lead: edit a teammate's pick.
|
|||
|
|
7. Past deadline (set `changeCutoffHours` to 0 + `eventAt` in the past briefly): member sees read-only state.
|
|||
|
|
8. Back as admin: hit "Send recap now" → success toast. Click again → confirm dialog → resend.
|
|||
|
|
9. Hit cron endpoints with `curl -X POST -H "x-cron-secret: $CRON_SECRET" $URL/api/cron/lunch-reminders` and `/api/cron/lunch-recap` — confirm idempotency.
|
|||
|
|
10. Download CSV — confirm file opens cleanly in a spreadsheet.
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Final commit (if any cleanup)**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git status # confirm tree is clean
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Self-review checklist (for the engineer executing this plan)
|
|||
|
|
|
|||
|
|
1. **Spec coverage:** every section of `docs/superpowers/specs/2026-04-29-pr6-lunch-event-design.md` maps to a task above.
|
|||
|
|
2. **Permission matrix** (spec §2) — implemented in Task 6 (`upsertPick`) and Task 20 (UI gating).
|
|||
|
|
3. **Cutoff enforcement** — `upsertPick` server-side guard (Task 6) + UI read-only state (Task 20).
|
|||
|
|
4. **Dish delete preserves picks** — Task 4 test asserts this; Prisma `SetNull` enforces.
|
|||
|
|
5. **External attendees can be standalone or project-attached** — Task 5 + Task 17 + Task 21.
|
|||
|
|
6. **Recap "send updated?"** — Task 9 server-side `forceUpdate` flag + Task 18 UI confirm dialog.
|
|||
|
|
7. **Cron idempotency** — Tasks 11 + 12 tests assert.
|
|||
|
|
8. **Audit log entries** — every mutation procedure writes a `DecisionAuditLog` row with the spec's exact `eventType` strings.
|
|||
|
|
9. **No keyboard shortcuts** introduced.
|
|||
|
|
10. **No new public token-gated pages** — picker stays inside authenticated dashboard.
|