Fix rounds management bugs and invitation flow
Build and Push Docker Image / build (push) Successful in 8m45s
Details
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:
parent
0277768ed7
commit
03c031a8b6
|
|
@ -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[]) => {
|
||||||
|
|
|
||||||
|
|
@ -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 “{round.name}” and all
|
This will permanently delete “{round.name}” 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue