Fix rounds management bugs and invitation flow
Build and Push Docker Image / build (push) Successful in 8m45s Details

- Fix rounds list showing 0 projects by adding _count to program.list query
- Fix round reordering by using correct cache invalidation params
- Fix finalizeResults to auto-advance passed projects to next round
- Fix member list not updating after add/remove by invalidating user.list
- Fix invitation link error page by correcting path from /auth-error to /error
- Add /apply, /verify, /error to public paths in auth config

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-03 22:15:22 +01:00
parent 0277768ed7
commit 03c031a8b6
7 changed files with 159 additions and 73 deletions

View File

@ -117,7 +117,13 @@ export default function MemberInvitePage() {
skipped: number skipped: number
} | null>(null) } | null>(null)
const bulkCreate = trpc.user.bulkCreate.useMutation() const utils = trpc.useUtils()
const bulkCreate = trpc.user.bulkCreate.useMutation({
onSuccess: () => {
// Invalidate user list to refresh the members table when navigating back
utils.user.list.invalidate()
},
})
// --- Manual entry helpers --- // --- Manual entry helpers ---
const updateRow = (id: string, field: keyof MemberRow, value: string | string[]) => { const updateRow = (id: string, field: keyof MemberRow, value: string | string[]) => {

View File

@ -163,12 +163,20 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const handleFinalizeFiltering = async () => { const handleFinalizeFiltering = async () => {
try { try {
const result = await finalizeResults.mutateAsync({ roundId }) const result = await finalizeResults.mutateAsync({ roundId })
if (result.advancedToRoundName) {
toast.success( toast.success(
`Finalized: ${result.passed} passed, ${result.filteredOut} filtered out` `Finalized: ${result.passed} projects advanced to "${result.advancedToRoundName}", ${result.filteredOut} filtered out`
) )
} else {
toast.success(
`Finalized: ${result.passed} passed, ${result.filteredOut} filtered out. No next round to advance to.`
)
}
refetchFilteringStats() refetchFilteringStats()
refetchRound() refetchRound()
utils.project.list.invalidate() utils.project.list.invalidate()
utils.program.list.invalidate({ includeRounds: true })
utils.round.get.invalidate({ id: roundId })
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : 'Failed to finalize' error instanceof Error ? error.message : 'Failed to finalize'
@ -292,7 +300,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Close Round Close Round
</Button> </Button>
)} )}
{round.status === 'DRAFT' && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive"> <Button variant="destructive">
@ -302,11 +309,30 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete Round</AlertDialogTitle> <AlertDialogTitle className="flex items-center gap-2">
<AlertDialogDescription> {round.status === 'ACTIVE' && (
<AlertTriangle className="h-5 w-5 text-destructive" />
)}
Delete Round
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
{round.status === 'ACTIVE' && (
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm font-medium">
Warning: This round is currently ACTIVE. Deleting it will immediately end all ongoing evaluations.
</div>
)}
<p>
This will permanently delete &ldquo;{round.name}&rdquo; and all This will permanently delete &ldquo;{round.name}&rdquo; and all
associated projects, assignments, and evaluations. This action associated data:
cannot be undone. </p>
<ul className="list-disc list-inside text-sm space-y-1">
<li>{progress?.totalProjects || 0} projects in this round</li>
<li>{progress?.totalAssignments || 0} jury assignments</li>
<li>{progress?.completedAssignments || 0} submitted evaluations</li>
</ul>
<p className="font-medium">This action cannot be undone.</p>
</div>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@ -328,7 +354,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
)}
</div> </div>
</div> </div>

View File

@ -161,17 +161,19 @@ function RoundRow({
const reorder = trpc.round.reorder.useMutation({ const reorder = trpc.round.reorder.useMutation({
onSuccess: () => { onSuccess: () => {
utils.program.list.invalidate() utils.program.list.invalidate({ includeRounds: true })
}, },
}) })
const moveUp = () => { const moveUp = () => {
if (index <= 0) return
const ids = [...allRoundIds] const ids = [...allRoundIds]
;[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]] ;[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]]
reorder.mutate({ programId, roundIds: ids }) reorder.mutate({ programId, roundIds: ids })
} }
const moveDown = () => { const moveDown = () => {
if (index >= totalRounds - 1) return
const ids = [...allRoundIds] const ids = [...allRoundIds]
;[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]] ;[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]]
reorder.mutate({ programId, roundIds: ids }) reorder.mutate({ programId, roundIds: ids })
@ -179,14 +181,14 @@ function RoundRow({
const updateStatus = trpc.round.updateStatus.useMutation({ const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => { onSuccess: () => {
utils.program.list.invalidate() utils.program.list.invalidate({ includeRounds: true })
}, },
}) })
const deleteRound = trpc.round.delete.useMutation({ const deleteRound = trpc.round.delete.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success('Round deleted successfully') toast.success('Round deleted successfully')
utils.program.list.invalidate() utils.program.list.invalidate({ includeRounds: true })
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message || 'Failed to delete round') toast.error(error.message || 'Failed to delete round')

View File

@ -2,7 +2,6 @@
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@ -38,12 +37,17 @@ interface UserActionsProps {
} }
export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) { export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) {
const router = useRouter()
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation() const sendInvitation = trpc.user.sendInvitation.useMutation()
const deleteUser = trpc.user.delete.useMutation() const deleteUser = trpc.user.delete.useMutation({
onSuccess: () => {
// Invalidate user list to refresh the members table
utils.user.list.invalidate()
},
})
const handleSendInvitation = async () => { const handleSendInvitation = async () => {
if (userStatus !== 'INVITED') { if (userStatus !== 'INVITED') {
@ -55,6 +59,8 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps)
try { try {
await sendInvitation.mutateAsync({ userId }) await sendInvitation.mutateAsync({ userId })
toast.success(`Invitation sent to ${userEmail}`) toast.success(`Invitation sent to ${userEmail}`)
// Invalidate in case status changed
utils.user.list.invalidate()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation') toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} finally { } finally {
@ -67,7 +73,6 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps)
await deleteUser.mutateAsync({ id: userId }) await deleteUser.mutateAsync({ id: userId })
toast.success('User deleted successfully') toast.success('User deleted successfully')
setShowDeleteDialog(false) setShowDeleteDialog(false)
router.refresh()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete user') toast.error(error instanceof Error ? error.message : 'Failed to delete user')
} }

View File

@ -39,9 +39,11 @@ export const authConfig: NextAuthConfig = {
// Public paths that don't require authentication // Public paths that don't require authentication
const publicPaths = [ const publicPaths = [
'/login', '/login',
'/verify',
'/verify-email', '/verify-email',
'/auth-error', '/error',
'/accept-invite', '/accept-invite',
'/apply',
'/api/auth', '/api/auth',
] ]
@ -78,7 +80,7 @@ export const authConfig: NextAuthConfig = {
pages: { pages: {
signIn: '/login', signIn: '/login',
verifyRequest: '/verify-email', verifyRequest: '/verify-email',
error: '/auth-error', error: '/error',
newUser: '/set-password', newUser: '/set-password',
}, },
session: { session: {

View File

@ -714,11 +714,28 @@ export const filteringRouter = router({
/** /**
* Finalize filtering results apply outcomes to project statuses * Finalize filtering results apply outcomes to project statuses
* PASSED keep in pool, FILTERED_OUT set aside (NOT deleted) * PASSED mark as ELIGIBLE and advance to next round
* FILTERED_OUT mark as REJECTED (data preserved)
*/ */
finalizeResults: adminProcedure finalizeResults: adminProcedure
.input(z.object({ roundId: z.string() })) .input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Get current round to find the next one
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, programId: true, sortOrder: true, name: true },
})
// Find the next round by sortOrder
const nextRound = await ctx.prisma.round.findFirst({
where: {
programId: currentRound.programId,
sortOrder: { gt: currentRound.sortOrder },
},
orderBy: { sortOrder: 'asc' },
select: { id: true, name: true },
})
const results = await ctx.prisma.filteringResult.findMany({ const results = await ctx.prisma.filteringResult.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
}) })
@ -732,27 +749,45 @@ export const filteringRouter = router({
.filter((r) => (r.finalOutcome || r.outcome) === 'PASSED') .filter((r) => (r.finalOutcome || r.outcome) === 'PASSED')
.map((r) => r.projectId) .map((r) => r.projectId)
// Update RoundProject statuses // Build transaction operations
await ctx.prisma.$transaction([ const operations: Prisma.PrismaPromise<unknown>[] = []
// Filtered out projects get REJECTED status (data preserved) // Filtered out projects get REJECTED status (data preserved)
...(filteredOutIds.length > 0 if (filteredOutIds.length > 0) {
? [ operations.push(
ctx.prisma.roundProject.updateMany({ ctx.prisma.roundProject.updateMany({
where: { roundId: input.roundId, projectId: { in: filteredOutIds } }, where: { roundId: input.roundId, projectId: { in: filteredOutIds } },
data: { status: 'REJECTED' }, data: { status: 'REJECTED' },
}), })
] )
: []), }
// Passed projects get ELIGIBLE status // Passed projects get ELIGIBLE status
...(passedIds.length > 0 if (passedIds.length > 0) {
? [ operations.push(
ctx.prisma.roundProject.updateMany({ ctx.prisma.roundProject.updateMany({
where: { roundId: input.roundId, projectId: { in: passedIds } }, where: { roundId: input.roundId, projectId: { in: passedIds } },
data: { status: 'ELIGIBLE' }, data: { status: 'ELIGIBLE' },
}), })
] )
: []),
]) // If there's a next round, advance passed projects to it
if (nextRound) {
operations.push(
ctx.prisma.roundProject.createMany({
data: passedIds.map((projectId) => ({
roundId: nextRound.id,
projectId,
status: 'SUBMITTED' as const,
})),
skipDuplicates: true,
})
)
}
}
// Execute all operations in a transaction
await ctx.prisma.$transaction(operations)
await logAudit({ await logAudit({
userId: ctx.user.id, userId: ctx.user.id,
@ -763,10 +798,16 @@ export const filteringRouter = router({
action: 'FINALIZE_FILTERING', action: 'FINALIZE_FILTERING',
passed: passedIds.length, passed: passedIds.length,
filteredOut: filteredOutIds.length, filteredOut: filteredOutIds.length,
advancedToRound: nextRound?.name || null,
}, },
}) })
return { passed: passedIds.length, filteredOut: filteredOutIds.length } return {
passed: passedIds.length,
filteredOut: filteredOutIds.length,
advancedToRoundId: nextRound?.id || null,
advancedToRoundName: nextRound?.name || null,
}
}), }),
/** /**

View File

@ -21,7 +21,12 @@ export const programRouter = router({
select: { rounds: true }, select: { rounds: true },
}, },
rounds: input?.includeRounds ? { rounds: input?.includeRounds ? {
orderBy: { createdAt: 'asc' }, orderBy: { sortOrder: 'asc' },
include: {
_count: {
select: { roundProjects: true, assignments: true },
},
},
} : false, } : false,
}, },
}) })