feat: admin can confirm/decline attendance on team behalf

This edition is being handled manually via email — admins need to
record what each finalist replied. Adds:
  - finalist.adminConfirm — flips PENDING → CONFIRMED with attendees +
    visa flags. Same cap and team-membership checks as the public flow,
    audit-logged as FINALIST_ADMIN_CONFIRM.
  - finalist.adminDecline — flips PENDING → DECLINED with optional
    reason and triggers waitlist promotion. Audit-logged as
    FINALIST_ADMIN_DECLINE.
  - finalist.getConfirmationDetail — feeds the admin attendee picker.
  - Per-row Confirm / Decline actions on the Logistics > Confirmations
    table (PENDING rows only) backed by a shared dialog that switches
    between attendee-picker and reason-input modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-28 19:03:01 +02:00
parent ff355ee10e
commit 6e5f607425
4 changed files with 689 additions and 0 deletions

View File

@@ -418,6 +418,171 @@ export const finalistRouter = router({
return { ok: true }
}),
/**
* Admin override: mark a PENDING finalist confirmation as CONFIRMED on
* behalf of the team. Used when teams reply by email instead of clicking
* the magic link. Same validation as the public `confirm` (cap, team
* membership) but bypasses token verification.
*/
adminConfirm: adminProcedure
.input(
z.object({
confirmationId: z.string(),
attendingUserIds: z.array(z.string()).min(1),
visaFlags: z.record(z.string(), z.boolean()).default({}),
}),
)
.mutation(async ({ ctx, input }) => {
const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({
where: { id: input.confirmationId },
include: {
project: {
select: {
id: true,
programId: true,
program: { select: { defaultAttendeeCap: true } },
teamMembers: { select: { userId: true } },
},
},
},
})
if (confirmation.status !== 'PENDING') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Confirmation is ${confirmation.status}, not PENDING`,
})
}
const cap = confirmation.project.program.defaultAttendeeCap
if (input.attendingUserIds.length > cap) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Selection exceeds attendee cap of ${cap}`,
})
}
const teamUserIds = new Set(confirmation.project.teamMembers.map((tm) => tm.userId))
for (const id of input.attendingUserIds) {
if (!teamUserIds.has(id)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `User ${id} is not a team member of this project`,
})
}
}
await ctx.prisma.$transaction([
ctx.prisma.finalistConfirmation.update({
where: { id: confirmation.id },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
}),
ctx.prisma.attendingMember.createMany({
data: input.attendingUserIds.map((userId) => ({
confirmationId: confirmation.id,
userId,
needsVisa: input.visaFlags[userId] ?? false,
})),
}),
])
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_ADMIN_CONFIRM',
entityType: 'FinalistConfirmation',
entityId: confirmation.id,
detailsJson: {
projectId: confirmation.projectId,
attendingUserIds: input.attendingUserIds,
visaFlags: input.visaFlags,
},
})
return { ok: true }
}),
/**
* Admin override: mark a PENDING finalist confirmation as DECLINED on
* behalf of the team and trigger waitlist promotion. Same effect as the
* public `decline` but bypasses token verification.
*/
adminDecline: adminProcedure
.input(z.object({ confirmationId: z.string(), reason: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
const confirmation = await ctx.prisma.finalistConfirmation.findUniqueOrThrow({
where: { id: input.confirmationId },
include: { project: { select: { programId: true } } },
})
if (confirmation.status !== 'PENDING') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Confirmation is ${confirmation.status}, not PENDING`,
})
}
await ctx.prisma.finalistConfirmation.update({
where: { id: confirmation.id },
data: {
status: 'DECLINED',
declinedAt: new Date(),
declineReason: input.reason ?? null,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_ADMIN_DECLINE',
entityType: 'FinalistConfirmation',
entityId: confirmation.id,
detailsJson: {
projectId: confirmation.projectId,
reason: input.reason ?? null,
},
})
const round = await ctx.prisma.round.findFirst({
where: {
competition: { programId: confirmation.project.programId },
roundType: 'LIVE_FINAL',
},
orderBy: { sortOrder: 'desc' },
select: { configJson: true },
})
const cfg = (round?.configJson ?? {}) as { confirmationWindowHours?: number }
const windowHours = cfg.confirmationWindowHours ?? 24
await promoteNextWaitlistEntry(ctx.prisma, {
programId: confirmation.project.programId,
category: confirmation.category,
windowHours,
})
return { ok: true }
}),
/**
* Returns the team-member roster for a given confirmation so the admin
* UI can render an attendee picker. Filtered by program scope so admins
* can only inspect confirmations in programs they manage.
*/
getConfirmationDetail: adminProcedure
.input(z.object({ confirmationId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.finalistConfirmation.findUniqueOrThrow({
where: { id: input.confirmationId },
include: {
project: {
select: {
id: true,
title: true,
program: { select: { defaultAttendeeCap: true } },
teamMembers: {
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { joinedAt: 'asc' },
},
},
},
attendingMembers: { select: { userId: true, needsVisa: true } },
},
})
}),
/**
* Add a project to the waitlist at a specific rank. Existing entries at
* rank >= input.rank shift down by one to make room.