From 03c031a8b6f80abf1682744b05c6fada1dd02251 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 3 Feb 2026 22:15:22 +0100 Subject: [PATCH] Fix rounds management bugs and invitation flow - 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 --- src/app/(admin)/admin/members/invite/page.tsx | 8 +- src/app/(admin)/admin/rounds/[id]/page.tsx | 103 +++++++++++------- src/app/(admin)/admin/rounds/page.tsx | 8 +- src/components/admin/user-actions.tsx | 13 ++- src/lib/auth.config.ts | 6 +- src/server/routers/filtering.ts | 87 +++++++++++---- src/server/routers/program.ts | 7 +- 7 files changed, 159 insertions(+), 73 deletions(-) diff --git a/src/app/(admin)/admin/members/invite/page.tsx b/src/app/(admin)/admin/members/invite/page.tsx index f63317c..ee9485a 100644 --- a/src/app/(admin)/admin/members/invite/page.tsx +++ b/src/app/(admin)/admin/members/invite/page.tsx @@ -117,7 +117,13 @@ export default function MemberInvitePage() { skipped: number } | 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 --- const updateRow = (id: string, field: keyof MemberRow, value: string | string[]) => { diff --git a/src/app/(admin)/admin/rounds/[id]/page.tsx b/src/app/(admin)/admin/rounds/[id]/page.tsx index 8568a43..6adcc25 100644 --- a/src/app/(admin)/admin/rounds/[id]/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/page.tsx @@ -163,12 +163,20 @@ function RoundDetailContent({ roundId }: { roundId: string }) { const handleFinalizeFiltering = async () => { try { const result = await finalizeResults.mutateAsync({ roundId }) - toast.success( - `Finalized: ${result.passed} passed, ${result.filteredOut} filtered out` - ) + if (result.advancedToRoundName) { + toast.success( + `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() refetchRound() utils.project.list.invalidate() + utils.program.list.invalidate({ includeRounds: true }) + utils.round.get.invalidate({ id: roundId }) } catch (error) { toast.error( error instanceof Error ? error.message : 'Failed to finalize' @@ -292,43 +300,60 @@ function RoundDetailContent({ roundId }: { roundId: string }) { Close Round )} - {round.status === 'DRAFT' && ( - - - - - - - Delete Round - - This will permanently delete “{round.name}” and all - associated projects, assignments, and evaluations. This action - cannot be undone. - - - - Cancel - deleteRound.mutate({ id: round.id })} - disabled={deleteRound.isPending} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {deleteRound.isPending ? ( - <> - - Deleting... - - ) : ( - 'Delete Round' + + + + + + + + {round.status === 'ACTIVE' && ( + + )} + Delete Round + + +
+ {round.status === 'ACTIVE' && ( +
+ Warning: This round is currently ACTIVE. Deleting it will immediately end all ongoing evaluations. +
)} - - - - - )} +

+ This will permanently delete “{round.name}” and all + associated data: +

+
    +
  • {progress?.totalProjects || 0} projects in this round
  • +
  • {progress?.totalAssignments || 0} jury assignments
  • +
  • {progress?.completedAssignments || 0} submitted evaluations
  • +
+

This action cannot be undone.

+
+
+
+ + Cancel + deleteRound.mutate({ id: round.id })} + disabled={deleteRound.isPending} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleteRound.isPending ? ( + <> + + Deleting... + + ) : ( + 'Delete Round' + )} + + +
+
diff --git a/src/app/(admin)/admin/rounds/page.tsx b/src/app/(admin)/admin/rounds/page.tsx index ad7f42e..ac9aa2d 100644 --- a/src/app/(admin)/admin/rounds/page.tsx +++ b/src/app/(admin)/admin/rounds/page.tsx @@ -161,17 +161,19 @@ function RoundRow({ const reorder = trpc.round.reorder.useMutation({ onSuccess: () => { - utils.program.list.invalidate() + utils.program.list.invalidate({ includeRounds: true }) }, }) const moveUp = () => { + if (index <= 0) return const ids = [...allRoundIds] ;[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]] reorder.mutate({ programId, roundIds: ids }) } const moveDown = () => { + if (index >= totalRounds - 1) return const ids = [...allRoundIds] ;[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]] reorder.mutate({ programId, roundIds: ids }) @@ -179,14 +181,14 @@ function RoundRow({ const updateStatus = trpc.round.updateStatus.useMutation({ onSuccess: () => { - utils.program.list.invalidate() + utils.program.list.invalidate({ includeRounds: true }) }, }) const deleteRound = trpc.round.delete.useMutation({ onSuccess: () => { toast.success('Round deleted successfully') - utils.program.list.invalidate() + utils.program.list.invalidate({ includeRounds: true }) }, onError: (error) => { toast.error(error.message || 'Failed to delete round') diff --git a/src/components/admin/user-actions.tsx b/src/components/admin/user-actions.tsx index bbeaa09..658d2cb 100644 --- a/src/components/admin/user-actions.tsx +++ b/src/components/admin/user-actions.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import Link from 'next/link' -import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { @@ -38,12 +37,17 @@ interface UserActionsProps { } export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) { - const router = useRouter() const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isSending, setIsSending] = useState(false) + const utils = trpc.useUtils() 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 () => { if (userStatus !== 'INVITED') { @@ -55,6 +59,8 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) try { await sendInvitation.mutateAsync({ userId }) toast.success(`Invitation sent to ${userEmail}`) + // Invalidate in case status changed + utils.user.list.invalidate() } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to send invitation') } finally { @@ -67,7 +73,6 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) await deleteUser.mutateAsync({ id: userId }) toast.success('User deleted successfully') setShowDeleteDialog(false) - router.refresh() } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to delete user') } diff --git a/src/lib/auth.config.ts b/src/lib/auth.config.ts index 407e86d..b023041 100644 --- a/src/lib/auth.config.ts +++ b/src/lib/auth.config.ts @@ -39,9 +39,11 @@ export const authConfig: NextAuthConfig = { // Public paths that don't require authentication const publicPaths = [ '/login', + '/verify', '/verify-email', - '/auth-error', + '/error', '/accept-invite', + '/apply', '/api/auth', ] @@ -78,7 +80,7 @@ export const authConfig: NextAuthConfig = { pages: { signIn: '/login', verifyRequest: '/verify-email', - error: '/auth-error', + error: '/error', newUser: '/set-password', }, session: { diff --git a/src/server/routers/filtering.ts b/src/server/routers/filtering.ts index 9166876..31f871f 100644 --- a/src/server/routers/filtering.ts +++ b/src/server/routers/filtering.ts @@ -714,11 +714,28 @@ export const filteringRouter = router({ /** * 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 .input(z.object({ roundId: z.string() })) .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({ where: { roundId: input.roundId }, }) @@ -732,27 +749,45 @@ export const filteringRouter = router({ .filter((r) => (r.finalOutcome || r.outcome) === 'PASSED') .map((r) => r.projectId) - // Update RoundProject statuses - await ctx.prisma.$transaction([ - // Filtered out projects get REJECTED status (data preserved) - ...(filteredOutIds.length > 0 - ? [ - ctx.prisma.roundProject.updateMany({ - where: { roundId: input.roundId, projectId: { in: filteredOutIds } }, - data: { status: 'REJECTED' }, - }), - ] - : []), - // Passed projects get ELIGIBLE status - ...(passedIds.length > 0 - ? [ - ctx.prisma.roundProject.updateMany({ - where: { roundId: input.roundId, projectId: { in: passedIds } }, - data: { status: 'ELIGIBLE' }, - }), - ] - : []), - ]) + // Build transaction operations + const operations: Prisma.PrismaPromise[] = [] + + // Filtered out projects get REJECTED status (data preserved) + if (filteredOutIds.length > 0) { + operations.push( + ctx.prisma.roundProject.updateMany({ + where: { roundId: input.roundId, projectId: { in: filteredOutIds } }, + data: { status: 'REJECTED' }, + }) + ) + } + + // Passed projects get ELIGIBLE status + if (passedIds.length > 0) { + operations.push( + ctx.prisma.roundProject.updateMany({ + where: { roundId: input.roundId, projectId: { in: passedIds } }, + 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({ userId: ctx.user.id, @@ -763,10 +798,16 @@ export const filteringRouter = router({ action: 'FINALIZE_FILTERING', passed: passedIds.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, + } }), /** diff --git a/src/server/routers/program.ts b/src/server/routers/program.ts index fa4b1ec..c66fbc5 100644 --- a/src/server/routers/program.ts +++ b/src/server/routers/program.ts @@ -21,7 +21,12 @@ export const programRouter = router({ select: { rounds: true }, }, rounds: input?.includeRounds ? { - orderBy: { createdAt: 'asc' }, + orderBy: { sortOrder: 'asc' }, + include: { + _count: { + select: { roundProjects: true, assignments: true }, + }, + }, } : false, }, })