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 { 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 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -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()}` })
|
||||||
|
|||||||
Reference in New Issue
Block a user