Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s

Phase 1 — Critical bugs:
- Fix deliberation participant selection (wire jury group query)
- Fix reports "By Round" tab (inline content instead of 404 route)
- Fix messages "Sent History" (add message.sent procedure, wire tab)
- Add missing fields to competition award form (criteriaText, maxRankedPicks)
- Wire LiveControlPanel buttons (cursor, voting, scores)
- Fix ResultLockControls empty snapshot (fetch actual data before lock)
- Fix SubmissionWindowManager losing fields on edit

Phase 2 — Backend fixes:
- Remove write-in-query from specialAward.get
- Fix award eligibility job overwriting manual shortlist overrides
- Fix filtering startJob deleting all prior results (defer cleanup to post-success)
- Tighten access control: protectedProcedure → adminProcedure on 8 procedures
- Add audit logging to deliberation mutations
- Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete

Phase 3 — Auto-refresh:
- Add refetchInterval to 15+ admin pages/components (10s–30s)
- Fix AI job polling: derive speed from job status for all viewers

Phase 4 — Dead code cleanup:
- Delete unused command-palette, pdf-report, admin-page-transition
- Remove dead subItems sidebar code, unused GripVertical import
- Replace redundant isGenerating state with mutation.isPending
- Add Role column to jury members table
- Remove misleading manual mentor assignment stub

Phase 5 — UX improvements:
- Fix rounds page single-competition assumption (add selector)
- Remove raw UUID fallback in deliberation config
- Fix programs page "Stage" → "Round" terminology

Phase 6 — Backend hardening:
- Complete logAudit calls (add prisma, ipAddress, userAgent)
- Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear)
- Batch user.bulkCreate writes (assignments, jury memberships, intents)
- Remove any casts from deliberation service (typed PrismaClient + TransactionClient)
- Fix stale DeliberationStatus enum values blocking build

40 files changed, 1010 insertions(+), 612 deletions(-)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 08:20:13 +01:00
parent aa1bf564ee
commit 1308c3ba87
40 changed files with 1011 additions and 613 deletions

View File

@@ -151,7 +151,7 @@ export default function AuditLogPage() {
)
// Fetch audit logs
const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput)
const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput, { refetchInterval: 30_000 })
// Fetch users for filter dropdown
const { data: usersData } = trpc.user.list.useQuery({

View File

@@ -162,7 +162,7 @@ export default function AwardDetailPage({
// Core queries — lazy-load tab-specific data based on activeTab
const { data: award, isLoading, refetch } =
trpc.specialAward.get.useQuery({ id: awardId })
trpc.specialAward.get.useQuery({ id: awardId }, { refetchInterval: 30_000 })
const { data: eligibilityData, refetch: refetchEligibility } =
trpc.specialAward.listEligible.useQuery({
awardId,

View File

@@ -40,7 +40,10 @@ const SCORING_LABELS: Record<string, string> = {
}
export default function AwardsListPage() {
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({})
const { data: awards, isLoading } = trpc.specialAward.list.useQuery(
{},
{ refetchInterval: 30_000 }
)
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300)

View File

@@ -22,8 +22,10 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
const [formData, setFormData] = useState({
name: '',
description: '',
criteriaText: '',
useAiEligibility: false,
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED'
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED',
maxRankedPicks: '3',
});
const { data: competition } = trpc.competition.getById.useQuery({
@@ -63,8 +65,10 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
competitionId: params.competitionId,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
criteriaText: formData.criteriaText.trim() || undefined,
scoringMode: formData.scoringMode,
useAiEligibility: formData.useAiEligibility
useAiEligibility: formData.useAiEligibility,
maxRankedPicks: formData.scoringMode === 'RANKED' ? parseInt(formData.maxRankedPicks) : undefined,
});
};
@@ -114,22 +118,17 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
</div>
<div className="space-y-2">
<Label htmlFor="scoringMode">Scoring Mode</Label>
<Select
value={formData.scoringMode}
onValueChange={(value) =>
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' })
}
>
<SelectTrigger id="scoringMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
<SelectItem value="RANKED">Ranked</SelectItem>
<SelectItem value="SCORED">Scored</SelectItem>
</SelectContent>
</Select>
<Label htmlFor="criteriaText">Eligibility Criteria</Label>
<Textarea
id="criteriaText"
value={formData.criteriaText}
onChange={(e) => setFormData({ ...formData, criteriaText: e.target.value })}
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
rows={4}
/>
<p className="text-xs text-muted-foreground">
This text will be used by AI to determine which projects are eligible for this award.
</p>
</div>
<div className="flex items-center space-x-2">
@@ -145,6 +144,41 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="scoringMode">Scoring Mode</Label>
<Select
value={formData.scoringMode}
onValueChange={(value) =>
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' })
}
>
<SelectTrigger id="scoringMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner Each juror picks 1</SelectItem>
<SelectItem value="RANKED">Ranked Each juror ranks top N</SelectItem>
<SelectItem value="SCORED">Scored Use evaluation form</SelectItem>
</SelectContent>
</Select>
</div>
{formData.scoringMode === 'RANKED' && (
<div className="space-y-2">
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
<Input
id="maxPicks"
type="number"
min="1"
max="20"
value={formData.maxRankedPicks}
onChange={(e) => setFormData({ ...formData, maxRankedPicks: e.target.value })}
/>
</div>
)}
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button
type="button"

View File

@@ -21,9 +21,10 @@ export default function DeliberationSessionPage({
const router = useRouter();
const utils = trpc.useUtils();
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery({
sessionId: params.sessionId
});
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
{ sessionId: params.sessionId },
{ refetchInterval: 10_000 }
);
const openVotingMutation = trpc.deliberation.openVoting.useMutation({
onSuccess: () => {
@@ -183,7 +184,7 @@ export default function DeliberationSessionPage({
variant="destructive"
onClick={() => closeVotingMutation.mutate({ sessionId: params.sessionId })}
disabled={
closeVotingMutation.isPending || session.status !== 'DELIB_VOTING'
closeVotingMutation.isPending || session.status !== 'VOTING'
}
className="flex-1"
>

View File

@@ -32,6 +32,7 @@ export default function DeliberationListPage({
const router = useRouter();
const utils = trpc.useUtils();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [selectedJuryGroupId, setSelectedJuryGroupId] = useState('');
const [formData, setFormData] = useState({
roundId: '',
category: 'STARTUP' as 'STARTUP' | 'BUSINESS_CONCEPT',
@@ -54,8 +55,17 @@ export default function DeliberationListPage({
);
const rounds = competition?.rounds || [];
// TODO: Add getJuryMembers endpoint if needed for participant selection
const juryMembers: any[] = [];
// Jury groups & members for participant selection
const { data: juryGroups = [] } = trpc.juryGroup.list.useQuery(
{ competitionId: params.competitionId },
{ enabled: !!params.competitionId }
);
const { data: selectedJuryGroup } = trpc.juryGroup.getById.useQuery(
{ id: selectedJuryGroupId },
{ enabled: !!selectedJuryGroupId }
);
const juryMembers = selectedJuryGroup?.members ?? [];
const createSessionMutation = trpc.deliberation.createSession.useMutation({
onSuccess: (data) => {
@@ -76,6 +86,10 @@ export default function DeliberationListPage({
toast.error('Please select a round');
return;
}
if (formData.participantUserIds.length === 0) {
toast.error('Please select at least one participant');
return;
}
createSessionMutation.mutate({
competitionId: params.competitionId,
@@ -273,6 +287,78 @@ export default function DeliberationListPage({
</Select>
</div>
{/* Participant Selection */}
<div className="space-y-2">
<Label htmlFor="juryGroup">Jury Group *</Label>
<Select
value={selectedJuryGroupId}
onValueChange={(value) => {
setSelectedJuryGroupId(value);
setFormData({ ...formData, participantUserIds: [] });
}}
>
<SelectTrigger id="juryGroup">
<SelectValue placeholder="Select jury group" />
</SelectTrigger>
<SelectContent>
{juryGroups.map((group: any) => (
<SelectItem key={group.id} value={group.id}>
{group.name} ({group._count?.members ?? 0} members)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{juryMembers.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Participants ({formData.participantUserIds.length}/{juryMembers.length})</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const allIds = juryMembers.map((m: any) => m.user.id);
const allSelected = allIds.every((id: string) => formData.participantUserIds.includes(id));
setFormData({
...formData,
participantUserIds: allSelected ? [] : allIds,
});
}}
>
{juryMembers.every((m: any) => formData.participantUserIds.includes(m.user.id))
? 'Deselect All'
: 'Select All'}
</Button>
</div>
<div className="max-h-48 space-y-2 overflow-y-auto rounded-md border p-3">
{juryMembers.map((member: any) => (
<div key={member.id} className="flex items-center space-x-2">
<Checkbox
id={`member-${member.user.id}`}
checked={formData.participantUserIds.includes(member.user.id)}
onCheckedChange={(checked) => {
setFormData({
...formData,
participantUserIds: checked
? [...formData.participantUserIds, member.user.id]
: formData.participantUserIds.filter((id: string) => id !== member.user.id),
});
}}
/>
<Label htmlFor={`member-${member.user.id}`} className="flex-1 font-normal">
{member.user.name || member.user.email}
<span className="ml-2 text-xs text-muted-foreground">
{member.role === 'CHAIR' ? 'Chair' : member.role === 'OBSERVER' ? 'Observer' : 'Member'}
</span>
</Label>
</div>
))}
</div>
</div>
)}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox

View File

@@ -15,7 +15,10 @@ export default function JuryGroupDetailPage() {
const router = useRouter()
const juryGroupId = params.juryGroupId as string
const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery({ id: juryGroupId })
const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery(
{ id: juryGroupId },
{ refetchInterval: 30_000 }
)
if (isLoading) {
return (

View File

@@ -104,9 +104,10 @@ export default function CompetitionDetailPage() {
roundType: '' as string,
})
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
id: competitionId,
})
const { data: competition, isLoading } = trpc.competition.getById.useQuery(
{ id: competitionId },
{ refetchInterval: 30_000 }
)
const updateMutation = trpc.competition.update.useMutation({
onSuccess: () => {

View File

@@ -53,7 +53,7 @@ export default function CompetitionListPage() {
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
{ programId: programId! },
{ enabled: !!programId }
{ enabled: !!programId, refetchInterval: 30_000 }
)
if (!programId) {

View File

@@ -104,9 +104,10 @@ export default function MessagesPage() {
{ enabled: recipientType === 'USER' }
)
// Fetch sent messages for history
const { data: sentMessages, isLoading: loadingSent } = trpc.message.inbox.useQuery(
{ page: 1, pageSize: 50 }
// Fetch sent messages for history (messages sent BY this admin)
const { data: sentMessages, isLoading: loadingSent } = trpc.message.sent.useQuery(
{ page: 1, pageSize: 50 },
{ refetchInterval: 30_000 }
)
const sendMutation = trpc.message.send.useMutation({
@@ -114,7 +115,7 @@ export default function MessagesPage() {
const count = (data as Record<string, unknown>)?.recipientCount || ''
toast.success(`Message sent successfully${count ? ` to ${count} recipients` : ''}`)
resetForm()
utils.message.inbox.invalidate()
utils.message.sent.invalidate()
},
onError: (e) => toast.error(e.message),
})
@@ -564,56 +565,69 @@ export default function MessagesPage() {
<TableHeader>
<TableRow>
<TableHead>Subject</TableHead>
<TableHead className="hidden md:table-cell">From</TableHead>
<TableHead className="hidden md:table-cell">Channel</TableHead>
<TableHead className="hidden md:table-cell">Recipients</TableHead>
<TableHead className="hidden md:table-cell">Channels</TableHead>
<TableHead className="hidden lg:table-cell">Status</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sentMessages.items.map((item: Record<string, unknown>) => {
const msg = item.message as Record<string, unknown> | undefined
const sender = msg?.sender as Record<string, unknown> | undefined
const channel = String(item.channel || 'EMAIL')
const isRead = !!item.isRead
{sentMessages.items.map((msg: any) => {
const channels = (msg.deliveryChannels as string[]) || []
const recipientCount = msg._count?.recipients ?? 0
const isSent = !!msg.sentAt
return (
<TableRow key={String(item.id)}>
<TableRow key={msg.id}>
<TableCell>
<div className="flex items-center gap-2">
{!isRead && (
<div className="h-2 w-2 rounded-full bg-primary shrink-0" />
)}
<span className={isRead ? 'text-muted-foreground' : 'font-medium'}>
{String(msg?.subject || 'No subject')}
<span className="font-medium">
{msg.subject || 'No subject'}
</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
{String(sender?.name || sender?.email || 'System')}
{msg.recipientType === 'ALL'
? 'All users'
: msg.recipientType === 'ROLE'
? `By role`
: msg.recipientType === 'ROUND_JURY'
? 'Round jury'
: msg.recipientType === 'USER'
? '1 user'
: msg.recipientType}
{recipientCount > 0 && ` (${recipientCount})`}
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex gap-1">
{channels.includes('EMAIL') && (
<Badge variant="outline" className="text-xs">
{channel === 'EMAIL' ? (
<><Mail className="mr-1 h-3 w-3" />Email</>
) : (
<><Bell className="mr-1 h-3 w-3" />In-App</>
)}
<Mail className="mr-1 h-3 w-3" />Email
</Badge>
)}
{channels.includes('IN_APP') && (
<Badge variant="outline" className="text-xs">
<Bell className="mr-1 h-3 w-3" />In-App
</Badge>
)}
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
{isRead ? (
{isSent ? (
<Badge variant="secondary" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
Read
Sent
</Badge>
) : (
<Badge variant="default" className="text-xs">New</Badge>
<Badge variant="default" className="text-xs">
<Clock className="mr-1 h-3 w-3" />
Scheduled
</Badge>
)}
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground">
{msg?.createdAt
? formatDate(msg.createdAt as string | Date)
{msg.sentAt
? formatDate(msg.sentAt)
: msg.scheduledAt
? formatDate(msg.scheduledAt)
: ''}
</TableCell>
</TableRow>

View File

@@ -108,7 +108,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
<Table>
<TableHeader>
<TableRow>
<TableHead>Stage</TableHead>
<TableHead>Round</TableHead>
<TableHead>Status</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Assignments</TableHead>

View File

@@ -21,7 +21,6 @@ import {
Bot,
Loader2,
Users,
User,
Check,
RefreshCw,
} from 'lucide-react'
@@ -338,24 +337,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</CardContent>
</Card>
{/* Manual Assignment */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<User className="h-5 w-5" />
Manual Assignment
</CardTitle>
<CardDescription>
Search and select a mentor manually
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Use the AI suggestions above or search for a specific user in the Users section
to assign them as a mentor manually.
</p>
</CardContent>
</Card>
</>
)}
</div>

View File

@@ -88,9 +88,10 @@ const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' |
function ProjectDetailContent({ projectId }: { projectId: string }) {
// Fetch project + assignments + stats in a single combined query
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery({
id: projectId,
})
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery(
{ id: projectId },
{ refetchInterval: 30_000 }
)
const project = fullDetail?.project
const assignments = fullDetail?.assignments

View File

@@ -2,7 +2,6 @@
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@@ -831,6 +830,97 @@ function DiversityTab() {
)
}
function RoundPipelineTab() {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap(p =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string; type?: string }>).map((s) => ({
...s,
programId: p.id,
programName: `${p.year} Edition`,
}))
) || []
const roundIds = rounds.map(r => r.id)
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds },
{ enabled: roundIds.length >= 1 }
)
if (isLoading || comparisonLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-24" />)}
</div>
)
}
if (!rounds.length) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Layers className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No rounds available</p>
</CardContent>
</Card>
)
}
const comparisonMap = new Map(
(comparison ?? []).map((c: any) => [c.roundId, c])
)
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Layers className="h-4 w-4 text-violet-600" />
</div>
Round Pipeline
</CardTitle>
<CardDescription>Project flow across competition rounds</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{rounds.map((round, idx) => {
const stats = comparisonMap.get(round.id) as any
return (
<div key={round.id} className="flex items-center gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium">
{idx + 1}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{round.name}</p>
<p className="text-xs text-muted-foreground">{round.programName}</p>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="tabular-nums">{stats?.projectCount ?? 0} projects</span>
<span className="tabular-nums">{stats?.evaluationCount ?? 0} evals</span>
<Badge variant={round.status === 'ROUND_ACTIVE' ? 'default' : round.status === 'ROUND_CLOSED' ? 'secondary' : 'outline'}>
{round.status?.replace('ROUND_', '') ?? 'DRAFT'}
</Badge>
</div>
</div>
{stats?.completionRate != null && (
<Progress value={stats.completionRate} className="mt-2 h-2" />
)}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
</div>
)
}
export default function ReportsPage() {
const [pdfStageId, setPdfStageId] = useState<string | null>(null)
@@ -879,11 +969,9 @@ export default function ReportsPage() {
<Globe className="h-4 w-4" />
Diversity
</TabsTrigger>
<TabsTrigger value="pipeline" className="gap-2" asChild>
<Link href={"/admin/reports/stages" as Route}>
<TabsTrigger value="pipeline" className="gap-2">
<Layers className="h-4 w-4" />
By Round
</Link>
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2 w-full sm:w-auto justify-between sm:justify-end">
@@ -928,6 +1016,10 @@ export default function ReportsPage() {
<TabsContent value="diversity">
<DiversityTab />
</TabsContent>
<TabsContent value="pipeline">
<RoundPipelineTab />
</TabsContent>
</Tabs>
</div>
)

View File

@@ -122,18 +122,19 @@ export default function RoundsPage() {
const [competitionEdits, setCompetitionEdits] = useState<Record<string, unknown>>({})
const [editingCompId, setEditingCompId] = useState<string | null>(null)
const [filterType, setFilterType] = useState<string>('all')
const [selectedCompId, setSelectedCompId] = useState<string | null>(null)
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
{ programId: programId! },
{ enabled: !!programId }
{ enabled: !!programId, refetchInterval: 30_000 }
)
// Use the first (and usually only) competition
const comp = competitions?.[0]
// Auto-select first competition, or use the user's selection
const comp = competitions?.find((c: any) => c.id === selectedCompId) ?? competitions?.[0]
const { data: compDetail, isLoading: isLoadingDetail } = trpc.competition.getById.useQuery(
{ id: comp?.id! },
{ enabled: !!comp?.id }
{ enabled: !!comp?.id, refetchInterval: 30_000 }
)
const { data: awards } = trpc.specialAward.list.useQuery(
@@ -289,6 +290,22 @@ export default function RoundsPage() {
return (
<TooltipProvider delayDuration={200}>
<div className="space-y-5">
{/* Competition selector (when multiple exist) */}
{competitions && competitions.length > 1 && (
<Select value={comp.id} onValueChange={setSelectedCompId}>
<SelectTrigger className="w-[280px] mb-4">
<SelectValue placeholder="Select competition" />
</SelectTrigger>
<SelectContent>
{competitions.map((c: any) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* ── Header Bar ──────────────────────────────────────────────── */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">

View File

@@ -31,7 +31,7 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
votes.forEach((vote) => {
submitVoteMutation.mutate({
sessionId: params.sessionId,
juryMemberId: session?.currentUser?.id || '',
juryMemberId: '', // TODO: resolve current user's jury member ID from session participants
projectId: vote.projectId,
rank: vote.rank,
isWinnerPick: vote.isWinnerPick
@@ -63,9 +63,9 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
);
}
const hasVoted = session.currentUser?.hasVoted;
const hasVoted = false; // TODO: check if current user has voted in this session
if (session.status !== 'DELIB_VOTING') {
if (session.status !== 'VOTING') {
return (
<div className="space-y-6">
<Card>
@@ -79,7 +79,7 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
<p className="text-muted-foreground">
{session.status === 'DELIB_OPEN'
? 'Voting has not started yet. Please wait for the admin to open voting.'
: session.status === 'DELIB_TALLYING'
: session.status === 'TALLYING'
? 'Voting is closed. Results are being tallied.'
: 'This session is locked.'}
</p>
@@ -140,7 +140,7 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
</Card>
<DeliberationRankingForm
projects={session.projects || []}
projects={session.results?.map((r) => r.project) ?? []}
mode={session.mode}
onSubmit={handleSubmitVote}
disabled={submitVoteMutation.isPending}

View File

@@ -35,7 +35,7 @@ export function AdminOverrideDialog({
const { data: session } = trpc.deliberation.getSession.useQuery(
{ sessionId },
{ enabled: open }
{ enabled: open, refetchInterval: 10_000 }
);
const adminDecideMutation = trpc.deliberation.adminDecide.useMutation({
@@ -91,7 +91,7 @@ export function AdminOverrideDialog({
<Label>Project Rankings</Label>
<div className="space-y-2">
{projectIds.map((projectId) => {
const project = session?.projects?.find((p: any) => p.id === projectId);
const project = session?.results?.find((r) => r.project.id === projectId)?.project;
return (
<div key={projectId} className="flex items-center gap-3">
<Input

View File

@@ -18,8 +18,14 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
const utils = trpc.useUtils();
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false);
const { data: session } = trpc.deliberation.getSession.useQuery({ sessionId });
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery({ sessionId });
const { data: session } = trpc.deliberation.getSession.useQuery(
{ sessionId },
{ refetchInterval: 10_000 }
);
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery(
{ sessionId },
{ refetchInterval: 10_000 }
);
const initRunoffMutation = trpc.deliberation.initRunoff.useMutation({
onSuccess: () => {
@@ -74,7 +80,7 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
.filter((r) => r.totalScore === (aggregatedResults.rankings as Array<{ totalScore?: number }>)[0]?.totalScore)
.map((r) => r.projectId)
: [];
const canFinalize = session?.status === 'DELIB_TALLYING' && !hasTie;
const canFinalize = session?.status === 'TALLYING' && !hasTie;
return (
<div className="space-y-4">

View File

@@ -1,6 +1,5 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@@ -85,8 +84,6 @@ export function EvaluationSummaryCard({
projectId,
roundId,
}: EvaluationSummaryCardProps) {
const [isGenerating, setIsGenerating] = useState(false)
const {
data: summary,
isLoading,
@@ -97,19 +94,18 @@ export function EvaluationSummaryCard({
onSuccess: () => {
toast.success('AI summary generated successfully')
refetch()
setIsGenerating(false)
},
onError: (error) => {
toast.error(error.message || 'Failed to generate summary')
setIsGenerating(false)
},
})
const handleGenerate = () => {
setIsGenerating(true)
generateMutation.mutate({ projectId, roundId })
}
const isGenerating = generateMutation.isPending
if (isLoading) {
return (
<Card>

View File

@@ -154,6 +154,7 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="hidden sm:table-cell">Role</TableHead>
<TableHead>Email</TableHead>
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
<TableHead className="hidden lg:table-cell">Cap Mode</TableHead>
@@ -163,7 +164,7 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableBody>
{members.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
<TableCell colSpan={6} className="text-center text-muted-foreground">
No members yet. Add members to get started.
</TableCell>
</TableRow>
@@ -173,6 +174,11 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableCell className="font-medium">
{member.user.name || 'Unnamed User'}
</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="outline" className="text-[10px] capitalize">
{member.role?.toLowerCase().replace('_', ' ') || 'member'}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{member.user.email}
</TableCell>

View File

@@ -5,7 +5,7 @@ import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ChevronLeft, ChevronRight, Play, Square, Timer } from 'lucide-react';
import { ChevronLeft, ChevronRight, Play, Square, Pause, Timer } from 'lucide-react';
import { toast } from 'sonner';
interface LiveControlPanelProps {
@@ -15,18 +15,36 @@ interface LiveControlPanelProps {
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
const utils = trpc.useUtils();
const [timerSeconds, setTimerSeconds] = useState(300); // 5 minutes default
const [timerSeconds, setTimerSeconds] = useState(300);
const [isTimerRunning, setIsTimerRunning] = useState(false);
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId });
// TODO: Add getScores to live router
const scores: any[] = [];
const { data: cursor } = trpc.live.getCursor.useQuery(
{ roundId },
{ refetchInterval: 5000 }
);
// TODO: Implement cursor mutation
const moveCursorMutation = {
mutate: () => {},
isPending: false
};
const jumpMutation = trpc.live.jump.useMutation({
onSuccess: () => {
utils.live.getCursor.invalidate({ roundId });
},
onError: (err) => toast.error(err.message),
});
const pauseMutation = trpc.live.pause.useMutation({
onSuccess: () => {
utils.live.getCursor.invalidate({ roundId });
toast.success('Live session paused');
},
onError: (err) => toast.error(err.message),
});
const resumeMutation = trpc.live.resume.useMutation({
onSuccess: () => {
utils.live.getCursor.invalidate({ roundId });
toast.success('Live session resumed');
},
onError: (err) => toast.error(err.message),
});
useEffect(() => {
if (!isTimerRunning) return;
@@ -44,14 +62,24 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
return () => clearInterval(interval);
}, [isTimerRunning]);
const currentIndex = cursor?.activeOrderIndex ?? 0;
const totalProjects = cursor?.totalProjects ?? 0;
const isNavigating = jumpMutation.isPending;
const handlePrevious = () => {
// TODO: Implement previous navigation
toast.info('Previous navigation not yet implemented');
if (currentIndex <= 0) {
toast.info('Already at the first project');
return;
}
jumpMutation.mutate({ roundId, index: currentIndex - 1 });
};
const handleNext = () => {
// TODO: Implement next navigation
toast.info('Next navigation not yet implemented');
if (currentIndex >= totalProjects - 1) {
toast.info('Already at the last project');
return;
}
jumpMutation.mutate({ roundId, index: currentIndex + 1 });
};
const formatTime = (seconds: number) => {
@@ -67,12 +95,17 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Current Project</CardTitle>
<div className="flex gap-2">
<div className="flex items-center gap-2">
{cursor && (
<span className="text-sm text-muted-foreground tabular-nums">
{currentIndex + 1} / {totalProjects}
</span>
)}
<Button
variant="outline"
size="icon"
onClick={handlePrevious}
disabled={moveCursorMutation.isPending}
disabled={isNavigating || currentIndex <= 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
@@ -80,7 +113,7 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
variant="outline"
size="icon"
onClick={handleNext}
disabled={moveCursorMutation.isPending}
disabled={isNavigating || currentIndex >= totalProjects - 1}
>
<ChevronRight className="h-4 w-4" />
</Button>
@@ -92,13 +125,24 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
<div className="space-y-4">
<div>
<h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3>
{cursor.activeProject.teamName && (
<p className="text-muted-foreground">{cursor.activeProject.teamName}</p>
)}
</div>
<div className="text-sm text-muted-foreground">
Total projects: {cursor.totalProjects}
{cursor.activeProject.tags && (cursor.activeProject.tags as string[]).length > 0 && (
<div className="flex flex-wrap gap-1">
{(cursor.activeProject.tags as string[]).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
</div>
) : (
<p className="text-muted-foreground">No project selected</p>
<p className="text-muted-foreground">
{cursor ? 'No project selected' : 'No live session active for this round'}
</p>
)}
</CardContent>
</Card>
@@ -144,48 +188,48 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
</CardContent>
</Card>
{/* Voting Controls */}
{/* Session Controls */}
<Card>
<CardHeader>
<CardTitle>Voting Controls</CardTitle>
<CardDescription>Manage jury and audience voting</CardDescription>
<CardTitle>Session Controls</CardTitle>
<CardDescription>Pause or resume the live presentation</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button className="w-full" variant="default">
<Play className="mr-2 h-4 w-4" />
Open Jury Voting
</Button>
<Button className="w-full" variant="outline">
Close Voting
</Button>
</CardContent>
</Card>
{/* Scores Display */}
<Card>
<CardHeader>
<CardTitle>Live Scores</CardTitle>
</CardHeader>
<CardContent>
{scores && scores.length > 0 ? (
<div className="space-y-2">
{scores.map((score: any, index: number) => (
<div
key={score.projectId}
className="flex items-center justify-between rounded-lg border p-3"
{cursor?.isPaused ? (
<Button
className="w-full"
onClick={() => resumeMutation.mutate({ roundId })}
disabled={resumeMutation.isPending}
>
<div>
<p className="font-medium">
#{index + 1} {score.projectTitle}
</p>
<p className="text-sm text-muted-foreground">{score.votes} votes</p>
</div>
<Badge variant="outline">{score.totalScore.toFixed(1)}</Badge>
<Play className="mr-2 h-4 w-4" />
{resumeMutation.isPending ? 'Resuming...' : 'Resume Session'}
</Button>
) : (
<Button
className="w-full"
variant="outline"
onClick={() => pauseMutation.mutate({ roundId })}
disabled={pauseMutation.isPending || !cursor}
>
<Pause className="mr-2 h-4 w-4" />
{pauseMutation.isPending ? 'Pausing...' : 'Pause Session'}
</Button>
)}
{cursor?.isPaused && (
<Badge variant="destructive" className="w-full justify-center py-1">
Session Paused
</Badge>
)}
{cursor?.openCohorts && cursor.openCohorts.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium mb-2">Open Voting Windows</p>
{cursor.openCohorts.map((cohort: any) => (
<div key={cohort.id} className="flex items-center justify-between text-sm">
<span>{cohort.name}</span>
<Badge variant="outline">{cohort.votingMode}</Badge>
</div>
))}
</div>
) : (
<p className="text-center text-muted-foreground">No scores yet</p>
)}
</CardContent>
</Card>

View File

@@ -1,166 +0,0 @@
'use client'
import { useState, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { FileDown, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import {
createReportDocument,
addCoverPage,
addPageBreak,
addHeader,
addSectionTitle,
addStatCards,
addTable,
addAllPageFooters,
savePdf,
} from '@/lib/pdf-generator'
interface PdfReportProps {
roundId: string
sections: string[]
}
export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
const [generating, setGenerating] = useState(false)
const { refetch } = trpc.export.getReportData.useQuery(
{ roundId, sections },
{ enabled: false }
)
const handleGenerate = useCallback(async () => {
setGenerating(true)
toast.info('Generating PDF report...')
try {
const result = await refetch()
if (!result.data) {
toast.error('Failed to fetch report data')
return
}
const data = result.data as Record<string, unknown>
const rName = String(data.roundName || 'Report')
const pName = String(data.programName || '')
// 1. Create document
const doc = await createReportDocument()
// 2. Cover page
await addCoverPage(doc, {
title: 'Round Report',
subtitle: `${pName} ${data.programYear ? `(${data.programYear})` : ''}`.trim(),
roundName: rName,
programName: pName,
})
// 3. Summary
const summary = data.summary as Record<string, unknown> | undefined
if (summary) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Summary', 28)
y = addStatCards(doc, [
{ label: 'Projects', value: String(summary.projectCount ?? 0) },
{ label: 'Evaluations', value: String(summary.evaluationCount ?? 0) },
{
label: 'Avg Score',
value: summary.averageScore != null
? Number(summary.averageScore).toFixed(1)
: '--',
},
{
label: 'Completion',
value: summary.completionRate != null
? `${Number(summary.completionRate).toFixed(0)}%`
: '--',
},
], y)
}
// 4. Rankings
const rankings = data.rankings as Array<Record<string, unknown>> | undefined
if (rankings && rankings.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Project Rankings', 28)
const headers = ['#', 'Project', 'Team', 'Avg Score', 'Evaluations', 'Yes %']
const rows = rankings.map((r, i) => [
i + 1,
String(r.title ?? ''),
String(r.teamName ?? ''),
r.averageScore != null ? Number(r.averageScore).toFixed(2) : '-',
String(r.evaluationCount ?? 0),
r.yesPercentage != null ? `${Number(r.yesPercentage).toFixed(0)}%` : '-',
])
y = addTable(doc, headers, rows, y)
}
// 5. Juror stats
const jurorStats = data.jurorStats as Array<Record<string, unknown>> | undefined
if (jurorStats && jurorStats.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Juror Statistics', 28)
const headers = ['Juror', 'Assigned', 'Completed', 'Completion %', 'Avg Score']
const rows = jurorStats.map((j) => [
String(j.name ?? ''),
String(j.assigned ?? 0),
String(j.completed ?? 0),
`${Number(j.completionRate ?? 0).toFixed(0)}%`,
j.averageScore != null ? Number(j.averageScore).toFixed(2) : '-',
])
y = addTable(doc, headers, rows, y)
}
// 6. Criteria breakdown
const criteriaBreakdown = data.criteriaBreakdown as Array<Record<string, unknown>> | undefined
if (criteriaBreakdown && criteriaBreakdown.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Criteria Breakdown', 28)
const headers = ['Criterion', 'Avg Score', 'Responses']
const rows = criteriaBreakdown.map((c) => [
String(c.label ?? ''),
c.averageScore != null ? Number(c.averageScore).toFixed(2) : '-',
String(c.count ?? 0),
])
y = addTable(doc, headers, rows, y)
}
// 7. Footers
addAllPageFooters(doc)
// 8. Save
const dateStr = new Date().toISOString().split('T')[0]
savePdf(doc, `MOPC-Report-${rName.replace(/\s+/g, '-')}-${dateStr}.pdf`)
toast.success('PDF report downloaded successfully')
} catch (err) {
console.error('PDF generation error:', err)
toast.error('Failed to generate PDF report')
} finally {
setGenerating(false)
}
}, [refetch])
return (
<Button variant="outline" onClick={handleGenerate} disabled={generating}>
{generating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileDown className="mr-2 h-4 w-4" />
)}
{generating ? 'Generating...' : 'Export PDF Report'}
</Button>
)
}

View File

@@ -31,15 +31,21 @@ export function ResultLockControls({ competitionId, roundId, category }: ResultL
const [unlockDialogOpen, setUnlockDialogOpen] = useState(false);
const [unlockReason, setUnlockReason] = useState('');
const { data: lockStatus } = trpc.resultLock.isLocked.useQuery({
competitionId,
roundId,
category
});
const { data: lockStatus } = trpc.resultLock.isLocked.useQuery(
{ competitionId, roundId, category },
{ refetchInterval: 15_000 }
);
const { data: history } = trpc.resultLock.history.useQuery({
competitionId
});
const { data: history } = trpc.resultLock.history.useQuery(
{ competitionId },
{ refetchInterval: 15_000 }
);
// Fetch project rankings for the snapshot
const { data: projectRankings } = trpc.analytics.getProjectRankings.useQuery(
{ roundId, limit: 5000 },
{ enabled: !!roundId }
);
const lockMutation = trpc.resultLock.lock.useMutation({
onSuccess: () => {
@@ -67,11 +73,25 @@ export function ResultLockControls({ competitionId, roundId, category }: ResultL
});
const handleLock = () => {
const snapshot = {
lockedAt: new Date().toISOString(),
category,
roundId,
rankings: (projectRankings ?? []).map((p: any) => ({
projectId: p.id,
title: p.title,
teamName: p.teamName,
averageScore: p.averageScore,
evaluationCount: p.evaluationCount,
status: p.status,
})),
};
lockMutation.mutate({
competitionId,
roundId,
category,
resultSnapshot: {} // This would contain the actual results snapshot
resultSnapshot: snapshot,
});
};

View File

@@ -35,7 +35,6 @@ import {
Pencil,
Trash2,
FileText,
GripVertical,
FileCheck,
FileQuestion,
} from 'lucide-react'

View File

@@ -95,6 +95,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
const [page, setPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [pollingJobId, setPollingJobId] = useState<string | null>(null)
const [jobRunning, setJobRunning] = useState(false)
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false)
const [overrideTarget, setOverrideTarget] = useState<{ id: string; name: string } | null>(null)
const [overrideOutcome, setOverrideOutcome] = useState<'PASSED' | 'FILTERED_OUT' | 'FLAGGED'>('PASSED')
@@ -127,14 +128,19 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
onError: (err) => toast.error(err.message),
})
// Dynamic refetch: all viewers get fast polling when a job is running (not just the one who started it)
const { data: latestJob } = trpc.filtering.getLatestJob.useQuery(
{ roundId },
{ refetchInterval: pollingJobId ? 3_000 : 15_000 },
{ refetchInterval: jobRunning ? 3_000 : 15_000 },
)
// Dynamic refetch: 3s during running job, 15s otherwise
const isRunning = !!pollingJobId || latestJob?.status === 'RUNNING'
// Sync jobRunning so fast polling kicks in for ALL viewers, not just the job starter
useEffect(() => {
setJobRunning(isRunning)
}, [isRunning])
const { data: stats, isLoading: statsLoading } = trpc.filtering.getResultStats.useQuery(
{ roundId },
{ refetchInterval: isRunning ? 3_000 : 15_000 },

View File

@@ -188,10 +188,10 @@ export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWi
roundNumber: window.roundNumber,
windowOpenAt: window.windowOpenAt ? new Date(window.windowOpenAt).toISOString().slice(0, 16) : '',
windowCloseAt: window.windowCloseAt ? new Date(window.windowCloseAt).toISOString().slice(0, 16) : '',
deadlinePolicy: 'HARD_DEADLINE', // Not available in query, use default
graceHours: 0, // Not available in query, use default
lockOnClose: true, // Not available in query, use default
sortOrder: 1, // Not available in query, use default
deadlinePolicy: window.deadlinePolicy ?? 'HARD_DEADLINE',
graceHours: window.graceHours ?? 0,
lockOnClose: window.lockOnClose ?? true,
sortOrder: window.sortOrder ?? 1,
})
setEditingWindow(window.id)
}

View File

@@ -46,12 +46,9 @@ export function DeliberationConfig({ config, onChange, juryGroups }: Deliberatio
</SelectContent>
</Select>
) : (
<Input
id="juryGroupId"
placeholder="Jury group ID"
value={(config.juryGroupId as string) ?? ''}
onChange={(e) => update('juryGroupId', e.target.value)}
/>
<p className="text-sm text-muted-foreground italic">
No jury groups available. Create one in the Juries section first.
</p>
)}
</div>
</CardContent>

View File

@@ -21,7 +21,8 @@ import { Badge } from '@/components/ui/badge';
interface Project {
id: string;
title: string;
category: string;
category?: string;
teamName?: string | null;
}
interface DeliberationRankingFormProps {

View File

@@ -59,7 +59,6 @@ type NavItem = {
icon: typeof LayoutDashboard
activeMatch?: string // pathname must include this to be active
activeExclude?: string // pathname must NOT include this to be active
subItems?: { name: string; href: string }[]
}
// Main navigation - scoped to selected edition
@@ -228,9 +227,6 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
const isActive =
pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href))
const isParentActive = item.subItems
? pathname.startsWith('/admin/competitions')
: false
return (
<div key={item.name}>
<Link
@@ -249,29 +245,6 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
)} />
{item.name}
</Link>
{item.subItems && isParentActive && (
<div className="ml-7 mt-0.5 space-y-0.5">
{item.subItems.map((sub) => {
const isSubActive = pathname === sub.href ||
(sub.href !== '/admin/competitions' && pathname.startsWith(sub.href))
return (
<Link
key={sub.name}
href={sub.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'block rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
isSubActive
? 'text-brand-blue bg-brand-blue/10'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
{sub.name}
</Link>
)
})}
</div>
)}
</div>
)
})}

View File

@@ -408,46 +408,61 @@ export const analyticsRouter = router({
getCrossRoundComparison: observerProcedure
.input(z.object({ roundIds: z.array(z.string()).min(2) }))
.query(async ({ ctx, input }) => {
const comparisons = await Promise.all(
input.roundIds.map(async (roundId) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true },
})
const { roundIds } = input
const [projectCount, assignmentCount, evaluationCount] = await Promise.all([
ctx.prisma.project.count({
where: { assignments: { some: { roundId } } },
// Batch: fetch all rounds, assignments, and evaluations in 3 queries
const [rounds, assignments, evaluations] = await Promise.all([
ctx.prisma.round.findMany({
where: { id: { in: roundIds } },
select: { id: true, name: true },
}),
ctx.prisma.assignment.count({ where: { roundId } }),
ctx.prisma.evaluation.count({
ctx.prisma.assignment.groupBy({
by: ['roundId'],
where: { roundId: { in: roundIds } },
_count: true,
}),
ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId },
assignment: { roundId: { in: roundIds } },
status: 'SUBMITTED',
},
select: { globalScore: true, assignment: { select: { roundId: true } } },
}),
])
const roundMap = new Map(rounds.map((r) => [r.id, r.name]))
const assignmentCountMap = new Map(assignments.map((a) => [a.roundId, a._count]))
// Group evaluations by round
const evalsByRound = new Map<string, number[]>()
const projectsByRound = new Map<string, Set<string>>()
for (const e of evaluations) {
const rid = e.assignment.roundId
if (!evalsByRound.has(rid)) evalsByRound.set(rid, [])
if (e.globalScore !== null) evalsByRound.get(rid)!.push(e.globalScore)
}
// Count distinct projects per round via assignments
const projectAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: { in: roundIds } },
select: { roundId: true, projectId: true },
distinct: ['roundId', 'projectId'],
})
for (const pa of projectAssignments) {
if (!projectsByRound.has(pa.roundId)) projectsByRound.set(pa.roundId, new Set())
projectsByRound.get(pa.roundId)!.add(pa.projectId)
}
return roundIds.map((roundId) => {
const globalScores = evalsByRound.get(roundId) ?? []
const assignmentCount = assignmentCountMap.get(roundId) ?? 0
const evaluationCount = globalScores.length
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId },
status: 'SUBMITTED',
},
select: { globalScore: true },
})
const globalScores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
const averageScore = globalScores.length > 0
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: null
const distribution = Array.from({ length: 10 }, (_, i) => ({
score: i + 1,
count: globalScores.filter((s) => Math.round(s) === i + 1).length,
@@ -455,17 +470,14 @@ export const analyticsRouter = router({
return {
roundId,
roundName: round.name,
projectCount,
roundName: roundMap.get(roundId) ?? roundId,
projectCount: projectsByRound.get(roundId)?.size ?? 0,
evaluationCount,
completionRate,
averageScore,
scoreDistribution: distribution,
}
})
)
return comparisons
}),
/**
@@ -620,38 +632,58 @@ export const analyticsRouter = router({
})
const allRounds = competitions.flatMap((c) => c.rounds)
const roundIds = allRounds.map((r) => r.id)
const stats = await Promise.all(
allRounds.map(async (round) => {
const [projectCount, evaluationCount, assignmentCount] = await Promise.all([
ctx.prisma.project.count({
where: { assignments: { some: { roundId: round.id } } },
if (roundIds.length === 0) return []
// Batch: fetch assignments, evaluations, and distinct projects in 3 queries
const [assignmentCounts, evaluations, projectAssignments] = await Promise.all([
ctx.prisma.assignment.groupBy({
by: ['roundId'],
where: { roundId: { in: roundIds } },
_count: true,
}),
ctx.prisma.evaluation.count({
ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: round.id },
assignment: { roundId: { in: roundIds } },
status: 'SUBMITTED',
},
select: { globalScore: true, assignment: { select: { roundId: true } } },
}),
ctx.prisma.assignment.findMany({
where: { roundId: { in: roundIds } },
select: { roundId: true, projectId: true },
distinct: ['roundId', 'projectId'],
}),
ctx.prisma.assignment.count({ where: { roundId: round.id } }),
])
const assignmentCountMap = new Map(assignmentCounts.map((a) => [a.roundId, a._count]))
// Group evaluation scores by round
const scoresByRound = new Map<string, number[]>()
const evalCountByRound = new Map<string, number>()
for (const e of evaluations) {
const rid = e.assignment.roundId
evalCountByRound.set(rid, (evalCountByRound.get(rid) ?? 0) + 1)
if (e.globalScore !== null) {
if (!scoresByRound.has(rid)) scoresByRound.set(rid, [])
scoresByRound.get(rid)!.push(e.globalScore)
}
}
// Count distinct projects per round
const projectsByRound = new Map<string, number>()
for (const pa of projectAssignments) {
projectsByRound.set(pa.roundId, (projectsByRound.get(pa.roundId) ?? 0) + 1)
}
return allRounds.map((round) => {
const scores = scoresByRound.get(round.id) ?? []
const assignmentCount = assignmentCountMap.get(round.id) ?? 0
const evaluationCount = evalCountByRound.get(round.id) ?? 0
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
select: { globalScore: true },
})
const scores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
const averageScore = scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null
@@ -660,15 +692,12 @@ export const analyticsRouter = router({
roundId: round.id,
roundName: round.name,
createdAt: round.createdAt,
projectCount,
projectCount: projectsByRound.get(round.id) ?? 0,
evaluationCount,
completionRate,
averageScore,
}
})
)
return stats
}),
/**

View File

@@ -128,7 +128,7 @@ export const competitionRouter = router({
/**
* List competitions for a program
*/
list: protectedProcedure
list: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.competition.findMany({

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, juryProcedure, protectedProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import {
createSession,
openVoting,
@@ -48,7 +49,26 @@ export const deliberationRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
return createSession(input, ctx.prisma)
const session = await createSession(input, ctx.prisma)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'DeliberationSession',
entityId: session.id,
detailsJson: {
competitionId: input.competitionId,
roundId: input.roundId,
category: input.category,
mode: input.mode,
participantCount: input.participantUserIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return session
}),
/**
@@ -98,7 +118,26 @@ export const deliberationRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
return submitVote(input, ctx.prisma)
const vote = await submitVote(input, ctx.prisma)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'DeliberationVote',
entityId: input.sessionId,
detailsJson: {
sessionId: input.sessionId,
projectId: input.projectId,
rank: input.rank,
isWinnerPick: input.isWinnerPick,
runoffRound: input.runoffRound,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return vote
}),
/**
@@ -264,7 +303,7 @@ export const deliberationRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
return updateParticipantStatus(
const result = await updateParticipantStatus(
input.sessionId,
input.userId,
input.status,
@@ -272,5 +311,23 @@ export const deliberationRouter = router({
ctx.user.id,
ctx.prisma,
)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'DeliberationParticipant',
entityId: input.sessionId,
detailsJson: {
sessionId: input.sessionId,
targetUserId: input.userId,
status: input.status,
replacedById: input.replacedById,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
}),
})

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma, PrismaClient } from '@prisma/client'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import { router, adminProcedure } from '../trpc'
import { executeFilteringRules, type ProgressCallback, type AwardCriteriaInput, type AwardMatchResult } from '../services/ai-filtering'
import { sanitizeUserInput } from '../services/ai-prompt-guard'
import { logAudit } from '../utils/audit'
@@ -259,15 +259,9 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
aiReasoningJson: { reasoning: am.reasoning, confidence: am.confidence },
},
update: {
eligible: am.eligible,
method: 'AUTO',
// Only update AI-computed fields; preserve manual overrides
qualityScore: am.qualityScore,
aiReasoningJson: { reasoning: am.reasoning, confidence: am.confidence },
overriddenBy: null,
overriddenAt: null,
shortlisted: false,
confirmedAt: null,
confirmedBy: null,
},
})
)
@@ -279,6 +273,36 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
}
}, awardsForAI)
// Sync eligible/method for non-overridden award eligibility records
if (awardsForAI.length > 0) {
const syncAwardIds = awardsForAI.map((a) => a.awardId)
const nonOverridden = await prisma.awardEligibility.findMany({
where: { awardId: { in: syncAwardIds }, overriddenBy: null },
select: { awardId: true, projectId: true },
})
const nonOverriddenSet = new Set(nonOverridden.map((r) => `${r.awardId}:${r.projectId}`))
const eligibleUpdates: Prisma.PrismaPromise<unknown>[] = []
for (const r of results) {
if (r.outcome !== 'PASSED' || !r.awardMatches) continue
for (const am of r.awardMatches) {
if (nonOverriddenSet.has(`${am.awardId}:${r.projectId}`)) {
eligibleUpdates.push(
prisma.awardEligibility.update({
where: {
awardId_projectId: { awardId: am.awardId, projectId: r.projectId },
},
data: { eligible: am.eligible, method: 'AUTO' },
})
)
}
}
}
if (eligibleUpdates.length > 0) {
await prisma.$transaction(eligibleUpdates)
}
}
// Auto-shortlist top-N per award and mark eligibility job as completed
if (awardsForAI.length > 0) {
// Collect all award matches from PASSED results
@@ -303,9 +327,18 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
for (const award of awardsWithSize) {
const eligible = awardMatchesByAward.get(award.id) || []
const shortlistSize = award.shortlistSize ?? 10
// Preserve manually-shortlisted records and account for them in count
const alreadyShortlisted = await prisma.awardEligibility.findMany({
where: { awardId: award.id, shortlisted: true, overriddenBy: { not: null } },
select: { projectId: true },
})
const manuallyShortlistedIds = new Set(alreadyShortlisted.map((r) => r.projectId))
const topN = eligible
.filter((e) => !manuallyShortlistedIds.has(e.projectId))
.sort((a, b) => b.qualityScore - a.qualityScore)
.slice(0, shortlistSize)
.slice(0, Math.max(0, shortlistSize - manuallyShortlistedIds.size))
if (topN.length > 0) {
await prisma.$transaction(
@@ -337,6 +370,28 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
console.log(`[Filtering] Auto-shortlisted for ${awardsWithSize.length} award(s)`)
}
// Clean up stale results from previous runs (projects no longer in this round)
const processedIds = projects.map((p: any) => p.id)
if (processedIds.length > 0) {
await prisma.filteringResult.deleteMany({
where: {
roundId,
projectId: { notIn: processedIds },
},
})
// Clean up stale award eligibilities for linked awards
if (awardsForAI.length > 0) {
const cleanupAwardIds = awardsForAI.map((a) => a.awardId)
await prisma.awardEligibility.deleteMany({
where: {
awardId: { in: cleanupAwardIds },
projectId: { notIn: processedIds },
},
})
}
}
// Count outcomes
const passedCount = results.filter((r) => r.outcome === 'PASSED').length
const filteredCount = results.filter((r) => r.outcome === 'FILTERED_OUT').length
@@ -421,7 +476,7 @@ export const filteringRouter = router({
/**
* Check if AI is configured and ready for filtering
*/
checkAIStatus: protectedProcedure
checkAIStatus: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const aiRules = await ctx.prisma.filteringRule.count({
@@ -459,7 +514,7 @@ export const filteringRouter = router({
/**
* Get filtering rules for a stage
*/
getRules: protectedProcedure
getRules: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.filteringRule.findMany({
@@ -493,11 +548,14 @@ export const filteringRouter = router({
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'FilteringRule',
entityId: rule.id,
detailsJson: { roundId: input.roundId, name: input.name, ruleType: input.ruleType },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return rule
@@ -528,10 +586,13 @@ export const filteringRouter = router({
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'FilteringRule',
entityId: id,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return rule
@@ -546,10 +607,13 @@ export const filteringRouter = router({
await ctx.prisma.filteringRule.delete({ where: { id: input.id } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'FilteringRule',
entityId: input.id,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}),
@@ -639,12 +703,10 @@ export const filteringRouter = router({
})
}
// Clear previous filtering results so new ones stream in fresh
await ctx.prisma.filteringResult.deleteMany({
where: { roundId: input.roundId },
})
// Clear award eligibilities for awards linked to this competition
// Reset award eligibility job status for linked awards (safe — just UI progress indicators)
// NOTE: We no longer delete filteringResults or awardEligibilities here.
// The job uses upserts, and stale records are cleaned up AFTER the job succeeds.
// This prevents data loss if the job fails mid-run.
const roundForComp = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { competitionId: true },
@@ -659,9 +721,6 @@ export const filteringRouter = router({
})
const awardIds = linkedAwards.map((a) => a.id)
if (awardIds.length > 0) {
await ctx.prisma.awardEligibility.deleteMany({
where: { awardId: { in: awardIds } },
})
await ctx.prisma.specialAward.updateMany({
where: { id: { in: awardIds } },
data: {
@@ -693,7 +752,7 @@ export const filteringRouter = router({
/**
* Get current job status
*/
getJobStatus: protectedProcedure
getJobStatus: adminProcedure
.input(z.object({ jobId: z.string() }))
.query(async ({ ctx, input }) => {
const job = await ctx.prisma.filteringJob.findUnique({
@@ -708,7 +767,7 @@ export const filteringRouter = router({
/**
* Get latest job for a stage
*/
getLatestJob: protectedProcedure
getLatestJob: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.filteringJob.findFirst({
@@ -817,6 +876,7 @@ export const filteringRouter = router({
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Stage',
@@ -828,6 +888,8 @@ export const filteringRouter = router({
filteredOut: results.filter((r) => r.outcome === 'FILTERED_OUT').length,
flagged: results.filter((r) => r.outcome === 'FLAGGED').length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
@@ -841,7 +903,7 @@ export const filteringRouter = router({
/**
* Get filtering results for a stage (paginated)
*/
getResults: protectedProcedure
getResults: adminProcedure
.input(
z.object({
roundId: z.string(),
@@ -909,7 +971,7 @@ export const filteringRouter = router({
/**
* Get aggregate stats for filtering results
*/
getResultStats: protectedProcedure
getResultStats: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Use effective outcome (finalOutcome if overridden, otherwise original outcome)
@@ -994,6 +1056,7 @@ export const filteringRouter = router({
})
await logAudit({
prisma: ctx.prisma,
userId: verifiedUserId,
action: 'UPDATE',
entityType: 'FilteringResult',
@@ -1004,6 +1067,8 @@ export const filteringRouter = router({
finalOutcome: input.finalOutcome,
reason: input.reason,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
@@ -1034,6 +1099,7 @@ export const filteringRouter = router({
})
await logAudit({
prisma: ctx.prisma,
userId: verifiedUserId,
action: 'BULK_UPDATE_STATUS',
entityType: 'FilteringResult',
@@ -1042,6 +1108,8 @@ export const filteringRouter = router({
count: input.ids.length,
finalOutcome: input.finalOutcome,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { updated: input.ids.length }
@@ -1217,6 +1285,7 @@ export const filteringRouter = router({
await ctx.prisma.$transaction(operations)
await logAudit({
prisma: ctx.prisma,
userId: verifiedUserId,
action: 'UPDATE',
entityType: 'Stage',
@@ -1231,6 +1300,8 @@ export const filteringRouter = router({
categoryWarnings,
advancedToStage: nextRound?.name || null,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
@@ -1279,6 +1350,7 @@ export const filteringRouter = router({
})
await logAudit({
prisma: ctx.prisma,
userId: verifiedUserId,
action: 'UPDATE',
entityType: 'FilteringResult',
@@ -1287,6 +1359,8 @@ export const filteringRouter = router({
roundId: input.roundId,
projectId: input.projectId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}),
@@ -1327,6 +1401,7 @@ export const filteringRouter = router({
])
await logAudit({
prisma: ctx.prisma,
userId: verifiedUserId,
action: 'BULK_UPDATE_STATUS',
entityType: 'FilteringResult',
@@ -1335,6 +1410,8 @@ export const filteringRouter = router({
roundId: input.roundId,
count: input.projectIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { reinstated: input.projectIds.length }

View File

@@ -189,6 +189,45 @@ export const messageRouter = router({
})
}),
/**
* Get messages sent by the current admin user.
*/
sent: adminProcedure
.input(
z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const page = input?.page ?? 1
const pageSize = input?.pageSize ?? 20
const skip = (page - 1) * pageSize
const [items, total] = await Promise.all([
ctx.prisma.message.findMany({
where: { senderId: ctx.user.id },
include: {
_count: { select: { recipients: true } },
},
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
ctx.prisma.message.count({
where: { senderId: ctx.user.id },
}),
])
return {
items,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
}),
/**
* Get unread message count for the current user.
*/

View File

@@ -785,9 +785,17 @@ export const projectRouter = router({
.mutation(async ({ ctx, input }) => {
const target = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, title: true },
select: { id: true, title: true, status: true },
})
const protectedStatuses = ['FINALIST', 'SEMIFINALIST']
if (protectedStatuses.includes(target.status)) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: `Cannot delete a project with status ${target.status}. Change status first.`,
})
}
const project = await ctx.prisma.project.delete({
where: { id: input.id },
})
@@ -819,7 +827,7 @@ export const projectRouter = router({
.mutation(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.ids } },
select: { id: true, title: true },
select: { id: true, title: true, status: true },
})
if (projects.length === 0) {
@@ -829,6 +837,16 @@ export const projectRouter = router({
})
}
const protectedProjects = projects.filter((p) =>
['FINALIST', 'SEMIFINALIST'].includes(p.status)
)
if (protectedProjects.length > 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: `Cannot delete ${protectedProjects.length} project(s) with FINALIST/SEMIFINALIST status. Remove them from the selection first.`,
})
}
const result = await ctx.prisma.project.deleteMany({
where: { id: { in: projects.map((p) => p.id) } },
})

View File

@@ -1,6 +1,6 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import { router, adminProcedure } from '../trpc'
import {
activateRound,
closeRound,
@@ -139,7 +139,7 @@ export const roundEngineRouter = router({
/**
* Get all project round states for a round
*/
getProjectStates: protectedProcedure
getProjectStates: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return getProjectRoundStates(input.roundId, ctx.prisma)
@@ -148,7 +148,7 @@ export const roundEngineRouter = router({
/**
* Get a single project's state within a round
*/
getProjectState: protectedProcedure
getProjectState: adminProcedure
.input(
z.object({
projectId: z.string(),

View File

@@ -99,11 +99,6 @@ export const specialAwardRouter = router({
})
if (comp) {
competition = comp
// Backfill competitionId on the award
await ctx.prisma.specialAward.update({
where: { id: input.id },
data: { competitionId: comp.id },
})
}
}

View File

@@ -696,28 +696,29 @@ export const userRouter = router({
select: { id: true, email: true, name: true, role: true },
})
// Create pre-assignments for users who have them
let assignmentsCreated = 0
// Create pre-assignments for users who have them (batched)
const assignmentData: Array<{ userId: string; projectId: string; roundId: string; method: 'MANUAL'; createdBy: string }> = []
for (const user of createdUsers) {
const assignments = emailToAssignments.get(user.email.toLowerCase())
if (assignments && assignments.length > 0) {
for (const assignment of assignments) {
try {
await ctx.prisma.assignment.create({
data: {
assignmentData.push({
userId: user.id,
projectId: assignment.projectId,
roundId: assignment.roundId,
method: 'MANUAL',
createdBy: ctx.user.id,
},
})
assignmentsCreated++
} catch {
// Skip if assignment already exists (shouldn't happen for new users)
}
}
}
let assignmentsCreated = 0
if (assignmentData.length > 0) {
const result = await ctx.prisma.assignment.createMany({
data: assignmentData,
skipDuplicates: true,
})
assignmentsCreated = result.count
}
// Audit log for assignments if any were created
@@ -733,64 +734,103 @@ export const userRouter = router({
})
}
// Create JuryGroupMember records for users with juryGroupIds
let juryGroupMembershipsCreated = 0
let assignmentIntentsCreated = 0
// Create JuryGroupMember records for users with juryGroupIds (batched)
const juryGroupMemberData: Array<{ juryGroupId: string; userId: string; role: 'CHAIR' | 'MEMBER' | 'OBSERVER' }> = []
for (const user of createdUsers) {
const groupInfo = emailToJuryGroupIds.get(user.email.toLowerCase())
if (groupInfo) {
for (const groupId of groupInfo.ids) {
try {
await ctx.prisma.juryGroupMember.create({
data: {
juryGroupMemberData.push({
juryGroupId: groupId,
userId: user.id,
role: groupInfo.role,
},
})
juryGroupMembershipsCreated++
} catch {
// Skip if membership already exists
}
}
}
let juryGroupMembershipsCreated = 0
if (juryGroupMemberData.length > 0) {
const result = await ctx.prisma.juryGroupMember.createMany({
data: juryGroupMemberData,
skipDuplicates: true,
})
juryGroupMembershipsCreated = result.count
}
// Create AssignmentIntents for users who have them
const intents = emailToIntents.get(user.email.toLowerCase())
if (intents) {
for (const intent of intents) {
try {
// Look up the round's juryGroupId to find the matching JuryGroupMember
const round = await ctx.prisma.round.findUnique({
where: { id: intent.roundId },
select: { juryGroupId: true },
let assignmentIntentsCreated = 0
const allIntentUsers = createdUsers.filter(
(u) => emailToIntents.has(u.email.toLowerCase())
)
if (allIntentUsers.length > 0) {
// Batch-fetch all relevant rounds to avoid N+1 lookups
const allIntentRoundIds = new Set<string>()
for (const u of allIntentUsers) {
for (const intent of emailToIntents.get(u.email.toLowerCase())!) {
allIntentRoundIds.add(intent.roundId)
}
}
const rounds = await ctx.prisma.round.findMany({
where: { id: { in: [...allIntentRoundIds] } },
select: { id: true, juryGroupId: true },
})
if (round?.juryGroupId) {
const member = await ctx.prisma.juryGroupMember.findUnique({
const roundJuryGroupMap = new Map(rounds.map((r) => [r.id, r.juryGroupId]))
// Batch-fetch all matching JuryGroupMembers
const memberLookups = allIntentUsers.flatMap((u) => {
const intents = emailToIntents.get(u.email.toLowerCase())!
return intents
.map((intent) => {
const juryGroupId = roundJuryGroupMap.get(intent.roundId)
return juryGroupId ? { juryGroupId, userId: u.id } : null
})
.filter((x): x is { juryGroupId: string; userId: string } => x !== null)
})
const members = memberLookups.length > 0
? await ctx.prisma.juryGroupMember.findMany({
where: {
juryGroupId_userId: {
juryGroupId: round.juryGroupId,
userId: user.id,
},
OR: memberLookups.map((l) => ({
juryGroupId: l.juryGroupId,
userId: l.userId,
})),
},
select: { id: true, juryGroupId: true, userId: true },
})
if (member) {
await ctx.prisma.assignmentIntent.create({
data: {
juryGroupMemberId: member.id,
: []
const memberMap = new Map(
members.map((m) => [`${m.juryGroupId}:${m.userId}`, m.id])
)
// Batch-create all intents
const intentData: Array<{
juryGroupMemberId: string
roundId: string
projectId: string
source: 'INVITE'
status: 'INTENT_PENDING'
}> = []
for (const user of allIntentUsers) {
const intents = emailToIntents.get(user.email.toLowerCase())!
for (const intent of intents) {
const juryGroupId = roundJuryGroupMap.get(intent.roundId)
if (!juryGroupId) continue
const memberId = memberMap.get(`${juryGroupId}:${user.id}`)
if (!memberId) continue
intentData.push({
juryGroupMemberId: memberId,
roundId: intent.roundId,
projectId: intent.projectId,
source: 'INVITE',
status: 'INTENT_PENDING',
},
})
assignmentIntentsCreated++
}
}
} catch {
// Skip duplicate intents
}
}
if (intentData.length > 0) {
const result = await ctx.prisma.assignmentIntent.createMany({
data: intentData,
skipDuplicates: true,
})
assignmentIntentsCreated = result.count
}
}

View File

@@ -181,7 +181,7 @@ export async function processEligibilityJob(
}
})
// Upsert eligibilities
// Upsert eligibilities — preserve manual overrides and shortlist status
await prisma.$transaction(
eligibilities.map((e) =>
prisma.awardEligibility.upsert({
@@ -200,26 +200,54 @@ export async function processEligibilityJob(
aiReasoningJson: e.aiReasoningJson ?? undefined,
},
update: {
eligible: e.eligible,
method: e.method as 'AUTO' | 'MANUAL',
// Only update AI-computed fields; DO NOT reset overriddenBy,
// overriddenAt, shortlisted, confirmedAt, confirmedBy — those
// reflect admin decisions that must survive re-runs.
qualityScore: e.qualityScore,
aiReasoningJson: e.aiReasoningJson ?? undefined,
overriddenBy: null,
overriddenAt: null,
shortlisted: false,
confirmedAt: null,
confirmedBy: null,
},
})
)
)
// For records without manual override, sync the eligible/method fields
const nonOverridden = await prisma.awardEligibility.findMany({
where: { awardId, overriddenBy: null },
select: { projectId: true },
})
const nonOverriddenIds = new Set(nonOverridden.map((r) => r.projectId))
if (nonOverriddenIds.size > 0) {
await prisma.$transaction(
eligibilities
.filter((e) => nonOverriddenIds.has(e.projectId))
.map((e) =>
prisma.awardEligibility.update({
where: {
awardId_projectId: { awardId, projectId: e.projectId },
},
data: {
eligible: e.eligible,
method: e.method as 'AUTO' | 'MANUAL',
},
})
)
)
}
// Auto-shortlist top N eligible projects by qualityScore
// Only auto-shortlist records that aren't already manually shortlisted
const shortlistSize = award.shortlistSize ?? 10
const alreadyShortlisted = await prisma.awardEligibility.findMany({
where: { awardId, shortlisted: true, overriddenBy: { not: null } },
select: { projectId: true },
})
const manuallyShortlistedIds = new Set(alreadyShortlisted.map((r) => r.projectId))
const topEligible = eligibilities
.filter((e) => e.eligible && e.qualityScore != null)
.filter((e) => e.eligible && e.qualityScore != null && !manuallyShortlistedIds.has(e.projectId))
.sort((a, b) => (b.qualityScore ?? 0) - (a.qualityScore ?? 0))
.slice(0, shortlistSize)
.slice(0, Math.max(0, shortlistSize - manuallyShortlistedIds.size))
if (topEligible.length > 0) {
await prisma.$transaction(

View File

@@ -66,9 +66,9 @@ export async function createSession(
showPriorJuryData?: boolean
participantUserIds: string[] // JuryGroupMember IDs
},
prisma: PrismaClient | any,
prisma: PrismaClient,
) {
return prisma.$transaction(async (tx: any) => {
return prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const session = await tx.deliberationSession.create({
data: {
competitionId: params.competitionId,
@@ -120,7 +120,7 @@ export async function createSession(
export async function openVoting(
sessionId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<SessionTransitionResult> {
return transitionSession(sessionId, 'DELIB_OPEN', 'VOTING', actorId, prisma)
}
@@ -132,7 +132,7 @@ export async function openVoting(
export async function closeVoting(
sessionId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<SessionTransitionResult> {
return transitionSession(sessionId, 'VOTING', 'TALLYING', actorId, prisma)
}
@@ -152,7 +152,7 @@ export async function submitVote(
isWinnerPick?: boolean
runoffRound?: number
},
prisma: PrismaClient | any,
prisma: PrismaClient,
) {
const session = await prisma.deliberationSession.findUnique({
where: { id: params.sessionId },
@@ -219,7 +219,7 @@ export async function submitVote(
*/
export async function aggregateVotes(
sessionId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<AggregationResult> {
const session = await prisma.deliberationSession.findUnique({
where: { id: sessionId },
@@ -313,7 +313,7 @@ export async function initRunoff(
sessionId: string,
tiedProjectIds: string[],
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<SessionTransitionResult> {
const session = await prisma.deliberationSession.findUnique({
where: { id: sessionId },
@@ -339,7 +339,7 @@ export async function initRunoff(
return { success: false, errors: [`Maximum runoff rounds (${MAX_RUNOFF_ROUNDS}) exceeded`] }
}
return prisma.$transaction(async (tx: any) => {
return prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const updated = await tx.deliberationSession.update({
where: { id: sessionId },
data: { status: 'RUNOFF' },
@@ -374,7 +374,7 @@ export async function adminDecide(
rankings: Array<{ projectId: string; rank: number }>,
reason: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<SessionTransitionResult> {
const session = await prisma.deliberationSession.findUnique({
where: { id: sessionId },
@@ -388,7 +388,7 @@ export async function adminDecide(
return { success: false, errors: [`Cannot admin-decide: status is ${session.status}`] }
}
return prisma.$transaction(async (tx: any) => {
return prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const updated = await tx.deliberationSession.update({
where: { id: sessionId },
data: {
@@ -431,7 +431,7 @@ export async function adminDecide(
export async function finalizeResults(
sessionId: string,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<SessionTransitionResult> {
const session = await prisma.deliberationSession.findUnique({
where: { id: sessionId },
@@ -470,7 +470,7 @@ export async function finalizeResults(
}))
}
return prisma.$transaction(async (tx: any) => {
return prisma.$transaction(async (tx: Prisma.TransactionClient) => {
// Create result records
for (const ranking of finalRankings) {
await tx.deliberationResult.upsert({
@@ -487,7 +487,7 @@ export async function finalizeResults(
voteCount: ranking.voteCount,
isAdminOverridden: ranking.isAdminOverridden,
overrideReason: ranking.isAdminOverridden
? (session.adminOverrideResult as any)?.reason ?? null
? (session.adminOverrideResult as Record<string, unknown> | null)?.reason as string ?? null
: null,
},
update: {
@@ -549,11 +549,11 @@ export async function updateParticipantStatus(
status: DeliberationParticipantStatus,
replacedById?: string,
actorId?: string,
prisma?: PrismaClient | any,
prisma?: PrismaClient,
) {
const db = prisma ?? (await import('@/lib/prisma')).prisma
return db.$transaction(async (tx: any) => {
return db.$transaction(async (tx: Prisma.TransactionClient) => {
const updated = await tx.deliberationParticipant.update({
where: { sessionId_userId: { sessionId, userId } },
data: {
@@ -601,7 +601,7 @@ export async function updateParticipantStatus(
*/
export async function getSessionWithVotes(
sessionId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
) {
return prisma.deliberationSession.findUnique({
where: { id: sessionId },
@@ -645,7 +645,7 @@ async function transitionSession(
expectedStatus: DeliberationStatus,
newStatus: DeliberationStatus,
actorId: string,
prisma: PrismaClient | any,
prisma: PrismaClient,
): Promise<SessionTransitionResult> {
try {
const session = await prisma.deliberationSession.findUnique({
@@ -671,7 +671,7 @@ async function transitionSession(
}
}
const updated = await prisma.$transaction(async (tx: any) => {
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.deliberationSession.update({
where: { id: sessionId },
data: { status: newStatus },