feat: external lunch attendees CRUD
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -129,6 +129,102 @@ export const lunchRouter = router({
|
|||||||
return { ok: true as const }
|
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<string, unknown>,
|
||||||
|
})
|
||||||
|
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 }
|
||||||
|
}),
|
||||||
|
|
||||||
/** Patch any subset of LunchEvent config fields. Audit-logged. */
|
/** Patch any subset of LunchEvent config fields. Audit-logged. */
|
||||||
updateEvent: adminProcedure
|
updateEvent: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -148,6 +148,76 @@ describe('dish CRUD', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('external attendees CRUD', () => {
|
||||||
|
it('listExternals returns standalone + project-attached entries', async () => {
|
||||||
|
const program = await createTestProgram({ name: `ext-list-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const { caller } = await newAdminCaller()
|
||||||
|
const event = await caller.getEvent({ programId: program.id })
|
||||||
|
const project = await createTestProject(program.id, {
|
||||||
|
title: `ext-${uid()}`,
|
||||||
|
competitionCategory: 'STARTUP',
|
||||||
|
})
|
||||||
|
await caller.createExternal({
|
||||||
|
lunchEventId: event.id, name: 'Princess Albert',
|
||||||
|
roleNote: 'Foundation rep',
|
||||||
|
})
|
||||||
|
await caller.createExternal({
|
||||||
|
lunchEventId: event.id, name: 'Speaker Smith',
|
||||||
|
projectId: project.id, email: 's@example.com',
|
||||||
|
})
|
||||||
|
const list = await caller.listExternals({ lunchEventId: event.id })
|
||||||
|
expect(list).toHaveLength(2)
|
||||||
|
expect(list.find((e) => e.name === 'Princess Albert')?.projectId).toBeNull()
|
||||||
|
expect(list.find((e) => e.name === 'Speaker Smith')?.projectId).toBe(project.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateExternal patches fields including dishId + allergens', async () => {
|
||||||
|
const program = await createTestProgram({ name: `ext-upd-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const { caller } = await newAdminCaller()
|
||||||
|
const event = await caller.getEvent({ programId: program.id })
|
||||||
|
const dish = await caller.createDish({
|
||||||
|
lunchEventId: event.id, name: 'Steak', dietaryTags: [],
|
||||||
|
})
|
||||||
|
const ext = await caller.createExternal({ lunchEventId: event.id, name: 'X' })
|
||||||
|
const updated = await caller.updateExternal({
|
||||||
|
externalId: ext.id, dishId: dish.id, allergens: ['GLUTEN', 'TREE_NUTS'],
|
||||||
|
allergenOther: 'sulphites in red wine',
|
||||||
|
})
|
||||||
|
expect(updated.dishId).toBe(dish.id)
|
||||||
|
expect(updated.allergens).toEqual(['GLUTEN', 'TREE_NUTS'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteExternal removes the row', async () => {
|
||||||
|
const program = await createTestProgram({ name: `ext-del-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const { caller } = await newAdminCaller()
|
||||||
|
const event = await caller.getEvent({ programId: program.id })
|
||||||
|
const ext = await caller.createExternal({ lunchEventId: event.id, name: 'tmp' })
|
||||||
|
await caller.deleteExternal({ externalId: ext.id })
|
||||||
|
const list = await caller.listExternals({ lunchEventId: event.id })
|
||||||
|
expect(list.find((e) => e.id === ext.id)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-admin createExternal', async () => {
|
||||||
|
const program = await createTestProgram({ name: `ext-rej-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const { caller } = await newAdminCaller()
|
||||||
|
const event = await caller.getEvent({ programId: program.id })
|
||||||
|
const member = await createTestUser('APPLICANT')
|
||||||
|
userIds.push(member.id)
|
||||||
|
const memberCaller = createCaller(lunchRouter, {
|
||||||
|
id: member.id,
|
||||||
|
email: member.email,
|
||||||
|
role: 'APPLICANT',
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
memberCaller.createExternal({ lunchEventId: event.id, name: 'nope' }),
|
||||||
|
).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