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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user