Add jury assignment transfer, cap redistribution, and learning hub overhaul
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m19s

- Add getTransferCandidates/transferAssignments procedures for targeted
  assignment moves between jurors with TOCTOU guards and audit logging
- Add getOverCapPreview/redistributeOverCap for auto-redistributing
  assignments when a juror's cap is lowered below their current load
- Add TransferAssignmentsDialog (2-step: select projects, pick destinations)
- Extend InlineMemberCap with over-cap detection and redistribute banner
- Extend getReassignmentHistory to show ASSIGNMENT_TRANSFER and CAP_REDISTRIBUTE events
- Learning hub: replace ResourceType/CohortLevel enums with accessJson JSONB,
  add coverImageKey, resource detail pages for jury/mentor, shared renderer
- Migration: 20260221200000_learning_hub_overhaul

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 18:50:29 +01:00
parent f42b452899
commit ee2f10e080
16 changed files with 2643 additions and 945 deletions

View File

@@ -2069,6 +2069,762 @@ export const assignmentRouter = router({
})
}),
/**
* Get transfer candidates: which of the source juror's assignments can be moved,
* and which other jurors are eligible to receive them.
*/
getTransferCandidates: adminProcedure
.input(z.object({
roundId: z.string(),
sourceJurorId: z.string(),
assignmentIds: z.array(z.string()),
}))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, name: true, configJson: true, juryGroupId: true },
})
const config = (round.configJson ?? {}) as Record<string, unknown>
const fallbackCap =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Fetch requested assignments — must belong to source juror
const requestedAssignments = await ctx.prisma.assignment.findMany({
where: {
id: { in: input.assignmentIds },
roundId: input.roundId,
userId: input.sourceJurorId,
},
select: {
id: true,
projectId: true,
project: { select: { title: true } },
evaluation: { select: { status: true } },
},
})
// Filter to movable only
const assignments = requestedAssignments.map((a) => ({
id: a.id,
projectId: a.projectId,
projectTitle: a.project.title,
evalStatus: a.evaluation?.status ?? null,
movable: !a.evaluation || MOVABLE_EVAL_STATUSES.includes(a.evaluation.status as typeof MOVABLE_EVAL_STATUSES[number]),
}))
const movableProjectIds = assignments
.filter((a) => a.movable)
.map((a) => a.projectId)
// Build candidate juror pool — same pattern as reassignDroppedJurorAssignments
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
if (round.juryGroupId) {
const members = await ctx.prisma.juryGroupMember.findMany({
where: { juryGroupId: round.juryGroupId },
include: {
user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } },
},
})
candidateJurors = members
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== input.sourceJurorId)
.map((m) => m.user)
} else {
const roundJurorIds = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true },
distinct: ['userId'],
})
const activeRoundJurorIds = roundJurorIds
.map((a) => a.userId)
.filter((id) => id !== input.sourceJurorId)
candidateJurors = activeRoundJurorIds.length > 0
? await ctx.prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER',
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },
})
: []
}
const candidateIds = candidateJurors.map((j) => j.id)
// Existing assignments, loads, COI pairs
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const currentLoads = new Map<string, number>()
for (const a of existingAssignments) {
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
}
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
// Completed evaluations count per candidate
const completedEvals = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId, userId: { in: candidateIds } },
status: 'SUBMITTED',
},
select: { assignment: { select: { userId: true } } },
})
const completedCounts = new Map<string, number>()
for (const e of completedEvals) {
const uid = e.assignment.userId
completedCounts.set(uid, (completedCounts.get(uid) ?? 0) + 1)
}
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
where: {
roundId: input.roundId,
hasConflict: true,
userId: { in: candidateIds },
},
select: { userId: true, projectId: true },
})
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
// Build candidate list with eligibility per project
const candidates = candidateJurors.map((j) => {
const load = currentLoads.get(j.id) ?? 0
const cap = j.maxAssignments ?? fallbackCap
const completed = completedCounts.get(j.id) ?? 0
const allCompleted = load > 0 && completed === load
const eligibleProjectIds = movableProjectIds.filter((pid) =>
!alreadyAssigned.has(`${j.id}:${pid}`) &&
!coiPairs.has(`${j.id}:${pid}`) &&
load < cap
)
return {
userId: j.id,
name: j.name || j.email,
email: j.email,
currentLoad: load,
cap,
allCompleted,
eligibleProjectIds,
}
})
// Sort: not-all-done first, then by lowest load
candidates.sort((a, b) => {
if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1
return a.currentLoad - b.currentLoad
})
return { assignments, candidates }
}),
/**
* Transfer specific assignments from one juror to destination jurors.
*/
transferAssignments: adminProcedure
.input(z.object({
roundId: z.string(),
sourceJurorId: z.string(),
transfers: z.array(z.object({
assignmentId: z.string(),
destinationJurorId: z.string(),
})),
forceOverCap: z.boolean().default(false),
}))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, name: true, configJson: true, juryGroupId: true },
})
const config = (round.configJson ?? {}) as Record<string, unknown>
const fallbackCap =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Verify all assignments belong to source juror and are movable
const assignmentIds = input.transfers.map((t) => t.assignmentId)
const sourceAssignments = await ctx.prisma.assignment.findMany({
where: {
id: { in: assignmentIds },
roundId: input.roundId,
userId: input.sourceJurorId,
OR: [
{ evaluation: null },
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
],
},
select: {
id: true,
projectId: true,
juryGroupId: true,
isRequired: true,
project: { select: { title: true } },
},
})
const sourceMap = new Map(sourceAssignments.map((a) => [a.id, a]))
// Build candidate pool data
const destinationIds = [...new Set(input.transfers.map((t) => t.destinationJurorId))]
const destinationUsers = await ctx.prisma.user.findMany({
where: { id: { in: destinationIds } },
select: { id: true, name: true, email: true, maxAssignments: true },
})
const destUserMap = new Map(destinationUsers.map((u) => [u.id, u]))
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
const currentLoads = new Map<string, number>()
for (const a of existingAssignments) {
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
}
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
where: {
roundId: input.roundId,
hasConflict: true,
userId: { in: destinationIds },
},
select: { userId: true, projectId: true },
})
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
// Validate each transfer
type PlannedMove = {
assignmentId: string
projectId: string
projectTitle: string
destinationJurorId: string
juryGroupId: string | null
isRequired: boolean
}
const plannedMoves: PlannedMove[] = []
const failed: { assignmentId: string; reason: string }[] = []
for (const transfer of input.transfers) {
const assignment = sourceMap.get(transfer.assignmentId)
if (!assignment) {
failed.push({ assignmentId: transfer.assignmentId, reason: 'Assignment not found or not movable' })
continue
}
const destUser = destUserMap.get(transfer.destinationJurorId)
if (!destUser) {
failed.push({ assignmentId: transfer.assignmentId, reason: 'Destination juror not found' })
continue
}
if (alreadyAssigned.has(`${transfer.destinationJurorId}:${assignment.projectId}`)) {
failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} is already assigned to this project` })
continue
}
if (coiPairs.has(`${transfer.destinationJurorId}:${assignment.projectId}`)) {
failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} has a COI with this project` })
continue
}
const destCap = destUser.maxAssignments ?? fallbackCap
const destLoad = currentLoads.get(transfer.destinationJurorId) ?? 0
if (destLoad >= destCap && !input.forceOverCap) {
failed.push({ assignmentId: transfer.assignmentId, reason: `${destUser.name || destUser.email} is at cap (${destLoad}/${destCap})` })
continue
}
plannedMoves.push({
assignmentId: assignment.id,
projectId: assignment.projectId,
projectTitle: assignment.project.title,
destinationJurorId: transfer.destinationJurorId,
juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
isRequired: assignment.isRequired,
})
// Track updated load for subsequent transfers to same destination
alreadyAssigned.add(`${transfer.destinationJurorId}:${assignment.projectId}`)
currentLoads.set(transfer.destinationJurorId, destLoad + 1)
}
// Execute in transaction with TOCTOU guard
const actualMoves: (PlannedMove & { newAssignmentId: string })[] = []
if (plannedMoves.length > 0) {
await ctx.prisma.$transaction(async (tx) => {
for (const move of plannedMoves) {
const deleted = await tx.assignment.deleteMany({
where: {
id: move.assignmentId,
userId: input.sourceJurorId,
OR: [
{ evaluation: null },
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
],
},
})
if (deleted.count === 0) {
failed.push({ assignmentId: move.assignmentId, reason: 'Assignment was modified concurrently' })
continue
}
const created = await tx.assignment.create({
data: {
roundId: input.roundId,
projectId: move.projectId,
userId: move.destinationJurorId,
juryGroupId: move.juryGroupId ?? undefined,
isRequired: move.isRequired,
method: 'MANUAL',
createdBy: ctx.user.id,
},
})
actualMoves.push({ ...move, newAssignmentId: created.id })
}
})
}
// Notify destination jurors
if (actualMoves.length > 0) {
const destCounts: Record<string, number> = {}
for (const move of actualMoves) {
destCounts[move.destinationJurorId] = (destCounts[move.destinationJurorId] ?? 0) + 1
}
await createBulkNotifications({
userIds: Object.keys(destCounts),
type: NotificationTypes.BATCH_ASSIGNED,
title: 'Additional Projects Assigned',
message: `You have received additional project assignments via transfer in ${round.name}.`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments',
metadata: { roundId: round.id, reason: 'assignment_transfer' },
})
// Notify admins
const sourceJuror = await ctx.prisma.user.findUnique({
where: { id: input.sourceJurorId },
select: { name: true, email: true },
})
const sourceName = sourceJuror?.name || sourceJuror?.email || 'Unknown'
const topReceivers = Object.entries(destCounts)
.map(([jurorId, count]) => {
const u = destUserMap.get(jurorId)
return `${u?.name || u?.email || jurorId} (${count})`
})
.join(', ')
await notifyAdmins({
type: NotificationTypes.EVALUATION_MILESTONE,
title: 'Assignment Transfer',
message: `Transferred ${actualMoves.length} project(s) from ${sourceName} to: ${topReceivers}.${failed.length > 0 ? ` ${failed.length} transfer(s) failed.` : ''}`,
linkUrl: `/admin/rounds/${round.id}`,
linkLabel: 'View Round',
metadata: {
roundId: round.id,
sourceJurorId: input.sourceJurorId,
movedCount: actualMoves.length,
failedCount: failed.length,
},
})
// Audit
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ASSIGNMENT_TRANSFER',
entityType: 'Round',
entityId: round.id,
detailsJson: {
sourceJurorId: input.sourceJurorId,
sourceJurorName: sourceName,
movedCount: actualMoves.length,
failedCount: failed.length,
moves: actualMoves.map((m) => ({
projectId: m.projectId,
projectTitle: m.projectTitle,
newJurorId: m.destinationJurorId,
newJurorName: destUserMap.get(m.destinationJurorId)?.name || destUserMap.get(m.destinationJurorId)?.email || m.destinationJurorId,
})),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}
return {
succeeded: actualMoves.map((m) => ({
assignmentId: m.assignmentId,
projectId: m.projectId,
destinationJurorId: m.destinationJurorId,
})),
failed,
}
}),
/**
* Preview the impact of lowering a juror's cap below their current load.
*/
getOverCapPreview: adminProcedure
.input(z.object({
roundId: z.string(),
jurorId: z.string(),
newCap: z.number().int().min(1),
}))
.query(async ({ ctx, input }) => {
const total = await ctx.prisma.assignment.count({
where: { roundId: input.roundId, userId: input.jurorId },
})
const immovableCount = await ctx.prisma.assignment.count({
where: {
roundId: input.roundId,
userId: input.jurorId,
evaluation: { status: { notIn: [...MOVABLE_EVAL_STATUSES] } },
},
})
const movableCount = total - immovableCount
const overCapCount = Math.max(0, total - input.newCap)
return {
total,
overCapCount,
movableOverCap: Math.min(overCapCount, movableCount),
immovableOverCap: Math.max(0, overCapCount - movableCount),
}
}),
/**
* Redistribute over-cap assignments after lowering a juror's cap.
* Moves the newest/least-progressed movable assignments to other eligible jurors.
*/
redistributeOverCap: adminProcedure
.input(z.object({
roundId: z.string(),
jurorId: z.string(),
newCap: z.number().int().min(1),
}))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, name: true, configJson: true, juryGroupId: true },
})
const config = (round.configJson ?? {}) as Record<string, unknown>
const fallbackCap =
(config.maxLoadPerJuror as number) ??
(config.maxAssignmentsPerJuror as number) ??
20
// Get juror's assignments sorted: null eval first, then DRAFT, newest first
const jurorAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId, userId: input.jurorId },
select: {
id: true,
projectId: true,
juryGroupId: true,
isRequired: true,
createdAt: true,
project: { select: { title: true } },
evaluation: { select: { status: true } },
},
orderBy: { createdAt: 'desc' },
})
const overCapCount = Math.max(0, jurorAssignments.length - input.newCap)
if (overCapCount === 0) {
return { redistributed: 0, failed: 0, failedProjects: [] as string[], moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[] }
}
// Separate movable and immovable, pick the newest movable ones for redistribution
const movable = jurorAssignments.filter(
(a) => !a.evaluation || MOVABLE_EVAL_STATUSES.includes(a.evaluation.status as typeof MOVABLE_EVAL_STATUSES[number])
)
// Sort movable: null eval first, then DRAFT, then by createdAt descending (newest first to remove)
movable.sort((a, b) => {
const statusOrder = (s: string | null) => s === null ? 0 : s === 'NOT_STARTED' ? 1 : s === 'DRAFT' ? 2 : 3
const diff = statusOrder(a.evaluation?.status ?? null) - statusOrder(b.evaluation?.status ?? null)
if (diff !== 0) return diff
return b.createdAt.getTime() - a.createdAt.getTime()
})
const assignmentsToMove = movable.slice(0, overCapCount)
if (assignmentsToMove.length === 0) {
return { redistributed: 0, failed: 0, failedProjects: [] as string[], moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[] }
}
// Build candidate pool — same pattern as reassignDroppedJurorAssignments
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
if (round.juryGroupId) {
const members = await ctx.prisma.juryGroupMember.findMany({
where: { juryGroupId: round.juryGroupId },
include: {
user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } },
},
})
candidateJurors = members
.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== input.jurorId)
.map((m) => m.user)
} else {
const roundJurorIds = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true },
distinct: ['userId'],
})
const activeRoundJurorIds = roundJurorIds
.map((a) => a.userId)
.filter((id) => id !== input.jurorId)
candidateJurors = activeRoundJurorIds.length > 0
? await ctx.prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER',
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },
})
: []
}
if (candidateJurors.length === 0) {
return {
redistributed: 0,
failed: assignmentsToMove.length,
failedProjects: assignmentsToMove.map((a) => a.project.title),
moves: [] as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[],
}
}
const candidateIds = candidateJurors.map((j) => j.id)
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
const currentLoads = new Map<string, number>()
for (const a of existingAssignments) {
currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
}
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
where: {
roundId: input.roundId,
hasConflict: true,
userId: { in: candidateIds },
},
select: { userId: true, projectId: true },
})
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
const caps = new Map<string, number>()
for (const juror of candidateJurors) {
caps.set(juror.id, juror.maxAssignments ?? fallbackCap)
}
// Check which candidates have completed all their evaluations
const completedEvals = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId, userId: { in: candidateIds } },
status: 'SUBMITTED',
},
select: { assignment: { select: { userId: true } } },
})
const completedCounts = new Map<string, number>()
for (const e of completedEvals) {
completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1)
}
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
type PlannedMove = {
assignmentId: string
projectId: string
projectTitle: string
newJurorId: string
juryGroupId: string | null
isRequired: boolean
}
const plannedMoves: PlannedMove[] = []
const failedProjects: string[] = []
for (const assignment of assignmentsToMove) {
const eligible = candidateIds
.filter((jurorId) => !alreadyAssigned.has(`${jurorId}:${assignment.projectId}`))
.filter((jurorId) => !coiPairs.has(`${jurorId}:${assignment.projectId}`))
.filter((jurorId) => (currentLoads.get(jurorId) ?? 0) < (caps.get(jurorId) ?? fallbackCap))
.sort((a, b) => {
// Prefer jurors who haven't completed all their work
const aLoad = currentLoads.get(a) ?? 0
const bLoad = currentLoads.get(b) ?? 0
const aComplete = aLoad > 0 && (completedCounts.get(a) ?? 0) === aLoad
const bComplete = bLoad > 0 && (completedCounts.get(b) ?? 0) === bLoad
if (aComplete !== bComplete) return aComplete ? 1 : -1
const loadDiff = aLoad - bLoad
if (loadDiff !== 0) return loadDiff
return a.localeCompare(b)
})
if (eligible.length === 0) {
failedProjects.push(assignment.project.title)
continue
}
const selectedJurorId = eligible[0]
plannedMoves.push({
assignmentId: assignment.id,
projectId: assignment.projectId,
projectTitle: assignment.project.title,
newJurorId: selectedJurorId,
juryGroupId: assignment.juryGroupId ?? round.juryGroupId,
isRequired: assignment.isRequired,
})
alreadyAssigned.add(`${selectedJurorId}:${assignment.projectId}`)
currentLoads.set(selectedJurorId, (currentLoads.get(selectedJurorId) ?? 0) + 1)
}
// Execute in transaction with TOCTOU guard
const actualMoves: PlannedMove[] = []
if (plannedMoves.length > 0) {
await ctx.prisma.$transaction(async (tx) => {
for (const move of plannedMoves) {
const deleted = await tx.assignment.deleteMany({
where: {
id: move.assignmentId,
userId: input.jurorId,
OR: [
{ evaluation: null },
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
],
},
})
if (deleted.count === 0) {
failedProjects.push(move.projectTitle)
continue
}
await tx.assignment.create({
data: {
roundId: input.roundId,
projectId: move.projectId,
userId: move.newJurorId,
juryGroupId: move.juryGroupId ?? undefined,
isRequired: move.isRequired,
method: 'MANUAL',
createdBy: ctx.user.id,
},
})
actualMoves.push(move)
}
})
}
// Notify destination jurors
if (actualMoves.length > 0) {
const destCounts: Record<string, number> = {}
for (const move of actualMoves) {
destCounts[move.newJurorId] = (destCounts[move.newJurorId] ?? 0) + 1
}
await createBulkNotifications({
userIds: Object.keys(destCounts),
type: NotificationTypes.BATCH_ASSIGNED,
title: 'Additional Projects Assigned',
message: `You have received additional project assignments due to a cap adjustment in ${round.name}.`,
linkUrl: `/jury/competitions`,
linkLabel: 'View Assignments',
metadata: { roundId: round.id, reason: 'cap_redistribute' },
})
const juror = await ctx.prisma.user.findUnique({
where: { id: input.jurorId },
select: { name: true, email: true },
})
const jurorName = juror?.name || juror?.email || 'Unknown'
const topReceivers = Object.entries(destCounts)
.map(([jurorId, count]) => {
const u = candidateMeta.get(jurorId)
return `${u?.name || u?.email || jurorId} (${count})`
})
.join(', ')
await notifyAdmins({
type: NotificationTypes.EVALUATION_MILESTONE,
title: 'Cap Redistribution',
message: `Redistributed ${actualMoves.length} project(s) from ${jurorName} (cap lowered to ${input.newCap}) to: ${topReceivers}.${failedProjects.length > 0 ? ` ${failedProjects.length} project(s) could not be reassigned.` : ''}`,
linkUrl: `/admin/rounds/${round.id}`,
linkLabel: 'View Round',
metadata: {
roundId: round.id,
jurorId: input.jurorId,
newCap: input.newCap,
movedCount: actualMoves.length,
failedCount: failedProjects.length,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CAP_REDISTRIBUTE',
entityType: 'Round',
entityId: round.id,
detailsJson: {
jurorId: input.jurorId,
jurorName,
newCap: input.newCap,
movedCount: actualMoves.length,
failedCount: failedProjects.length,
failedProjects,
moves: actualMoves.map((m) => ({
projectId: m.projectId,
projectTitle: m.projectTitle,
newJurorId: m.newJurorId,
newJurorName: candidateMeta.get(m.newJurorId)?.name || candidateMeta.get(m.newJurorId)?.email || m.newJurorId,
})),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}
return {
redistributed: actualMoves.length,
failed: failedProjects.length,
failedProjects,
moves: actualMoves.map((m) => ({
projectId: m.projectId,
projectTitle: m.projectTitle,
newJurorId: m.newJurorId,
newJurorName: candidateMeta.get(m.newJurorId)?.name || candidateMeta.get(m.newJurorId)?.email || m.newJurorId,
})),
}
}),
/**
* Get reshuffle history for a round — shows all dropout/COI reassignment events
* with per-project detail of where each project was moved to.
@@ -2080,7 +2836,7 @@ export const assignmentRouter = router({
const auditEntries = await ctx.prisma.auditLog.findMany({
where: {
entityType: { in: ['Round', 'Assignment'] },
action: { in: ['JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT'] },
action: { in: ['JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT', 'ASSIGNMENT_TRANSFER', 'CAP_REDISTRIBUTE'] },
entityId: input.roundId,
},
orderBy: { timestamp: 'desc' },
@@ -2124,7 +2880,7 @@ export const assignmentRouter = router({
type ReshuffleEvent = {
id: string
type: 'DROPOUT' | 'COI'
type: 'DROPOUT' | 'COI' | 'TRANSFER' | 'CAP_REDISTRIBUTE'
timestamp: Date
performedBy: { name: string | null; email: string }
droppedJuror: { id: string; name: string }
@@ -2179,6 +2935,44 @@ export const assignmentRouter = router({
failedProjects: (details.failedProjects as string[]) || [],
moves: reconstructedMoves,
})
} else if (entry.action === 'ASSIGNMENT_TRANSFER') {
const moves = (details.moves as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]) || []
events.push({
id: entry.id,
type: 'TRANSFER',
timestamp: entry.timestamp,
performedBy: {
name: entry.user?.name ?? null,
email: entry.user?.email ?? '',
},
droppedJuror: {
id: (details.sourceJurorId as string) || '',
name: (details.sourceJurorName as string) || 'Unknown',
},
movedCount: (details.movedCount as number) || 0,
failedCount: (details.failedCount as number) || 0,
failedProjects: (details.failedProjects as string[]) || [],
moves,
})
} else if (entry.action === 'CAP_REDISTRIBUTE') {
const moves = (details.moves as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]) || []
events.push({
id: entry.id,
type: 'CAP_REDISTRIBUTE',
timestamp: entry.timestamp,
performedBy: {
name: entry.user?.name ?? null,
email: entry.user?.email ?? '',
},
droppedJuror: {
id: (details.jurorId as string) || '',
name: (details.jurorName as string) || 'Unknown',
},
movedCount: (details.movedCount as number) || 0,
failedCount: (details.failedCount as number) || 0,
failedProjects: (details.failedProjects as string[]) || [],
moves,
})
}
}

View File

@@ -1,5 +1,6 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import {
router,
protectedProcedure,
@@ -11,6 +12,69 @@ import { logAudit } from '../utils/audit'
// Bucket for learning resources
export const LEARNING_BUCKET = 'mopc-learning'
// Access rule schema for fine-grained access control
const accessRuleSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('everyone') }),
z.object({ type: z.literal('roles'), roles: z.array(z.string()) }),
z.object({ type: z.literal('jury_group'), juryGroupIds: z.array(z.string()) }),
z.object({ type: z.literal('round'), roundIds: z.array(z.string()) }),
])
type AccessRule = z.infer<typeof accessRuleSchema>
/**
* Evaluate whether a user can access a resource based on its accessJson rules.
* null/empty = everyone. Rules are OR-combined (match ANY rule = access).
*/
async function canUserAccessResource(
prisma: { juryGroupMember: { findFirst: Function }; assignment: { findFirst: Function } },
userId: string,
userRole: string,
accessJson: unknown,
): Promise<boolean> {
// null or empty = everyone
if (!accessJson) return true
let rules: AccessRule[]
try {
const parsed = accessJson as unknown[]
if (!Array.isArray(parsed) || parsed.length === 0) return true
rules = parsed as AccessRule[]
} catch {
return true
}
for (const rule of rules) {
if (rule.type === 'everyone') return true
if (rule.type === 'roles') {
if (rule.roles.includes(userRole)) return true
}
if (rule.type === 'jury_group') {
const membership = await prisma.juryGroupMember.findFirst({
where: {
userId,
juryGroupId: { in: rule.juryGroupIds },
},
})
if (membership) return true
}
if (rule.type === 'round') {
const assignment = await prisma.assignment.findFirst({
where: {
userId,
roundId: { in: rule.roundIds },
},
})
if (assignment) return true
}
}
return false
}
export const learningResourceRouter = router({
/**
* List all resources (admin view)
@@ -19,11 +83,9 @@ export const learningResourceRouter = router({
.input(
z.object({
programId: z.string().optional(),
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).optional(),
isPublished: z.boolean().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(20),
perPage: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
@@ -32,12 +94,6 @@ export const learningResourceRouter = router({
if (input.programId !== undefined) {
where.programId = input.programId
}
if (input.resourceType) {
where.resourceType = input.resourceType
}
if (input.cohortLevel) {
where.cohortLevel = input.cohortLevel
}
if (input.isPublished !== undefined) {
where.isPublished = input.isPublished
}
@@ -67,71 +123,41 @@ export const learningResourceRouter = router({
}),
/**
* Get resources accessible to the current user (jury view)
* Get resources accessible to the current user (jury/mentor/observer view)
*/
myResources: protectedProcedure
.input(
z.object({
programId: z.string().optional(),
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
})
)
.query(async ({ ctx, input }) => {
// Determine user's cohort level based on their assignments
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id },
include: {
project: {
select: {
status: true,
},
},
},
})
// Determine highest cohort level
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
const projectStatus = assignment.project.status
if (projectStatus === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
if (projectStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
// Build query based on cohort level
const cohortLevels = ['ALL']
if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') {
cohortLevels.push('SEMIFINALIST')
}
if (userCohortLevel === 'FINALIST') {
cohortLevels.push('FINALIST')
}
const where: Record<string, unknown> = {
isPublished: true,
cohortLevel: { in: cohortLevels },
}
if (input.programId) {
where.OR = [{ programId: input.programId }, { programId: null }]
}
if (input.resourceType) {
where.resourceType = input.resourceType
}
const resources = await ctx.prisma.learningResource.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
})
return {
resources,
userCohortLevel,
// Filter by access rules in application code (small dataset)
const accessible = []
for (const resource of resources) {
const allowed = await canUserAccessResource(
ctx.prisma,
ctx.user.id,
ctx.user.role,
resource.accessJson,
)
if (allowed) accessible.push(resource)
}
return { resources: accessible }
}),
/**
@@ -149,7 +175,8 @@ export const learningResourceRouter = router({
})
// Check access for non-admins
if (ctx.user.role === 'JURY_MEMBER') {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
if (!resource.isPublished) {
throw new TRPCError({
code: 'FORBIDDEN',
@@ -157,39 +184,13 @@ export const learningResourceRouter = router({
})
}
// Check cohort level access
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id },
include: {
project: {
select: {
status: true,
},
},
},
})
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
const projectStatus = assignment.project.status
if (projectStatus === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
if (projectStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
const accessibleLevels = ['ALL']
if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') {
accessibleLevels.push('SEMIFINALIST')
}
if (userCohortLevel === 'FINALIST') {
accessibleLevels.push('FINALIST')
}
if (!accessibleLevels.includes(resource.cohortLevel)) {
const allowed = await canUserAccessResource(
ctx.prisma,
ctx.user.id,
ctx.user.role,
resource.accessJson,
)
if (!allowed) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this resource',
@@ -202,7 +203,6 @@ export const learningResourceRouter = router({
/**
* Get download URL for a resource file
* Checks cohort level access for non-admin users
*/
getDownloadUrl: protectedProcedure
.input(z.object({ id: z.string() }))
@@ -228,39 +228,13 @@ export const learningResourceRouter = router({
})
}
// Check cohort level access
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id },
include: {
project: {
select: {
status: true,
},
},
},
})
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
const projectStatus = assignment.project.status
if (projectStatus === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
if (projectStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
const accessibleLevels = ['ALL']
if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') {
accessibleLevels.push('SEMIFINALIST')
}
if (userCohortLevel === 'FINALIST') {
accessibleLevels.push('FINALIST')
}
if (!accessibleLevels.includes(resource.cohortLevel)) {
const allowed = await canUserAccessResource(
ctx.prisma,
ctx.user.id,
ctx.user.role,
resource.accessJson,
)
if (!allowed) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this resource',
@@ -281,6 +255,22 @@ export const learningResourceRouter = router({
return { url }
}),
/**
* Log access when user opens a resource detail page
*/
logAccess: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.resourceAccess.create({
data: {
resourceId: input.id,
userId: ctx.user.id,
ipAddress: ctx.ip,
},
})
return { success: true }
}),
/**
* Create a new resource (admin only)
*/
@@ -291,9 +281,9 @@ export const learningResourceRouter = router({
title: z.string().min(1).max(255),
description: z.string().optional(),
contentJson: z.any().optional(), // BlockNote document structure
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']),
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).default('ALL'),
accessJson: accessRuleSchema.array().nullable().optional(),
externalUrl: z.string().url().optional(),
coverImageKey: z.string().optional(),
sortOrder: z.number().int().default(0),
isPublished: z.boolean().default(false),
// File info (set after upload)
@@ -305,9 +295,12 @@ export const learningResourceRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const { accessJson, ...rest } = input
const resource = await ctx.prisma.learningResource.create({
data: {
...input,
...rest,
accessJson: accessJson === null ? Prisma.JsonNull : accessJson ?? undefined,
createdById: ctx.user.id,
},
})
@@ -319,7 +312,7 @@ export const learningResourceRouter = router({
action: 'CREATE',
entityType: 'LearningResource',
entityId: resource.id,
detailsJson: { title: input.title, resourceType: input.resourceType },
detailsJson: { title: input.title },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
@@ -335,11 +328,12 @@ export const learningResourceRouter = router({
z.object({
id: z.string(),
title: z.string().min(1).max(255).optional(),
description: z.string().optional(),
description: z.string().optional().nullable(),
contentJson: z.any().optional(), // BlockNote document structure
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).optional(),
accessJson: accessRuleSchema.array().nullable().optional(),
externalUrl: z.string().url().optional().nullable(),
coverImageKey: z.string().optional().nullable(),
programId: z.string().nullable().optional(),
sortOrder: z.number().int().optional(),
isPublished: z.boolean().optional(),
// File info (set after upload)
@@ -351,7 +345,15 @@ export const learningResourceRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const { id, accessJson, ...rest } = input
// Prisma requires Prisma.JsonNull for nullable JSON fields instead of raw null
const data = {
...rest,
...(accessJson !== undefined && {
accessJson: accessJson === null ? Prisma.JsonNull : accessJson,
}),
}
const resource = await ctx.prisma.learningResource.update({
where: { id },
@@ -365,7 +367,7 @@ export const learningResourceRouter = router({
action: 'UPDATE',
entityType: 'LearningResource',
entityId: id,
detailsJson: data,
detailsJson: rest,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})