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