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

@@ -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,
})