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

@@ -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) {