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
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:
@@ -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,
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user