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

@@ -408,64 +408,76 @@ 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 } } },
}),
ctx.prisma.assignment.count({ where: { roundId } }),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId },
status: 'SUBMITTED',
},
}),
])
// 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.groupBy({
by: ['roundId'],
where: { roundId: { in: roundIds } },
_count: true,
}),
ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: { in: roundIds } },
status: 'SUBMITTED',
},
select: { globalScore: true, assignment: { select: { roundId: true } } },
}),
])
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
const roundMap = new Map(rounds.map((r) => [r.id, r.name]))
const assignmentCountMap = new Map(assignments.map((a) => [a.roundId, a._count]))
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId },
status: 'SUBMITTED',
},
select: { globalScore: true },
})
// 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)
}
const globalScores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
// 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)
}
const averageScore = globalScores.length > 0
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: null
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 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,
}))
const distribution = Array.from({ length: 10 }, (_, i) => ({
score: i + 1,
count: globalScores.filter((s) => Math.round(s) === i + 1).length,
}))
return {
roundId,
roundName: round.name,
projectCount,
evaluationCount,
completionRate,
averageScore,
scoreDistribution: distribution,
}
})
)
return comparisons
return {
roundId,
roundName: roundMap.get(roundId) ?? roundId,
projectCount: projectsByRound.get(roundId)?.size ?? 0,
evaluationCount,
completionRate,
averageScore,
scoreDistribution: distribution,
}
})
}),
/**
@@ -620,55 +632,72 @@ 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 } } },
}),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.count({ where: { roundId: round.id } }),
])
if (roundIds.length === 0) return []
const completionRate = assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
// 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.findMany({
where: {
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'],
}),
])
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
select: { globalScore: true },
})
const assignmentCountMap = new Map(assignmentCounts.map((a) => [a.roundId, a._count]))
const scores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
// 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)
}
}
const averageScore = scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null
// 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 {
roundId: round.id,
roundName: round.name,
createdAt: round.createdAt,
projectCount,
evaluationCount,
completionRate,
averageScore,
}
})
)
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 averageScore = scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null
return stats
return {
roundId: round.id,
roundName: round.name,
createdAt: round.createdAt,
projectCount: projectsByRound.get(round.id) ?? 0,
evaluationCount,
completionRate,
averageScore,
}
})
}),
/**

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,29 +696,30 @@ 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: {
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)
}
assignmentData.push({
userId: user.id,
projectId: assignment.projectId,
roundId: assignment.roundId,
method: 'MANUAL',
createdBy: ctx.user.id,
})
}
}
}
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
if (assignmentsCreated > 0) {
@@ -733,65 +734,104 @@ 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: {
juryGroupId: groupId,
userId: user.id,
role: groupInfo.role,
},
})
juryGroupMembershipsCreated++
} catch {
// Skip if membership already exists
}
juryGroupMemberData.push({
juryGroupId: groupId,
userId: user.id,
role: groupInfo.role,
})
}
}
}
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 },
})
if (round?.juryGroupId) {
const member = await ctx.prisma.juryGroupMember.findUnique({
where: {
juryGroupId_userId: {
juryGroupId: round.juryGroupId,
userId: user.id,
},
},
})
if (member) {
await ctx.prisma.assignmentIntent.create({
data: {
juryGroupMemberId: member.id,
roundId: intent.roundId,
projectId: intent.projectId,
source: 'INVITE',
status: 'INTENT_PENDING',
},
})
assignmentIntentsCreated++
}
}
} catch {
// Skip duplicate intents
}
// Create AssignmentIntents for users who have them
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 },
})
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: {
OR: memberLookups.map((l) => ({
juryGroupId: l.juryGroupId,
userId: l.userId,
})),
},
select: { id: true, juryGroupId: true, userId: true },
})
: []
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',
})
}
}
if (intentData.length > 0) {
const result = await ctx.prisma.assignmentIntent.createMany({
data: intentData,
skipDuplicates: true,
})
assignmentIntentsCreated = result.count
}
}
if (juryGroupMembershipsCreated > 0) {

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 },