import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, adminProcedure, protectedProcedure } from '../trpc' import { logAudit } from '../utils/audit' // ─── Shared zod schemas ────────────────────────────────────────────────────── const dietaryTags = z.array( z.enum(['VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN']), ) const allergens = z.array( z.enum([ 'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK', 'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS', ]), ) // ─── Router ────────────────────────────────────────────────────────────────── export const lunchRouter = router({ /** * Get-or-create the LunchEvent for a program. Lazy creation mirrors * the hotel pattern: callers don't have to know whether the row * already exists. */ getEvent: adminProcedure .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => { const existing = await ctx.prisma.lunchEvent.findUnique({ where: { programId: input.programId }, }) if (existing) return existing return ctx.prisma.lunchEvent.create({ data: { programId: input.programId } }) }), // ─── Dish CRUD ──────────────────────────────────────────────────────────── listDishes: adminProcedure .input(z.object({ lunchEventId: z.string() })) .query(({ ctx, input }) => ctx.prisma.dish.findMany({ where: { lunchEventId: input.lunchEventId }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], }), ), createDish: adminProcedure .input( z.object({ lunchEventId: z.string(), name: z.string().min(1).max(200), dietaryTags, sortOrder: z.number().int().optional(), }), ) .mutation(async ({ ctx, input }) => { const dish = await ctx.prisma.dish.create({ data: { lunchEventId: input.lunchEventId, name: input.name, dietaryTags: input.dietaryTags, sortOrder: input.sortOrder ?? 0, }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LUNCH_DISH_CREATED', entityType: 'Dish', entityId: dish.id, detailsJson: { name: dish.name, dietaryTags: dish.dietaryTags }, }) return dish }), updateDish: adminProcedure .input( z.object({ dishId: z.string(), name: z.string().min(1).max(200).optional(), dietaryTags: dietaryTags.optional(), sortOrder: z.number().int().optional(), }), ) .mutation(async ({ ctx, input }) => { const { dishId, ...patch } = input const dish = await ctx.prisma.dish.update({ where: { id: dishId }, data: patch }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LUNCH_DISH_UPDATED', entityType: 'Dish', entityId: dish.id, detailsJson: patch as Record, }) return dish }), deleteDish: adminProcedure .input(z.object({ dishId: z.string() })) .mutation(async ({ ctx, input }) => { const dish = await ctx.prisma.dish.delete({ where: { id: input.dishId } }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LUNCH_DISH_DELETED', entityType: 'Dish', entityId: dish.id, detailsJson: { name: dish.name }, }) return { ok: true as const } }), reorderDishes: adminProcedure .input( z.object({ ordered: z.array( z.object({ dishId: z.string(), sortOrder: z.number().int() }), ), }), ) .mutation(async ({ ctx, input }) => { await ctx.prisma.$transaction( input.ordered.map(({ dishId, sortOrder }) => ctx.prisma.dish.update({ where: { id: dishId }, data: { sortOrder } }), ), ) return { ok: true as const } }), // ─── External attendees CRUD ───────────────────────────────────────────── listExternals: adminProcedure .input(z.object({ lunchEventId: z.string() })) .query(({ ctx, input }) => ctx.prisma.externalAttendee.findMany({ where: { lunchEventId: input.lunchEventId }, orderBy: { createdAt: 'asc' }, include: { project: { select: { id: true, title: true } } }, }), ), createExternal: adminProcedure .input( z.object({ lunchEventId: z.string(), name: z.string().min(1).max(200), email: z.string().email().optional(), projectId: z.string().nullable().optional(), roleNote: z.string().max(500).optional(), dishId: z.string().nullable().optional(), allergens: allergens.optional(), allergenOther: z.string().max(500).optional(), }), ) .mutation(async ({ ctx, input }) => { const ext = await ctx.prisma.externalAttendee.create({ data: { lunchEventId: input.lunchEventId, name: input.name, email: input.email, projectId: input.projectId ?? null, roleNote: input.roleNote, dishId: input.dishId ?? null, allergens: input.allergens ?? [], allergenOther: input.allergenOther, }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LUNCH_EXTERNAL_CREATED', entityType: 'ExternalAttendee', entityId: ext.id, detailsJson: { name: ext.name, projectId: ext.projectId }, }) return ext }), updateExternal: adminProcedure .input( z.object({ externalId: z.string(), name: z.string().min(1).max(200).optional(), email: z.string().email().nullable().optional(), projectId: z.string().nullable().optional(), roleNote: z.string().max(500).nullable().optional(), dishId: z.string().nullable().optional(), allergens: allergens.optional(), allergenOther: z.string().max(500).nullable().optional(), }), ) .mutation(async ({ ctx, input }) => { const { externalId, ...patch } = input const ext = await ctx.prisma.externalAttendee.update({ where: { id: externalId }, data: patch, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LUNCH_EXTERNAL_UPDATED', entityType: 'ExternalAttendee', entityId: ext.id, detailsJson: patch as Record, }) return ext }), deleteExternal: adminProcedure .input(z.object({ externalId: z.string() })) .mutation(async ({ ctx, input }) => { const ext = await ctx.prisma.externalAttendee.delete({ where: { id: input.externalId }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LUNCH_EXTERNAL_DELETED', entityType: 'ExternalAttendee', entityId: ext.id, detailsJson: { name: ext.name }, }) return { ok: true as const } }), // ─── Mixed-permission picker ───────────────────────────────────────────── /** * Upsert a MemberLunchPick. Permission: * - admin (SUPER_ADMIN / PROGRAM_ADMIN): always allowed, no deadline cap * - team lead of the parent project: allowed before deadline * - the AttendingMember.userId themselves: allowed before deadline * - everyone else: FORBIDDEN * Audit-logged with the actor role (SELF / TEAM_LEAD / ADMIN). */ upsertPick: protectedProcedure .input( z.object({ attendingMemberId: z.string(), dishId: z.string().nullable(), allergens, allergenOther: z.string().max(500).nullable(), }), ) .mutation(async ({ ctx, input }) => { const am = await ctx.prisma.attendingMember.findUnique({ where: { id: input.attendingMemberId }, include: { confirmation: { select: { project: { select: { id: true, programId: true, teamMembers: { select: { userId: true, role: true } }, }, }, }, }, lunchPick: true, }, }) if (!am) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Attending member not found', }) } const userId = ctx.user.id const userRole = ctx.user.role const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN' const isSelf = am.userId === userId const isLead = am.confirmation.project.teamMembers.some( (tm) => tm.userId === userId && tm.role === 'LEAD', ) if (!isAdmin && !isSelf && !isLead) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Not allowed to edit this pick', }) } // Cutoff check (admins skip) if (!isAdmin) { const event = await ctx.prisma.lunchEvent.findUnique({ where: { programId: am.confirmation.project.programId }, select: { eventAt: true, changeCutoffHours: true }, }) if (event?.eventAt) { const deadline = new Date( event.eventAt.getTime() - event.changeCutoffHours * 3_600_000, ) if (new Date() > deadline) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Past lunch change deadline. Contact an admin.', }) } } } const actorRole: 'SELF' | 'TEAM_LEAD' | 'ADMIN' = isAdmin ? 'ADMIN' : isLead && !isSelf ? 'TEAM_LEAD' : 'SELF' const pick = await ctx.prisma.memberLunchPick.upsert({ where: { attendingMemberId: input.attendingMemberId }, create: { attendingMemberId: input.attendingMemberId, dishId: input.dishId, allergens: input.allergens, allergenOther: input.allergenOther, pickedAt: input.dishId ? new Date() : null, }, update: { dishId: input.dishId, allergens: input.allergens, allergenOther: input.allergenOther, pickedAt: input.dishId ? new Date() : null, }, }) await logAudit({ prisma: ctx.prisma, userId, action: 'LUNCH_PICK_UPDATED', entityType: 'MemberLunchPick', entityId: pick.id, detailsJson: { actorRole, dishId: input.dishId, allergenCount: input.allergens.length, }, }) return pick }), /** Patch any subset of LunchEvent config fields. Audit-logged. */ updateEvent: adminProcedure .input( z.object({ programId: z.string(), enabled: z.boolean().optional(), eventAt: z.date().nullable().optional(), endAt: z.date().nullable().optional(), venue: z.string().nullable().optional(), notes: z.string().nullable().optional(), changeCutoffHours: z.number().int().min(0).max(720).optional(), reminderHoursBeforeDeadline: z .number() .int() .min(0) .max(720) .nullable() .optional(), cronEnabled: z.boolean().optional(), extraRecipients: z.array(z.string().email()).optional(), }), ) .mutation(async ({ ctx, input }) => { const { programId, ...patch } = input // Lazy-create before patching so updateEvent doubles as "create + update" await ctx.prisma.lunchEvent.upsert({ where: { programId }, create: { programId }, update: {}, }) const updated = await ctx.prisma.lunchEvent.update({ where: { programId }, data: patch, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LUNCH_EVENT_UPDATED', entityType: 'LunchEvent', entityId: updated.id, detailsJson: patch as Record, }) return updated }), })