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 }