25 KiB
Award UX Fixes & Bulk Invite — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fix 5 issues: upload auto-retry for flaky connections, bulk invite for award jurors, jury group selector UX, decision mode setting on award edit page, and award juror role filter.
Architecture: These are 5 independent fixes touching different files. No schema changes needed — all are UI/UX and router-level changes. The bulk invite is the largest task, requiring a new reusable component, a new tRPC procedure, and integration into the award juror tab.
Tech Stack: Next.js 15 App Router, tRPC 11, Prisma 6, shadcn/ui, Tailwind CSS 4
File Map
New Files
| File | Purpose |
|---|---|
src/components/shared/bulk-invite-form.tsx |
Reusable multi-row name+email invite form component |
Modified Files
| File | Change |
|---|---|
src/components/shared/requirement-upload-slot.tsx |
Add XHR retry logic (3 attempts, exponential backoff) |
src/app/(admin)/admin/awards/[id]/page.tsx |
Integrate bulk invite form, widen role filter, add chair toggle |
src/app/(admin)/admin/rounds/[roundId]/page.tsx |
Move jury group selector above empty-state message |
src/app/(admin)/admin/awards/[id]/edit/page.tsx |
Add Decision Mode dropdown |
src/server/routers/specialAward.ts |
Add bulkInviteJurors procedure |
Task 1: Upload Auto-Retry
Files:
-
Modify:
src/components/shared/requirement-upload-slot.tsx:169-188 -
Step 1: Replace the single-attempt XHR upload with a retry wrapper
In src/components/shared/requirement-upload-slot.tsx, find the XHR upload block (lines 169-188). Replace it with a retry loop. The key change is wrapping the XHR Promise in a function and calling it up to 3 times with exponential backoff.
Find this code:
// Upload file with progress tracking
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => reject(new Error('Upload failed')))
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', file.type)
xhr.send(file)
})
Replace with:
// Upload file with progress tracking and auto-retry
const maxRetries = 3
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () =>
reject(new Error('Network error during upload'))
)
xhr.addEventListener('abort', () =>
reject(new Error('Upload was aborted'))
)
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', file.type)
xhr.send(file)
})
break // Success — exit retry loop
} catch (uploadErr) {
if (attempt < maxRetries) {
const delay = attempt * 2000 // 2s, 4s
toast.info(`Upload interrupted, retrying... (${attempt}/${maxRetries})`)
setProgress(0)
await new Promise((r) => setTimeout(r, delay))
} else {
throw uploadErr // Final attempt failed — propagate to outer catch
}
}
}
- Step 2: Verify it compiles
powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'requirement-upload'"
Expected: No errors for this file.
- Step 3: Commit
git add src/components/shared/requirement-upload-slot.tsx
git commit -m "feat: add auto-retry (3 attempts) for file uploads on flaky connections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 2: Bulk Invite Form Component
Files:
-
Create:
src/components/shared/bulk-invite-form.tsx -
Step 1: Create the reusable bulk invite component
Create src/components/shared/bulk-invite-form.tsx. This is a multi-row form where each row has Name + Email fields, with an "Add Row" button and a "Send Invites" button.
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
import { Plus, Trash2, Send, Loader2 } from 'lucide-react'
interface InviteRow {
name: string
email: string
}
interface BulkInviteFormProps {
onSubmit: (rows: InviteRow[]) => Promise<void>
isPending?: boolean
/** Label for the submit button */
submitLabel?: string
}
const emptyRow = (): InviteRow => ({ name: '', email: '' })
export function BulkInviteForm({
onSubmit,
isPending = false,
submitLabel = 'Send Invites',
}: BulkInviteFormProps) {
const [rows, setRows] = useState<InviteRow[]>([emptyRow()])
const updateRow = (index: number, field: keyof InviteRow, value: string) => {
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r)))
}
const addRow = () => setRows((prev) => [...prev, emptyRow()])
const removeRow = (index: number) => {
if (rows.length <= 1) return
setRows((prev) => prev.filter((_, i) => i !== index))
}
const validRows = rows.filter(
(r) => r.email.trim() && r.email.includes('@')
)
const handleSubmit = async () => {
if (validRows.length === 0) {
toast.error('Please enter at least one valid email address')
return
}
// Check for duplicate emails
const emails = validRows.map((r) => r.email.trim().toLowerCase())
const dupes = emails.filter((e, i) => emails.indexOf(e) !== i)
if (dupes.length > 0) {
toast.error(`Duplicate email: ${dupes[0]}`)
return
}
await onSubmit(validRows.map((r) => ({ name: r.name.trim(), email: r.email.trim() })))
setRows([emptyRow()])
}
return (
<div className="space-y-3">
<div className="space-y-2">
{rows.map((row, index) => (
<div key={index} className="flex items-end gap-2">
<div className="flex-1 space-y-1">
{index === 0 && (
<Label className="text-xs text-muted-foreground">Name</Label>
)}
<Input
placeholder="Full name"
value={row.name}
onChange={(e) => updateRow(index, 'name', e.target.value)}
disabled={isPending}
/>
</div>
<div className="flex-1 space-y-1">
{index === 0 && (
<Label className="text-xs text-muted-foreground">
Email <span className="text-destructive">*</span>
</Label>
)}
<Input
type="email"
placeholder="email@example.com"
value={row.email}
onChange={(e) => updateRow(index, 'email', e.target.value)}
disabled={isPending}
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => removeRow(index)}
disabled={rows.length <= 1 || isPending}
className="shrink-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={addRow}
disabled={isPending}
>
<Plus className="mr-1.5 h-4 w-4" />
Add Row
</Button>
<Button
onClick={handleSubmit}
disabled={validRows.length === 0 || isPending}
size="sm"
>
{isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Send className="mr-1.5 h-4 w-4" />
)}
{submitLabel} ({validRows.length})
</Button>
</div>
</div>
)
}
- Step 2: Commit
git add src/components/shared/bulk-invite-form.tsx
git commit -m "feat: add reusable BulkInviteForm component for multi-row name+email invites
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 3: Backend — Bulk Invite Jurors Procedure
Files:
- Modify:
src/server/routers/specialAward.ts— addbulkInviteJurorsprocedure
This procedure accepts an array of { name, email }, creates user accounts (or finds existing ones), assigns them the specified role, adds them as AwardJuror, and sends invite emails.
- Step 1: Add the
bulkInviteJurorsprocedure
In src/server/routers/specialAward.ts, add the following imports near the top (after existing imports):
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
import { sendJuryInvitationEmail } from '@/lib/email'
Check if these are already imported — generateInviteToken and getInviteExpiryMs are already imported (line 9). sendJuryInvitationEmail may not be — add it if missing.
Then add the procedure after the existing bulkAddJurors procedure (after ~line 576):
/**
* Bulk invite new users as award jurors — creates accounts, assigns role, sends invite emails
*/
bulkInviteJurors: adminProcedure
.input(
z.object({
awardId: z.string(),
role: z.enum(['JURY_MEMBER', 'AWARD_MASTER']).default('AWARD_MASTER'),
invitees: z.array(
z.object({
name: z.string().optional(),
email: z.string().email(),
})
).min(1).max(50),
})
)
.mutation(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
select: { id: true, name: true },
})
const results: Array<{ email: string; status: 'created' | 'existing' | 'error'; error?: string }> = []
for (const invitee of input.invitees) {
try {
// Check if user already exists
let user = await ctx.prisma.user.findUnique({
where: { email: invitee.email },
select: { id: true, status: true, role: true },
})
if (!user) {
// Create new user with invite token
const inviteToken = generateInviteToken()
const expiryMs = await getInviteExpiryMs(ctx.prisma)
user = await ctx.prisma.user.create({
data: {
email: invitee.email,
name: invitee.name || null,
role: input.role,
status: 'INVITED',
inviteToken,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
select: { id: true, status: true, role: true },
})
// Send invite email
const inviteUrl = `${process.env.NEXTAUTH_URL}/accept-invite?token=${inviteToken}`
try {
await sendJuryInvitationEmail(
invitee.email,
invitee.name || null,
inviteUrl,
award.name
)
} catch {
// Email failure shouldn't block the invite
}
results.push({ email: invitee.email, status: 'created' })
} else {
results.push({ email: invitee.email, status: 'existing' })
}
// Add as award juror (skip if already added)
await ctx.prisma.awardJuror.upsert({
where: {
awardId_userId: { awardId: input.awardId, userId: user.id },
},
update: {},
create: { awardId: input.awardId, userId: user.id },
})
} catch (err) {
results.push({
email: invitee.email,
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error',
})
}
}
await logAudit({
userId: ctx.user.id,
action: 'CREATE',
entityType: 'AwardJuror',
entityId: input.awardId,
detailsJson: {
action: 'BULK_INVITE',
awardName: award.name,
role: input.role,
count: input.invitees.length,
results,
},
})
return {
created: results.filter((r) => r.status === 'created').length,
existing: results.filter((r) => r.status === 'existing').length,
errors: results.filter((r) => r.status === 'error').length,
results,
}
}),
- Step 2: Check that
sendJuryInvitationEmailis importable
Verify the import exists in src/lib/email.ts:
grep -n "export.*sendJuryInvitationEmail" src/lib/email.ts
Expected: Shows the exported function. If it doesn't exist, check for the actual name and adjust the import.
- Step 3: Verify it compiles
powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'specialAward'"
- Step 4: Commit
git add src/server/routers/specialAward.ts
git commit -m "feat: add bulkInviteJurors procedure — creates accounts, assigns role, sends invites
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 4: Integrate Bulk Invite into Award Juror Tab + Widen Role Filter
Files:
- Modify:
src/app/(admin)/admin/awards/[id]/page.tsx
Three changes in this file:
- Widen the
allUsersquery role filter to includeAWARD_MASTER - Add a collapsible "Invite New Jurors" section with the
BulkInviteForm - Wire up the
bulkInviteJurorsmutation
- Step 1: Widen the role filter
At line 405-408, change:
const { data: allUsers } = trpc.user.list.useQuery(
{ role: 'JURY_MEMBER', page: 1, perPage: 100 },
{ enabled: activeTab === 'jurors' }
)
to:
const { data: allUsers } = trpc.user.list.useQuery(
{ page: 1, perPage: 200 },
{ enabled: activeTab === 'jurors' }
)
This removes the role filter entirely — AwardJuror is role-agnostic, so any user should be selectable. Increase perPage to 200 to cover more users.
- Step 2: Add the bulk invite mutation
Near the other mutations (after removeJuror at ~line 479), add:
const bulkInvite = trpc.specialAward.bulkInviteJurors.useMutation({
onSuccess: (data) => {
utils.specialAward.listJurors.invalidate({ awardId })
toast.success(`${data.created} invited, ${data.existing} already existed${data.errors > 0 ? `, ${data.errors} failed` : ''}`)
},
onError: (err) => toast.error(err.message),
})
- Step 3: Add the BulkInviteForm to the juror tab
Add the import at the top of the file:
import { BulkInviteForm } from '@/components/shared/bulk-invite-form'
In the jurors tab (after the existing "Add Juror" button section, around line 1329), add a separator and the bulk invite form:
<Separator className="my-4" />
<div className="space-y-2">
<h3 className="text-sm font-medium">Invite New Jurors by Email</h3>
<p className="text-xs text-muted-foreground">
Invite new users who don't have accounts yet. They'll receive an invitation email and be added as jurors for this award.
</p>
<BulkInviteForm
onSubmit={async (rows) => {
await bulkInvite.mutateAsync({
awardId,
role: 'AWARD_MASTER',
invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })),
})
}}
isPending={bulkInvite.isPending}
submitLabel="Invite & Add as Jurors"
/>
</div>
Make sure Separator is imported from @/components/ui/separator (check existing imports).
- Step 4: Verify it compiles
powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'awards/\[id\]'"
- Step 5: Commit
git add src/app/(admin)/admin/awards/[id]/page.tsx
git commit -m "feat: add bulk invite form to award juror tab, widen role filter to all users
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 5: Move Jury Group Selector to Top of Assignments Tab
Files:
-
Modify:
src/app/(admin)/admin/rounds/[roundId]/page.tsx:1787-1793, 1987-2060 -
Step 1: Replace the empty-state placeholder with the jury group selector
In src/app/(admin)/admin/rounds/[roundId]/page.tsx, find the "Select a jury group below" placeholder (lines 1787-1793):
{!round?.juryGroupId ? (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">Select a jury group below to get started.</p>
</CardContent>
</Card>
) : (
Replace the Card block inside the !round?.juryGroupId branch with the actual jury group selector (same UI that's at the bottom). Change it to:
{!round?.juryGroupId ? (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Jury Group</CardTitle>
<CardDescription>
Select or create a jury group to get started
</CardDescription>
</div>
<Button size="sm" variant="outline" onClick={() => setCreateJuryOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
New Jury
</Button>
</div>
</CardHeader>
<CardContent>
{juryGroups && juryGroups.length > 0 ? (
<Select
value="__none__"
onValueChange={(value) => {
if (value !== '__none__') {
assignJuryMutation.mutate({ id: roundId, juryGroupId: value })
}
}}
disabled={assignJuryMutation.isPending}
>
<SelectTrigger className="w-full sm:w-80">
<SelectValue placeholder="Select jury group..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No jury assigned</SelectItem>
{juryGroups.map((jg: any) => (
<SelectItem key={jg.id} value={jg.id}>
{jg.name} ({jg._count?.members ?? 0} members)
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">
No jury groups exist yet. Create one to get started.
</p>
)}
</CardContent>
</Card>
) : (
This replaces the unhelpful "Select a jury group below" message with the actual selector, right at the top where the user expects it.
- Step 2: Verify it compiles
powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'rounds/\[roundId\]'"
- Step 3: Commit
git add "src/app/(admin)/admin/rounds/[roundId]/page.tsx"
git commit -m "fix: show jury group selector at top of assignments tab when none assigned
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 6: Add Decision Mode Dropdown to Award Edit Page
Files:
-
Modify:
src/app/(admin)/admin/awards/[id]/edit/page.tsx -
Modify:
src/server/routers/specialAward.ts(update mutation input to acceptdecisionMode) -
Step 1: Verify the
updateprocedure acceptsdecisionMode
Check if the specialAward.update procedure already accepts decisionMode in its input schema. Search for decisionMode in the update input:
grep -A5 "decisionMode" src/server/routers/specialAward.ts | head -20
If it's NOT in the update input, add it. In the update procedure's .input() schema (around line 200-220), add:
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).nullable().optional(),
And in the mutation data (the ctx.prisma.specialAward.update call), include:
decisionMode: input.decisionMode,
- Step 2: Add state and initialization for decisionMode on the edit page
In src/app/(admin)/admin/awards/[id]/edit/page.tsx, near the other state declarations (around line 54), add:
const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'>('JURY_VOTE')
In the useEffect or initialization block where award data populates the form (around line 76), add:
setDecisionMode((award.decisionMode as typeof decisionMode) || 'JURY_VOTE')
In the submit handler (around line 95), include decisionMode in the mutation call.
- Step 3: Add the Decision Mode dropdown to the form
In the grid that contains the Scoring Mode dropdown (around line 199, the sm:grid-cols-2 div), add a new cell after the Scoring Mode </div>:
<div className="space-y-2">
<Label htmlFor="decisionMode">Decision Mode</Label>
<Select
value={decisionMode}
onValueChange={(v) => setDecisionMode(v as typeof decisionMode)}
>
<SelectTrigger id="decisionMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="JURY_VOTE">Jury Vote — tallied from all jurors</SelectItem>
<SelectItem value="AWARD_MASTER_DECISION">Award Master — sponsor picks winner</SelectItem>
<SelectItem value="ADMIN_DECISION">Admin Decision — admin selects winner</SelectItem>
</SelectContent>
</Select>
</div>
- Step 4: Verify it compiles
powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'edit/page'"
- Step 5: Commit
git add "src/app/(admin)/admin/awards/[id]/edit/page.tsx" src/server/routers/specialAward.ts
git commit -m "feat: add Decision Mode dropdown to award edit page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 7: Verification
- Step 1: Run typecheck
powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit"
- Step 2: Run tests
npx vitest run
- Step 3: Run build
npm run build
- Step 4: Manual smoke test checklist
- Upload retry: Find a large file upload, verify progress shows. Simulate by disconnecting network briefly during upload — should see "retrying" toast, then succeed.
- Bulk invite: Go to Awards → [award] → Jurors tab. See the "Invite New Jurors by Email" section. Add 2 rows with name+email, click "Invite & Add as Jurors". Verify users created and added as jurors.
- Jury group selector: Go to Rounds → any evaluation round with no jury assigned. The jury group dropdown should appear at the top, not buried at the bottom.
- Decision Mode: Go to Awards → [award] → Edit. See the Decision Mode dropdown. Select "Award Master" and save. Verify it persists on reload.
- Role filter: On the award juror tab, the "Select a juror" dropdown should show all users, not just JURY_MEMBER.
Key Design Decisions
| Decision | Rationale |
|---|---|
| Retry with exponential backoff (2s, 4s) | Gives time for transient network issues to resolve without overwhelming the server |
BulkInviteForm as shared component |
Reusable for jury group invites later, not award-specific |
bulkInviteJurors creates accounts + adds jurors in one call |
Avoids requiring admin to do two separate steps (create user, then add juror) |
upsert for AwardJuror in bulk invite |
Safe for re-inviting — won't error if user is already a juror |
| Remove role filter entirely from juror dropdown | AwardJuror is role-agnostic; any user should be selectable. The old JURY_MEMBER filter was too restrictive. |
| Inline jury group selector when empty | Users couldn't find it at the bottom; showing it where the empty state message is matches user expectation |