- IDOR fix: deliberation vote now verifies juryMemberId === ctx.user.id - Rate limiting: tRPC middleware (100/min), AI endpoints (5/hr), auth IP-based (10/15min) - 6 compound indexes added to Prisma schema - N+1 eliminated in processRoundClose (batch updateMany/createMany) - N+1 eliminated in batchCheckRequirementsAndTransition (3 batch queries) - Service extraction: juror-reassignment.ts (578 lines) - Dead code removed: award.ts, cohort.ts, decision.ts (680 lines) - 35 bare catch blocks replaced across 16 files - Fire-and-forget async calls fixed - Notification false positive bug fixed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
114 lines
4.0 KiB
TypeScript
114 lines
4.0 KiB
TypeScript
import { z } from 'zod'
|
|
import { router, adminProcedure } from '../trpc'
|
|
import { resolveMemberContext } from '@/server/services/competition-context'
|
|
import { evaluateAssignmentPolicy } from '@/server/services/assignment-policy'
|
|
|
|
export const assignmentPolicyRouter = router({
|
|
/**
|
|
* Get the fully-resolved assignment policy for a specific member in a round.
|
|
* Returns cap, cap mode, buffer, category bias — all with provenance.
|
|
*/
|
|
getMemberPolicy: adminProcedure
|
|
.input(z.object({ roundId: z.string(), userId: z.string() }))
|
|
.query(async ({ input }) => {
|
|
const ctx = await resolveMemberContext(input.roundId, input.userId)
|
|
return evaluateAssignmentPolicy(ctx)
|
|
}),
|
|
|
|
/**
|
|
* Get policy summary for all members in a jury group for a given round.
|
|
* Useful for admin dashboards showing cap compliance across the group.
|
|
*/
|
|
getGroupPolicySummary: adminProcedure
|
|
.input(z.object({ juryGroupId: z.string(), roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const members = await ctx.prisma.juryGroupMember.findMany({
|
|
where: { juryGroupId: input.juryGroupId },
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
},
|
|
})
|
|
|
|
const results = await Promise.all(
|
|
members.map(async (member) => {
|
|
try {
|
|
const memberCtx = await resolveMemberContext(input.roundId, member.userId)
|
|
const policy = evaluateAssignmentPolicy(memberCtx)
|
|
return {
|
|
userId: member.userId,
|
|
userName: member.user.name,
|
|
userEmail: member.user.email,
|
|
role: member.role,
|
|
policy,
|
|
}
|
|
} catch (err) {
|
|
console.error('[AssignmentPolicy] Failed to resolve member context:', err)
|
|
return null
|
|
}
|
|
}),
|
|
)
|
|
|
|
return results.filter(Boolean)
|
|
}),
|
|
|
|
/**
|
|
* Get cap compliance report for a round.
|
|
* Groups members into overCap, atCap, belowCap, and noCap buckets.
|
|
*/
|
|
getCapComplianceReport: adminProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { juryGroupId: true },
|
|
})
|
|
|
|
if (!round.juryGroupId) {
|
|
return { overCap: [], atCap: [], belowCap: [], noCap: [] }
|
|
}
|
|
|
|
const members = await ctx.prisma.juryGroupMember.findMany({
|
|
where: { juryGroupId: round.juryGroupId },
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
},
|
|
})
|
|
|
|
const report: {
|
|
overCap: Array<{ userId: string; userName: string | null; overCapBy: number }>
|
|
atCap: Array<{ userId: string; userName: string | null }>
|
|
belowCap: Array<{ userId: string; userName: string | null; remaining: number }>
|
|
noCap: Array<{ userId: string; userName: string | null }>
|
|
} = { overCap: [], atCap: [], belowCap: [], noCap: [] }
|
|
|
|
for (const member of members) {
|
|
try {
|
|
const memberCtx = await resolveMemberContext(input.roundId, member.userId)
|
|
const policy = evaluateAssignmentPolicy(memberCtx)
|
|
|
|
if (policy.effectiveCapMode.value === 'NONE') {
|
|
report.noCap.push({ userId: member.userId, userName: member.user.name })
|
|
} else if (policy.isOverCap) {
|
|
report.overCap.push({
|
|
userId: member.userId,
|
|
userName: member.user.name,
|
|
overCapBy: policy.overCapBy,
|
|
})
|
|
} else if (policy.remainingCapacity === 0) {
|
|
report.atCap.push({ userId: member.userId, userName: member.user.name })
|
|
} else {
|
|
report.belowCap.push({
|
|
userId: member.userId,
|
|
userName: member.user.name,
|
|
remaining: policy.remainingCapacity,
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.error('[AssignmentPolicy] Failed to evaluate policy for member:', err)
|
|
}
|
|
}
|
|
|
|
return report
|
|
}),
|
|
})
|