diff --git a/src/server/routers/lunch.ts b/src/server/routers/lunch.ts index 7666af7..28f2f79 100644 --- a/src/server/routers/lunch.ts +++ b/src/server/routers/lunch.ts @@ -225,6 +225,122 @@ export const lunchRouter = router({ 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( diff --git a/tests/unit/lunch-upsert-pick.test.ts b/tests/unit/lunch-upsert-pick.test.ts new file mode 100644 index 0000000..75cb89e --- /dev/null +++ b/tests/unit/lunch-upsert-pick.test.ts @@ -0,0 +1,159 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' +import { lunchRouter } from '@/server/routers/lunch' + +const programIds: string[] = [] +const userIds: string[] = [] + +afterAll(async () => { + for (const programId of programIds) { + await prisma.memberLunchPick.deleteMany({ + where: { attendingMember: { confirmation: { project: { programId } } } }, + }) + await prisma.attendingMember.deleteMany({ + where: { confirmation: { project: { programId } } }, + }) + await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } }) + await prisma.dish.deleteMany({ where: { lunchEvent: { programId } } }) + await prisma.lunchEvent.deleteMany({ where: { programId } }) + await prisma.teamMember.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }) + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } +}) + +async function setupTeam(opts: { + cutoffHours?: number + eventAt?: Date + enabled?: boolean +}) { + const program = await createTestProgram({ name: `pick-${uid()}` }) + programIds.push(program.id) + const lead = await createTestUser('APPLICANT') + const member = await createTestUser('APPLICANT') + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(lead.id, member.id, admin.id) + const project = await createTestProject(program.id, { + title: `pick-${uid()}`, + competitionCategory: 'STARTUP', + }) + await prisma.teamMember.createMany({ + data: [ + { projectId: project.id, userId: lead.id, role: 'LEAD' }, + { projectId: project.id, userId: member.id, role: 'MEMBER' }, + ], + }) + 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: member.id }, + }) + const event = await prisma.lunchEvent.create({ + data: { + programId: program.id, + enabled: opts.enabled ?? true, + eventAt: opts.eventAt ?? new Date(Date.now() + 7 * 86_400_000), + changeCutoffHours: opts.cutoffHours ?? 48, + }, + }) + const dish = await prisma.dish.create({ + data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'] }, + }) + await prisma.memberLunchPick.create({ data: { attendingMemberId: am.id } }) + return { program, lead, member, admin, project, attendingMember: am, dish, event } +} + +function callerFor(user: { id: string; email: string; role: string }) { + return createCaller(lunchRouter, user) +} + +describe('lunch.upsertPick', () => { + it('member can edit their own pick before deadline', async () => { + const t = await setupTeam({}) + const caller = callerFor({ id: t.member.id, email: t.member.email, role: 'APPLICANT' }) + const result = await caller.upsertPick({ + attendingMemberId: t.attendingMember.id, dishId: t.dish.id, + allergens: ['GLUTEN'], allergenOther: null, + }) + expect(result.dishId).toBe(t.dish.id) + expect(result.pickedAt).not.toBeNull() + const audit = await prisma.auditLog.findFirst({ + where: { action: 'LUNCH_PICK_UPDATED', entityId: result.id }, + orderBy: { timestamp: 'desc' }, + }) + expect((audit?.detailsJson as Record | null)?.actorRole).toBe('SELF') + }) + + it('team lead can edit a teammate pick before deadline', async () => { + const t = await setupTeam({}) + const caller = callerFor({ id: t.lead.id, email: t.lead.email, role: 'APPLICANT' }) + const result = await caller.upsertPick({ + attendingMemberId: t.attendingMember.id, dishId: t.dish.id, + allergens: [], allergenOther: null, + }) + expect(result.dishId).toBe(t.dish.id) + const audit = await prisma.auditLog.findFirst({ + where: { action: 'LUNCH_PICK_UPDATED', entityId: result.id }, + orderBy: { timestamp: 'desc' }, + }) + expect((audit?.detailsJson as Record | null)?.actorRole).toBe('TEAM_LEAD') + }) + + it('non-team-member is forbidden', async () => { + const t = await setupTeam({}) + const stranger = await createTestUser('APPLICANT') + userIds.push(stranger.id) + const caller = callerFor({ id: stranger.id, email: stranger.email, role: 'APPLICANT' }) + await expect( + caller.upsertPick({ + attendingMemberId: t.attendingMember.id, dishId: t.dish.id, + allergens: [], allergenOther: null, + }), + ).rejects.toThrow(/not allowed/i) + }) + + it('member cannot edit their own pick after deadline', async () => { + // event is "now + 1h", cutoffHours = 24 -> deadline already passed + const t = await setupTeam({ + eventAt: new Date(Date.now() + 60 * 60 * 1000), cutoffHours: 24, + }) + const caller = callerFor({ id: t.member.id, email: t.member.email, role: 'APPLICANT' }) + await expect( + caller.upsertPick({ + attendingMemberId: t.attendingMember.id, dishId: t.dish.id, + allergens: [], allergenOther: null, + }), + ).rejects.toThrow(/deadline/i) + }) + + it('admin can edit after deadline; audit records ADMIN role', async () => { + const t = await setupTeam({ + eventAt: new Date(Date.now() + 60 * 60 * 1000), cutoffHours: 24, + }) + const caller = callerFor({ id: t.admin.id, email: t.admin.email, role: 'SUPER_ADMIN' }) + const result = await caller.upsertPick({ + attendingMemberId: t.attendingMember.id, dishId: t.dish.id, + allergens: [], allergenOther: null, + }) + expect(result.dishId).toBe(t.dish.id) + const audit = await prisma.auditLog.findFirst({ + where: { action: 'LUNCH_PICK_UPDATED', entityId: result.id }, + orderBy: { timestamp: 'desc' }, + }) + expect((audit?.detailsJson as Record | null)?.actorRole).toBe('ADMIN') + }) +})