142 lines
4.5 KiB
TypeScript
142 lines
4.5 KiB
TypeScript
|
|
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
|
||
|
|
}
|