diff --git a/src/server/routers/lunch.ts b/src/server/routers/lunch.ts index b565635..dd936b9 100644 --- a/src/server/routers/lunch.ts +++ b/src/server/routers/lunch.ts @@ -2,7 +2,8 @@ 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' +import { buildManifest, buildRecapPayload } from '../services/lunch-recap' +import { sendLunchRecapEmail } from '@/lib/email' // ─── Shared zod schemas ────────────────────────────────────────────────────── @@ -268,6 +269,67 @@ export const lunchRouter = router({ return lines.join('\n') }), + // ─── Recap preview + send ──────────────────────────────────────────────── + + getRecapPreview: adminProcedure + .input(z.object({ programId: z.string() })) + .query(({ ctx, input }) => buildRecapPayload(ctx.prisma, input.programId)), + + sendRecap: adminProcedure + .input( + z.object({ + programId: z.string(), + forceUpdate: z.boolean().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const event = await ctx.prisma.lunchEvent.findUnique({ + where: { programId: input.programId }, + }) + if (!event) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lunch event not found' }) + if (event.recapSentAt && !input.forceUpdate) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Recap already sent. Pass forceUpdate=true to resend.', + }) + } + const payload = await buildRecapPayload(ctx.prisma, input.programId) + const adminUsers = await ctx.prisma.user.findMany({ + where: { + role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] }, + email: { not: '' }, + }, + select: { email: true }, + }) + const recipients = [ + ...adminUsers.map((u) => u.email).filter(Boolean), + ...event.extraRecipients, + ] + try { + await sendLunchRecapEmail(recipients, payload) + } catch (e) { + console.error('[lunch.sendRecap] email send failed', e) + // Continue — we still stamp recapSentAt and audit so admins see what happened. + } + const updated = await ctx.prisma.lunchEvent.update({ + where: { programId: input.programId }, + data: { recapSentAt: new Date() }, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'LUNCH_RECAP_SENT', + entityType: 'LunchEvent', + entityId: event.id, + detailsJson: { + recipientCount: recipients.length, + forceUpdate: !!input.forceUpdate, + source: 'manual', + }, + }) + return updated + }), + // ─── Member reads ──────────────────────────────────────────────────────── /** diff --git a/tests/unit/lunch-recap.test.ts b/tests/unit/lunch-recap.test.ts new file mode 100644 index 0000000..e7f3fd7 --- /dev/null +++ b/tests/unit/lunch-recap.test.ts @@ -0,0 +1,118 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + cleanupTestData, + uid, +} from '../helpers' +import { lunchRouter } from '@/server/routers/lunch' +import { buildRecapPayload } from '@/server/services/lunch-recap' + +vi.mock('@/lib/email', async () => { + const actual = await vi.importActual('@/lib/email') + return { ...actual, sendLunchRecapEmail: vi.fn(async () => undefined) } +}) + +const programIds: string[] = [] +const userIds: string[] = [] + +afterAll(async () => { + for (const programId of programIds) { + await prisma.externalAttendee.deleteMany({ where: { lunchEvent: { programId } } }) + await prisma.dish.deleteMany({ where: { lunchEvent: { programId } } }) + await prisma.lunchEvent.deleteMany({ where: { programId } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }) + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } +}) + +describe('buildRecapPayload', () => { + it('aggregates dish + dietary + allergen counts', async () => { + const program = await createTestProgram({ name: `recap-${uid()}` }) + programIds.push(program.id) + const event = await prisma.lunchEvent.create({ + data: { programId: program.id, enabled: true }, + }) + const veg = await prisma.dish.create({ + data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'] }, + }) + const fish = await prisma.dish.create({ + data: { lunchEventId: event.id, name: 'Sea bass', dietaryTags: ['PESCATARIAN'] }, + }) + await prisma.externalAttendee.create({ + data: { lunchEventId: event.id, name: 'A', dishId: veg.id, allergens: ['GLUTEN'] }, + }) + await prisma.externalAttendee.create({ + data: { lunchEventId: event.id, name: 'B', dishId: fish.id, allergens: ['GLUTEN', 'FISH'] }, + }) + const payload = await buildRecapPayload(prisma, program.id) + expect(payload.dishCounts['Risotto']).toBe(1) + expect(payload.dishCounts['Sea bass']).toBe(1) + expect(payload.dietaryCounts['VEGETARIAN']).toBe(1) + expect(payload.dietaryCounts['PESCATARIAN']).toBe(1) + expect(payload.allergenCounts['GLUTEN']).toBe(2) + expect(payload.allergenCounts['FISH']).toBe(1) + }) +}) + +describe('lunch.sendRecap', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + async function newAdminCaller() { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const caller = createCaller(lunchRouter, { + id: admin.id, email: admin.email, role: 'SUPER_ADMIN', + }) + return { admin, caller } + } + + it('sends and stamps recapSentAt on first call', async () => { + const program = await createTestProgram({ name: `recap-send-${uid()}` }) + programIds.push(program.id) + await prisma.lunchEvent.create({ + data: { programId: program.id, enabled: true }, + }) + const { caller } = await newAdminCaller() + await caller.sendRecap({ programId: program.id }) + const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } }) + expect(row?.recapSentAt).not.toBeNull() + }) + + it('throws PRECONDITION_FAILED on second send unless forceUpdate', async () => { + const program = await createTestProgram({ name: `recap-pre-${uid()}` }) + programIds.push(program.id) + await prisma.lunchEvent.create({ + data: { programId: program.id, enabled: true, recapSentAt: new Date() }, + }) + const { caller } = await newAdminCaller() + await expect( + caller.sendRecap({ programId: program.id }), + ).rejects.toThrow(/already sent/i) + await expect( + caller.sendRecap({ programId: program.id, forceUpdate: true }), + ).resolves.toBeTruthy() + }) + + it('writes a LUNCH_RECAP_SENT audit row', async () => { + const program = await createTestProgram({ name: `recap-audit-${uid()}` }) + programIds.push(program.id) + await prisma.lunchEvent.create({ + data: { programId: program.id, enabled: true }, + }) + const { admin, caller } = await newAdminCaller() + await caller.sendRecap({ programId: program.id }) + const audit = await prisma.auditLog.findFirst({ + where: { action: 'LUNCH_RECAP_SENT', userId: admin.id }, + orderBy: { timestamp: 'desc' }, + }) + expect(audit).not.toBeNull() + expect((audit?.detailsJson as Record | null)?.source).toBe('manual') + }) +})