diff --git a/src/server/routers/lunch.ts b/src/server/routers/lunch.ts index ffb84d8..b565635 100644 --- a/src/server/routers/lunch.ts +++ b/src/server/routers/lunch.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, adminProcedure, protectedProcedure } from '../trpc' import { logAudit } from '../utils/audit' +import { buildManifest } from '../services/lunch-recap' // ─── Shared zod schemas ────────────────────────────────────────────────────── @@ -225,6 +226,48 @@ export const lunchRouter = router({ 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 ──────────────────────────────────────────────────────── /** diff --git a/src/server/services/lunch-recap.ts b/src/server/services/lunch-recap.ts new file mode 100644 index 0000000..01dd04d --- /dev/null +++ b/src/server/services/lunch-recap.ts @@ -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 = {} + const dietaryCounts: Record = {} + const allergenCounts: Record = {} + 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 +} diff --git a/tests/unit/lunch-router.test.ts b/tests/unit/lunch-router.test.ts index 59768de..bf49839 100644 --- a/tests/unit/lunch-router.test.ts +++ b/tests/unit/lunch-router.test.ts @@ -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', () => { it('patches an arbitrary subset of fields', async () => { const program = await createTestProgram({ name: `lunch-upd-${uid()}` })