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:
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
141
src/server/services/lunch-recap.ts
Normal file
141
src/server/services/lunch-recap.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user