feat: lunch member reads — getEventForMember + getTeamPicks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -225,6 +225,70 @@ export const lunchRouter = router({
|
|||||||
return { ok: true as const }
|
return { ok: true as const }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ─── Member reads ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public-ish event view for the applicant dashboard banner.
|
||||||
|
* Returns null when the lunch event is disabled (banner hidden).
|
||||||
|
*/
|
||||||
|
getEventForMember: protectedProcedure
|
||||||
|
.input(z.object({ programId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const event = await ctx.prisma.lunchEvent.findUnique({
|
||||||
|
where: { programId: input.programId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
enabled: true,
|
||||||
|
eventAt: true,
|
||||||
|
endAt: true,
|
||||||
|
venue: true,
|
||||||
|
notes: true,
|
||||||
|
changeCutoffHours: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!event || !event.enabled) return null
|
||||||
|
const changeDeadline = event.eventAt
|
||||||
|
? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3_600_000)
|
||||||
|
: null
|
||||||
|
return { ...event, changeDeadline }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All picks for the caller's team. Within-team transparency: every team
|
||||||
|
* member sees their teammates' picks (lunch picks aren't sensitive).
|
||||||
|
* Cross-team and admins go through the manifest endpoint instead, which
|
||||||
|
* has more detail.
|
||||||
|
*/
|
||||||
|
getTeamPicks: protectedProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user.id
|
||||||
|
const role = ctx.user.role
|
||||||
|
const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
|
||||||
|
if (!isAdmin) {
|
||||||
|
const tm = await ctx.prisma.teamMember.findFirst({
|
||||||
|
where: { projectId: input.projectId, userId },
|
||||||
|
})
|
||||||
|
if (!tm) throw new TRPCError({ code: 'FORBIDDEN' })
|
||||||
|
}
|
||||||
|
const ams = await ctx.prisma.attendingMember.findMany({
|
||||||
|
where: { confirmation: { projectId: input.projectId } },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
lunchPick: { include: { dish: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return ams.map((am) => ({
|
||||||
|
attendingMemberId: am.id,
|
||||||
|
userId: am.user.id,
|
||||||
|
memberName: am.user.name ?? am.user.email,
|
||||||
|
dish: am.lunchPick?.dish ?? null,
|
||||||
|
allergens: am.lunchPick?.allergens ?? [],
|
||||||
|
allergenOther: am.lunchPick?.allergenOther ?? null,
|
||||||
|
hasPicked: !!am.lunchPick?.pickedAt,
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
|
||||||
// ─── Mixed-permission picker ─────────────────────────────────────────────
|
// ─── Mixed-permission picker ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -218,6 +218,118 @@ describe('external attendees CRUD', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('lunch.getEventForMember', () => {
|
||||||
|
it('returns event details when enabled', async () => {
|
||||||
|
const program = await createTestProgram({ name: `efm-en-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const member = await createTestUser('APPLICANT')
|
||||||
|
userIds.push(member.id)
|
||||||
|
await prisma.lunchEvent.create({
|
||||||
|
data: {
|
||||||
|
programId: program.id,
|
||||||
|
enabled: true,
|
||||||
|
eventAt: new Date('2026-06-28T12:30:00Z'),
|
||||||
|
venue: 'Hôtel',
|
||||||
|
changeCutoffHours: 48,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const caller = createCaller(lunchRouter, {
|
||||||
|
id: member.id,
|
||||||
|
email: member.email,
|
||||||
|
role: 'APPLICANT',
|
||||||
|
})
|
||||||
|
const result = await caller.getEventForMember({ programId: program.id })
|
||||||
|
expect(result?.venue).toBe('Hôtel')
|
||||||
|
expect(result?.changeDeadline).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when event is disabled', async () => {
|
||||||
|
const program = await createTestProgram({ name: `efm-dis-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const member = await createTestUser('APPLICANT')
|
||||||
|
userIds.push(member.id)
|
||||||
|
await prisma.lunchEvent.create({
|
||||||
|
data: { programId: program.id, enabled: false },
|
||||||
|
})
|
||||||
|
const caller = createCaller(lunchRouter, {
|
||||||
|
id: member.id,
|
||||||
|
email: member.email,
|
||||||
|
role: 'APPLICANT',
|
||||||
|
})
|
||||||
|
const result = await caller.getEventForMember({ programId: program.id })
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lunch.getTeamPicks', () => {
|
||||||
|
it('returns picks for all attending members of the caller team', async () => {
|
||||||
|
const program = await createTestProgram({ name: `tp-ok-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const lead = await createTestUser('APPLICANT')
|
||||||
|
const m1 = await createTestUser('APPLICANT')
|
||||||
|
const m2 = await createTestUser('APPLICANT')
|
||||||
|
userIds.push(lead.id, m1.id, m2.id)
|
||||||
|
const project = await createTestProject(program.id, {
|
||||||
|
title: `tp-${uid()}`,
|
||||||
|
competitionCategory: 'STARTUP',
|
||||||
|
})
|
||||||
|
await prisma.teamMember.createMany({
|
||||||
|
data: [
|
||||||
|
{ projectId: project.id, userId: lead.id, role: 'LEAD' },
|
||||||
|
{ projectId: project.id, userId: m1.id, role: 'MEMBER' },
|
||||||
|
{ projectId: project.id, userId: m2.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 am1 = await prisma.attendingMember.create({
|
||||||
|
data: { confirmationId: conf.id, userId: m1.id },
|
||||||
|
})
|
||||||
|
const am2 = await prisma.attendingMember.create({
|
||||||
|
data: { confirmationId: conf.id, userId: m2.id },
|
||||||
|
})
|
||||||
|
const event = await prisma.lunchEvent.create({
|
||||||
|
data: { programId: program.id, enabled: true },
|
||||||
|
})
|
||||||
|
const dish = await prisma.dish.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'X', dietaryTags: [] },
|
||||||
|
})
|
||||||
|
await prisma.memberLunchPick.create({
|
||||||
|
data: { attendingMemberId: am1.id, dishId: dish.id, pickedAt: new Date() },
|
||||||
|
})
|
||||||
|
await prisma.memberLunchPick.create({ data: { attendingMemberId: am2.id } })
|
||||||
|
|
||||||
|
const caller = createCaller(lunchRouter, {
|
||||||
|
id: lead.id, email: lead.email, role: 'APPLICANT',
|
||||||
|
})
|
||||||
|
const picks = await caller.getTeamPicks({ projectId: project.id })
|
||||||
|
expect(picks).toHaveLength(2)
|
||||||
|
expect(picks.find((p) => p.userId === m1.id)?.hasPicked).toBe(true)
|
||||||
|
expect(picks.find((p) => p.userId === m2.id)?.hasPicked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-team-member callers', async () => {
|
||||||
|
const program = await createTestProgram({ name: `tp-rej-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const stranger = await createTestUser('APPLICANT')
|
||||||
|
userIds.push(stranger.id)
|
||||||
|
const project = await createTestProject(program.id, {
|
||||||
|
title: `tp-rej-${uid()}`,
|
||||||
|
competitionCategory: 'STARTUP',
|
||||||
|
})
|
||||||
|
const caller = createCaller(lunchRouter, {
|
||||||
|
id: stranger.id, email: stranger.email, role: 'APPLICANT',
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
caller.getTeamPicks({ projectId: project.id }),
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
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