Fix stale session redirect loop, filtering stats to reflect overrides
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m56s

- Auth layout verifies user exists in DB before redirecting to dashboard,
  breaking infinite loop for deleted accounts with stale sessions
- Jury/Mentor layouts handle null user (deleted) by redirecting to login
- Filtering stats cards and result list now use effective outcome
  (finalOutcome ?? outcome) instead of raw AI outcome
- Award eligibility evaluation includes admin-overridden PASSED projects
- Award shortlist reasoning column shows full text instead of truncating

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-18 10:01:31 +01:00
parent 1ec2247295
commit 6e9fcda45a
6 changed files with 72 additions and 44 deletions

View File

@@ -1,6 +1,7 @@
import { redirect } from 'next/navigation'
import Image from 'next/image'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export default async function AuthLayout({
children,
@@ -18,16 +19,25 @@ export default async function AuthLayout({
// Redirect logged-in users to their dashboard
// But NOT if they still need to set their password
if (session?.user && !session.user.mustSetPassword) {
const role = session.user.role
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
redirect('/admin')
} else if (role === 'JURY_MEMBER') {
redirect('/jury')
} else if (role === 'OBSERVER') {
redirect('/observer')
} else if (role === 'MENTOR') {
redirect('/mentor')
// Verify user still exists in DB (handles deleted accounts with stale sessions)
const dbUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true },
})
if (dbUser) {
const role = session.user.role
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
redirect('/admin')
} else if (role === 'JURY_MEMBER') {
redirect('/jury')
} else if (role === 'OBSERVER') {
redirect('/observer')
} else if (role === 'MENTOR') {
redirect('/mentor')
}
}
// If user doesn't exist in DB, fall through and show auth page
}
return (

View File

@@ -18,7 +18,12 @@ export default async function JuryLayout({
select: { onboardingCompletedAt: true },
})
if (!user?.onboardingCompletedAt) {
if (!user) {
// User was deleted — session is stale, send to login
redirect('/login')
}
if (!user.onboardingCompletedAt) {
redirect('/onboarding')
}

View File

@@ -19,7 +19,12 @@ export default async function MentorLayout({
select: { onboardingCompletedAt: true },
})
if (!user?.onboardingCompletedAt) {
if (!user) {
// User was deleted — session is stale, send to login
redirect('/login')
}
if (!user.onboardingCompletedAt) {
redirect('/onboarding')
}
}

View File

@@ -32,7 +32,6 @@ import {
Play,
Trophy,
AlertTriangle,
ChevronsUpDown,
} from 'lucide-react'
type AwardShortlistProps = {
@@ -59,7 +58,6 @@ export function AwardShortlist({
jobDone,
}: AwardShortlistProps) {
const [expanded, setExpanded] = useState(false)
const [expandedReasoning, setExpandedReasoning] = useState<Set<string>>(new Set())
const utils = trpc.useUtils()
const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING'
@@ -130,17 +128,6 @@ export function AwardShortlist({
const allShortlisted = shortlist && shortlist.eligibilities.length > 0 && shortlist.eligibilities.every((e) => e.shortlisted)
const someShortlisted = shortlistedCount > 0 && !allShortlisted
const toggleReasoning = (id: string) => {
setExpandedReasoning((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const handleBulkToggle = () => {
if (!shortlist) return
@@ -286,7 +273,7 @@ export function AwardShortlist({
<th className="px-3 py-2 text-left w-8">#</th>
<th className="px-3 py-2 text-left">Project</th>
<th className="px-3 py-2 text-left w-24">Score</th>
<th className="px-3 py-2 text-left w-44">Reasoning</th>
<th className="px-3 py-2 text-left min-w-[300px]">Reasoning</th>
<th className="px-3 py-2 text-center w-20">
<div className="flex items-center justify-center gap-1">
<Checkbox
@@ -304,7 +291,6 @@ export function AwardShortlist({
{shortlist.eligibilities.map((e, i) => {
const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined
const isTop5 = i < shortlistSize
const isReasoningExpanded = expandedReasoning.has(e.id)
return (
<tr key={e.id} className={`border-t ${isTop5 ? 'bg-amber-50/50' : ''}`}>
<td className="px-3 py-2 text-muted-foreground font-mono">
@@ -337,18 +323,9 @@ export function AwardShortlist({
</td>
<td className="px-3 py-2">
{reasoning ? (
<button
onClick={() => toggleReasoning(e.id)}
className="text-left w-full group"
>
<p className={`text-xs text-muted-foreground ${isReasoningExpanded ? '' : 'line-clamp-2'}`}>
{reasoning}
</p>
<span className="text-xs text-blue-600 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-0.5 mt-0.5">
<ChevronsUpDown className="h-3 w-3" />
{isReasoningExpanded ? 'Collapse' : 'Expand'}
</span>
</button>
<p className="text-xs text-muted-foreground whitespace-pre-wrap leading-relaxed">
{reasoning}
</p>
) : (
<span className="text-xs text-muted-foreground"></span>
)}

View File

@@ -855,7 +855,13 @@ export const filteringRouter = router({
const skip = (page - 1) * perPage
const where: Record<string, unknown> = { roundId }
if (outcome) where.outcome = outcome
if (outcome) {
// Filter by effective outcome (finalOutcome if overridden, otherwise original outcome)
where.OR = [
{ finalOutcome: outcome },
{ finalOutcome: null, outcome },
]
}
const [results, total] = await Promise.all([
ctx.prisma.filteringResult.findMany({
@@ -896,15 +902,34 @@ export const filteringRouter = router({
getResultStats: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Use effective outcome (finalOutcome if overridden, otherwise original outcome)
const [passed, filteredOut, flagged, overridden] = await Promise.all([
ctx.prisma.filteringResult.count({
where: { roundId: input.roundId, outcome: 'PASSED' },
where: {
roundId: input.roundId,
OR: [
{ finalOutcome: 'PASSED' },
{ finalOutcome: null, outcome: 'PASSED' },
],
},
}),
ctx.prisma.filteringResult.count({
where: { roundId: input.roundId, outcome: 'FILTERED_OUT' },
where: {
roundId: input.roundId,
OR: [
{ finalOutcome: 'FILTERED_OUT' },
{ finalOutcome: null, outcome: 'FILTERED_OUT' },
],
},
}),
ctx.prisma.filteringResult.count({
where: { roundId: input.roundId, outcome: 'FLAGGED' },
where: {
roundId: input.roundId,
OR: [
{ finalOutcome: 'FLAGGED' },
{ finalOutcome: null, outcome: 'FLAGGED' },
],
},
}),
ctx.prisma.filteringResult.count({
where: { roundId: input.roundId, overriddenBy: { not: null } },

View File

@@ -59,9 +59,15 @@ export async function processEligibilityJob(
}>
if (filteringRoundId) {
// Scope to projects that PASSED filtering in the specified round
// Scope to projects that effectively PASSED filtering (including admin overrides)
const passedResults = await prisma.filteringResult.findMany({
where: { roundId: filteringRoundId, outcome: 'PASSED' },
where: {
roundId: filteringRoundId,
OR: [
{ finalOutcome: 'PASSED' },
{ finalOutcome: null, outcome: 'PASSED' },
],
},
select: { projectId: true },
})
const passedIds = passedResults.map((r) => r.projectId)