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

@@ -0,0 +1,235 @@
'use client'
import { useEffect, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export type AttendanceMode = 'confirm' | 'decline'
export function AdminAttendanceDialog({
open,
mode,
confirmationId,
programId,
onOpenChange,
}: {
open: boolean
mode: AttendanceMode
confirmationId: string | null
programId: string
onOpenChange: (next: boolean) => void
}) {
const utils = trpc.useUtils()
const enabled = open && !!confirmationId
const { data: detail, isLoading } = trpc.finalist.getConfirmationDetail.useQuery(
{ confirmationId: confirmationId ?? '' },
{ enabled },
)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [visa, setVisa] = useState<Record<string, boolean>>({})
const [reason, setReason] = useState('')
const invalidate = () => {
utils.logistics.listConfirmations.invalidate({ programId })
}
const confirmMutation = trpc.finalist.adminConfirm.useMutation({
onSuccess: () => {
toast.success('Attendance confirmed')
invalidate()
onOpenChange(false)
},
onError: (e) => toast.error(e.message),
})
const declineMutation = trpc.finalist.adminDecline.useMutation({
onSuccess: () => {
toast.success('Marked as declined')
invalidate()
onOpenChange(false)
},
onError: (e) => toast.error(e.message),
})
// Reset form when the dialog opens for a new row
useEffect(() => {
if (!open) return
setReason('')
if (detail) {
// Default-pre-select the team lead + up to cap members
const cap = detail.project.program.defaultAttendeeCap
const initial = new Set(
detail.project.teamMembers.slice(0, cap).map((tm) => tm.userId),
)
setSelected(initial)
setVisa({})
}
}, [open, detail])
const isPending = confirmMutation.isPending || declineMutation.isPending
const handleConfirm = () => {
if (!confirmationId) return
const ids = Array.from(selected)
confirmMutation.mutate({
confirmationId,
attendingUserIds: ids,
visaFlags: Object.fromEntries(ids.map((id) => [id, !!visa[id]])),
})
}
const handleDecline = () => {
if (!confirmationId) return
declineMutation.mutate({
confirmationId,
reason: reason.trim() || undefined,
})
}
const cap = detail?.project.program.defaultAttendeeCap ?? 3
const overCap = selected.size > cap
const noneSelected = selected.size === 0
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!isPending) onOpenChange(next)
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{mode === 'confirm' ? 'Confirm attendance on team behalf' : 'Decline on team behalf'}
</DialogTitle>
<DialogDescription>
{mode === 'confirm'
? 'Use this when the team replied by email. The selected attendees will be locked in just like a public confirmation.'
: 'Use this when the team has told us they cannot attend. The slot will cascade to the next waitlist entry.'}
</DialogDescription>
</DialogHeader>
{isLoading || !detail ? (
<div className="space-y-2">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-20 w-full" />
</div>
) : mode === 'confirm' ? (
<>
<div className="text-sm">
<span className="text-muted-foreground">Project:</span>{' '}
<strong>{detail.project.title}</strong>
</div>
<ul className="space-y-2 max-h-[50vh] overflow-y-auto pr-1">
{detail.project.teamMembers.map((tm) => {
const checked = selected.has(tm.userId)
return (
<li
key={tm.userId}
className="flex items-start justify-between gap-4 rounded-md border px-3 py-2"
>
<label className="flex flex-1 items-start gap-3 cursor-pointer">
<Checkbox
checked={checked}
onCheckedChange={(c) => {
setSelected((prev) => {
const next = new Set(prev)
if (c === true) next.add(tm.userId)
else next.delete(tm.userId)
return next
})
}}
className="mt-0.5"
/>
<div>
<div className="text-sm font-medium">
{tm.user.name ?? tm.user.email}
</div>
<div className="text-muted-foreground text-xs">
{tm.user.email}
{tm.role && tm.role !== 'MEMBER' ? ` · ${tm.role.toLowerCase()}` : ''}
</div>
</div>
</label>
{checked && (
<label className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Visa?</span>
<Switch
checked={!!visa[tm.userId]}
onCheckedChange={(c) =>
setVisa((prev) => ({ ...prev, [tm.userId]: c }))
}
/>
</label>
)}
</li>
)
})}
</ul>
{overCap && (
<p className="text-destructive text-sm">
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
</p>
)}
</>
) : (
<>
<div className="text-sm">
<span className="text-muted-foreground">Project:</span>{' '}
<strong>{detail.project.title}</strong>
</div>
<div className="space-y-2">
<label className="text-muted-foreground text-sm" htmlFor="admin-decline-reason">
Reason (optional)
</label>
<Textarea
id="admin-decline-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. logistics conflict, team disbanded, no longer interested"
rows={3}
/>
</div>
</>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
{mode === 'confirm' ? (
<Button
onClick={handleConfirm}
disabled={!detail || overCap || noneSelected || isPending}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm attendance
</Button>
) : (
<Button
onClick={handleDecline}
disabled={!detail || isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Mark as declined
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -13,8 +13,10 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { formatEnumLabel } from '@/lib/utils' import { formatEnumLabel } from '@/lib/utils'
import type { FinalistConfirmationStatus } from '@prisma/client' import type { FinalistConfirmationStatus } from '@prisma/client'
import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog'
interface Props { interface Props {
programId: string programId: string
@@ -51,6 +53,11 @@ function relativeFromNow(d: Date): string {
export function ConfirmationsTab({ programId }: Props) { export function ConfirmationsTab({ programId }: Props) {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all') const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [dialogState, setDialogState] = useState<{
open: boolean
mode: AttendanceMode
confirmationId: string | null
}>({ open: false, mode: 'confirm', confirmationId: null })
const { data, isLoading } = trpc.logistics.listConfirmations.useQuery( const { data, isLoading } = trpc.logistics.listConfirmations.useQuery(
{ programId }, { programId },
{ refetchInterval: 60_000 }, { refetchInterval: 60_000 },
@@ -130,11 +137,13 @@ export function ConfirmationsTab({ programId }: Props) {
<TableHead>Deadline</TableHead> <TableHead>Deadline</TableHead>
<TableHead className="text-right">Attendees</TableHead> <TableHead className="text-right">Attendees</TableHead>
<TableHead>Notes</TableHead> <TableHead>Notes</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filtered.map((r) => { {filtered.map((r) => {
const badge = STATUS_BADGE[r.status] const badge = STATUS_BADGE[r.status]
const isPending = r.status === 'PENDING'
return ( return (
<TableRow key={r.id}> <TableRow key={r.id}>
<TableCell> <TableCell>
@@ -179,6 +188,40 @@ export function ConfirmationsTab({ programId }: Props) {
? `Expired ${formatDeadline(new Date(r.expiredAt))}` ? `Expired ${formatDeadline(new Date(r.expiredAt))}`
: '—'} : '—'}
</TableCell> </TableCell>
<TableCell className="text-right">
{isPending ? (
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="default"
onClick={() =>
setDialogState({
open: true,
mode: 'confirm',
confirmationId: r.id,
})
}
>
Confirm
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
setDialogState({
open: true,
mode: 'decline',
confirmationId: r.id,
})
}
>
Decline
</Button>
</div>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</TableCell>
</TableRow> </TableRow>
) )
})} })}
@@ -188,6 +231,16 @@ export function ConfirmationsTab({ programId }: Props) {
)} )}
</CardContent> </CardContent>
</Card> </Card>
<AdminAttendanceDialog
open={dialogState.open}
mode={dialogState.mode}
confirmationId={dialogState.confirmationId}
programId={programId}
onOpenChange={(next) =>
setDialogState((prev) => ({ ...prev, open: next }))
}
/>
</div> </div>
) )
} }

View File

@@ -418,6 +418,171 @@ export const finalistRouter = router({
return { ok: true } 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 * Add a project to the waitlist at a specific rank. Existing entries at
* rank >= input.rank shift down by one to make room. * rank >= input.rank shift down by one to make room.

View File

@@ -0,0 +1,236 @@
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
createTestCompetition,
createTestRound,
cleanupTestData,
uid,
} from '../helpers'
import { finalistRouter } from '../../src/server/routers/finalist'
async function createApplicant(role: 'LEAD' | 'MEMBER' = 'MEMBER') {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: `Test ${role}`,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'ACTIVE',
},
})
}
async function setup(opts: {
programName: string
status?: 'PENDING' | 'CONFIRMED' | 'DECLINED'
}) {
const program = await createTestProgram({ name: opts.programName, defaultAttendeeCap: 3 })
const project = await createTestProject(program.id, {
title: 'P',
competitionCategory: 'STARTUP',
})
const lead = await createApplicant('LEAD')
const member = await createApplicant('MEMBER')
await prisma.teamMember.createMany({
data: [
{ projectId: project.id, userId: lead.id, role: 'LEAD' },
{ projectId: project.id, userId: member.id, role: 'MEMBER' },
],
})
const competition = await createTestCompetition(program.id)
await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
sortOrder: 99,
configJson: { confirmationWindowHours: 24 },
})
const confirmation = await prisma.finalistConfirmation.create({
data: {
projectId: project.id,
category: 'STARTUP',
status: opts.status ?? 'PENDING',
deadline: new Date(Date.now() + 86_400_000),
token: `tok_${uid()}`,
confirmedAt: opts.status === 'CONFIRMED' ? new Date() : null,
},
})
return { program, project, lead, member, confirmation }
}
describe('finalist.adminConfirm', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } },
})
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('flips PENDING → CONFIRMED with attendee rows + audit log', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, lead, member, confirmation } = await setup({
programName: `admin-confirm-${uid()}`,
})
programIds.push(program.id)
userIds.push(lead.id, member.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.adminConfirm({
confirmationId: confirmation.id,
attendingUserIds: [lead.id, member.id],
visaFlags: { [member.id]: true },
})
const updated = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { id: confirmation.id },
})
expect(updated.status).toBe('CONFIRMED')
expect(updated.confirmedAt).not.toBeNull()
const attendees = await prisma.attendingMember.findMany({
where: { confirmationId: confirmation.id },
})
expect(attendees).toHaveLength(2)
expect(attendees.find((a) => a.userId === member.id)?.needsVisa).toBe(true)
const audit = await prisma.auditLog.findFirst({
where: { action: 'FINALIST_ADMIN_CONFIRM', entityId: confirmation.id },
})
expect(audit).not.toBeNull()
})
it('rejects when confirmation is not PENDING', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, lead, member, confirmation } = await setup({
programName: `admin-confirm-already-${uid()}`,
status: 'CONFIRMED',
})
programIds.push(program.id)
userIds.push(lead.id, member.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await expect(
caller.adminConfirm({
confirmationId: confirmation.id,
attendingUserIds: [lead.id],
}),
).rejects.toThrow(/PENDING/i)
})
it('rejects when attendee is not a team member', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, lead, member, confirmation } = await setup({
programName: `admin-confirm-foreign-${uid()}`,
})
programIds.push(program.id)
userIds.push(lead.id, member.id)
const outsider = await createApplicant('MEMBER')
userIds.push(outsider.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await expect(
caller.adminConfirm({
confirmationId: confirmation.id,
attendingUserIds: [lead.id, outsider.id],
}),
).rejects.toThrow(/not a team member/i)
})
})
describe('finalist.adminDecline', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.attendingMember.deleteMany({
where: { confirmation: { project: { programId } } },
})
await prisma.waitlistEntry.deleteMany({ where: { programId } })
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('flips PENDING → DECLINED with reason + audit log', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, lead, member, confirmation } = await setup({
programName: `admin-decline-${uid()}`,
})
programIds.push(program.id)
userIds.push(lead.id, member.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.adminDecline({
confirmationId: confirmation.id,
reason: 'Team replied by email — schedule conflict',
})
const updated = await prisma.finalistConfirmation.findUniqueOrThrow({
where: { id: confirmation.id },
})
expect(updated.status).toBe('DECLINED')
expect(updated.declineReason).toContain('schedule conflict')
const audit = await prisma.auditLog.findFirst({
where: { action: 'FINALIST_ADMIN_DECLINE', entityId: confirmation.id },
})
expect(audit).not.toBeNull()
})
it('rejects when confirmation is not PENDING', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const { program, lead, member, confirmation } = await setup({
programName: `admin-decline-already-${uid()}`,
status: 'CONFIRMED',
})
programIds.push(program.id)
userIds.push(lead.id, member.id)
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await expect(
caller.adminDecline({ confirmationId: confirmation.id }),
).rejects.toThrow(/PENDING/i)
})
})