feat: lunch manifest query + CSV export

Adds buildManifest service shared between getManifest and the recap.
CSV escaper handles commas/quotes/newlines for safe spreadsheet import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 02:34:24 +02:00
parent d779959e54
commit a671bb853c
3 changed files with 268 additions and 0 deletions

View File

@@ -2,6 +2,7 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '../trpc' import { router, adminProcedure, protectedProcedure } from '../trpc'
import { logAudit } from '../utils/audit' import { logAudit } from '../utils/audit'
import { buildManifest } from '../services/lunch-recap'
// ─── Shared zod schemas ────────────────────────────────────────────────────── // ─── Shared zod schemas ──────────────────────────────────────────────────────
@@ -225,6 +226,48 @@ export const lunchRouter = router({
return { ok: true as const } return { ok: true as const }
}), }),
// ─── Manifest + CSV export ───────────────────────────────────────────────
getManifest: adminProcedure
.input(z.object({ programId: z.string() }))
.query(({ ctx, input }) => buildManifest(ctx.prisma, input.programId)),
exportManifestCsv: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const m = await buildManifest(ctx.prisma, 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) =>
[
'Member',
escape(row.project?.name),
escape(row.name),
escape(row.email),
escape(row.dish?.name),
escape(row.allergens.join(';')),
escape(row.allergenOther),
].join(','),
),
...m.externals.map((row) =>
[
'External',
escape(row.project?.name),
escape(row.name),
escape(row.email),
escape(row.dish?.name),
escape(row.allergens.join(';')),
escape(row.allergenOther),
].join(','),
),
]
return lines.join('\n')
}),
// ─── Member reads ──────────────────────────────────────────────────────── // ─── Member reads ────────────────────────────────────────────────────────
/** /**

View File

@@ -0,0 +1,141 @@
import type { PrismaClient } from '@prisma/client'
/**
* Build the lunch manifest payload for a program. Used by both the
* admin manifest UI and the recap aggregation.
*
* Filters attending members to FinalistConfirmation.status === 'CONFIRMED'.
* Externals are unconditionally included (admin curates them directly).
*/
export async function buildManifest(prisma: PrismaClient, programId: string) {
const event = await prisma.lunchEvent.findUnique({
where: { programId },
include: { dishes: { orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] } },
})
if (!event) {
return {
event: null,
members: [] as ManifestMember[],
externals: [] as ManifestExternal[],
dishes: [],
summary: { total: 0, picked: 0, missing: 0 },
}
}
const ams = await prisma.attendingMember.findMany({
where: {
confirmation: { project: { programId }, status: 'CONFIRMED' },
},
include: {
user: { select: { id: true, name: true, email: true } },
confirmation: { include: { project: { select: { id: true, title: true } } } },
lunchPick: { include: { dish: true } },
},
})
const externals = await prisma.externalAttendee.findMany({
where: { lunchEventId: event.id },
include: {
project: { select: { id: true, title: true } },
dish: true,
},
orderBy: { createdAt: 'asc' },
})
const members: ManifestMember[] = ams.map((am) => ({
kind: 'MEMBER',
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.title },
dish: am.lunchPick?.dish ?? null,
allergens: am.lunchPick?.allergens ?? [],
allergenOther: am.lunchPick?.allergenOther ?? null,
pickedAt: am.lunchPick?.pickedAt ?? null,
}))
const ext: ManifestExternal[] = externals.map((e) => ({
kind: 'EXTERNAL',
externalId: e.id,
name: e.name,
email: e.email,
project: e.project ? { id: e.project.id, name: e.project.title } : null,
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 },
}
}
/**
* Build the recap email payload — manifest plus aggregate counts of dishes,
* dietary tags, and allergens.
*/
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) => ({ dish: r.dish, allergens: r.allergens })),
...m.externals.map((r) => ({ 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,
}
}
// ─── Types ──────────────────────────────────────────────────────────────────
export type ManifestMember = {
kind: 'MEMBER'
attendingMemberId: string
userId: string
name: string
email: string
project: { id: string; name: string }
dish: { id: string; name: string; dietaryTags: string[] } | null
allergens: string[]
allergenOther: string | null
pickedAt: Date | null
}
export type ManifestExternal = {
kind: 'EXTERNAL'
externalId: string
name: string
email: string | null
project: { id: string; name: string } | null
roleNote: string | null
dish: { id: string; name: string; dietaryTags: string[] } | null
allergens: string[]
allergenOther: string | null
pickedAt: Date | null
}

View File

@@ -330,6 +330,90 @@ describe('lunch.getTeamPicks', () => {
}) })
}) })
describe('lunch.getManifest', () => {
it('returns confirmed attending members + externals with merged shape', async () => {
const program = await createTestProgram({ name: `mfst-${uid()}` })
programIds.push(program.id)
const { caller } = await newAdminCaller()
const m = await createTestUser('APPLICANT')
userIds.push(m.id)
const project = await createTestProject(program.id, {
title: `mfst-${uid()}`, competitionCategory: 'STARTUP',
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id, category: 'STARTUP', 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 manifest = await caller.getManifest({ programId: program.id })
expect(manifest.members).toHaveLength(1)
expect(manifest.externals).toHaveLength(1)
expect(manifest.summary.picked).toBe(2)
expect(manifest.summary.missing).toBe(0)
})
it('excludes non-CONFIRMED confirmations', async () => {
const program = await createTestProgram({ name: `mfst-x-${uid()}` })
programIds.push(program.id)
const { caller } = await newAdminCaller()
const u = await createTestUser('APPLICANT')
userIds.push(u.id)
const project = await createTestProject(program.id, {
title: `mfst-x-${uid()}`, competitionCategory: 'STARTUP',
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id, category: 'STARTUP', 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 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({ name: `csv-${uid()}` })
programIds.push(program.id)
const { caller } = await newAdminCaller()
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 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,')
})
})
describe('lunch.updateEvent', () => { describe('lunch.updateEvent', () => {
it('patches an arbitrary subset of fields', async () => { it('patches an arbitrary subset of fields', async () => {
const program = await createTestProgram({ name: `lunch-upd-${uid()}` }) const program = await createTestProgram({ name: `lunch-upd-${uid()}` })