Consolidated round management, AI filtering enhancements, MinIO storage restructure
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s

- Fix STAGE_ACTIVE bug in assignment router (now ROUND_ACTIVE)
- Add evaluation form CRUD (getForm + upsertForm endpoints)
- Add advanceProjects mutation for manual project advancement
- Rewrite round detail page: 7-tab consolidated interface
- Add filtering rules UI with full CRUD (field-based, document check, AI screening)
- Add pageCount field to ProjectFile for document page limit filtering
- Enhance AI filtering: per-file page limits, category/region-aware guidelines
- Restructure MinIO paths: {ProjectName}/{RoundName}/{timestamp}-{file}
- Update dashboard and pool page links from /admin/competitions to /admin/rounds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 09:20:02 +01:00
parent 845554fdb8
commit 8e5fc18da6
14 changed files with 2606 additions and 303 deletions

View File

@@ -2,7 +2,7 @@ import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio'
import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
@@ -306,9 +306,17 @@ export const applicantRouter = router({
})
}
const timestamp = Date.now()
const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
const objectKey = `${project.id}/${input.fileType}/${timestamp}-${sanitizedName}`
// Fetch round name for storage path (if uploading against a round)
let roundName: string | undefined
if (input.roundId) {
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true },
})
roundName = round?.name
}
const objectKey = generateObjectKey(project.title, input.fileName, roundName)
const url = await getPresignedUrl(SUBMISSIONS_BUCKET, objectKey, 'PUT', 3600)

View File

@@ -241,7 +241,7 @@ export const assignmentRouter = router({
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
userId: ctx.user.id,
round: { status: 'STAGE_ACTIVE' },
round: { status: 'ROUND_ACTIVE' },
}
if (input.roundId) {

View File

@@ -1019,6 +1019,129 @@ export const evaluationRouter = router({
return discussion
}),
// =========================================================================
// Evaluation Form CRUD (Admin)
// =========================================================================
/**
* Get active evaluation form for a round (admin view with full details)
*/
getForm: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const form = await ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
})
if (!form) return null
return {
id: form.id,
roundId: form.roundId,
version: form.version,
isActive: form.isActive,
criteriaJson: form.criteriaJson as Array<{
id: string
label: string
description?: string
weight?: number
minScore?: number
maxScore?: number
}>,
scalesJson: form.scalesJson as Record<string, unknown> | null,
createdAt: form.createdAt,
updatedAt: form.updatedAt,
}
}),
/**
* Create or update the evaluation form for a round.
* Deactivates any existing active form and creates a new versioned one.
*/
upsertForm: adminProcedure
.input(
z.object({
roundId: z.string(),
criteria: z.array(
z.object({
id: z.string(),
label: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
weight: z.number().min(0).max(100).optional(),
minScore: z.number().int().min(0).optional(),
maxScore: z.number().int().min(1).optional(),
})
).min(1),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, criteria } = input
// Verify round exists
await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId } })
// Get current max version for this round
const latestForm = await ctx.prisma.evaluationForm.findFirst({
where: { roundId },
orderBy: { version: 'desc' },
select: { version: true },
})
const nextVersion = (latestForm?.version ?? 0) + 1
// Build criteriaJson with defaults
const criteriaJson = criteria.map((c) => ({
id: c.id,
label: c.label,
description: c.description || '',
weight: c.weight ?? 1,
scale: `${c.minScore ?? 1}-${c.maxScore ?? 10}`,
required: true,
}))
// Auto-generate scalesJson from criteria min/max ranges
const scaleSet = new Set(criteriaJson.map((c) => c.scale))
const scalesJson: Record<string, { min: number; max: number }> = {}
for (const scale of scaleSet) {
const [min, max] = scale.split('-').map(Number)
scalesJson[scale] = { min, max }
}
// Transaction: deactivate old → create new
const form = await ctx.prisma.$transaction(async (tx) => {
await tx.evaluationForm.updateMany({
where: { roundId, isActive: true },
data: { isActive: false },
})
return tx.evaluationForm.create({
data: {
roundId,
version: nextVersion,
criteriaJson,
scalesJson,
isActive: true,
},
})
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPSERT_EVALUATION_FORM',
entityType: 'EvaluationForm',
entityId: form.id,
detailsJson: {
roundId,
version: nextVersion,
criteriaCount: criteria.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return form
}),
// =========================================================================
// Phase 4: Stage-scoped evaluation procedures
// =========================================================================

View File

@@ -149,20 +149,27 @@ export const fileRouter = router({
})
}
let isLate = false
if (input.roundId) {
const stage = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { windowCloseAt: true },
})
// Fetch project title and optional round name for storage path
const [project, roundInfo] = await Promise.all([
ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
select: { title: true },
}),
input.roundId
? ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, windowCloseAt: true },
})
: null,
])
if (stage?.windowCloseAt) {
isLate = new Date() > stage.windowCloseAt
}
let isLate = false
if (roundInfo?.windowCloseAt) {
isLate = new Date() > roundInfo.windowCloseAt
}
const bucket = BUCKET_NAME
const objectKey = generateObjectKey(input.projectId, input.fileName)
const objectKey = generateObjectKey(project.title, input.fileName, roundInfo?.name)
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600) // 1 hour
@@ -1122,8 +1129,20 @@ export const fileRouter = router({
else if (input.mimeType.includes('presentation') || input.mimeType.includes('powerpoint'))
fileType = 'PRESENTATION'
// Fetch project title and window name for storage path
const [project, submissionWindow] = await Promise.all([
ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
select: { title: true },
}),
ctx.prisma.submissionWindow.findUniqueOrThrow({
where: { id: input.submissionWindowId },
select: { name: true },
}),
])
const bucket = BUCKET_NAME
const objectKey = generateObjectKey(input.projectId, input.fileName)
const objectKey = generateObjectKey(project.title, input.fileName, submissionWindow.name)
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600)
// Remove any existing file for this project+requirement combo (replace)

View File

@@ -54,7 +54,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
project: {
include: {
files: {
select: { id: true, fileName: true, fileType: true },
select: { id: true, fileName: true, fileType: true, size: true, pageCount: true },
},
},
},
@@ -513,7 +513,7 @@ export const filteringRouter = router({
project: {
include: {
files: {
select: { id: true, fileName: true, fileType: true },
select: { id: true, fileName: true, fileType: true, size: true, pageCount: true },
},
},
},

View File

@@ -227,6 +227,137 @@ export const roundRouter = router({
return existing
}),
// =========================================================================
// Project Advancement (Manual Only)
// =========================================================================
/**
* Advance PASSED projects from one round to the next.
* This is ALWAYS manual — no auto-advancement after AI filtering.
* Admin must explicitly trigger this after reviewing results.
*/
advanceProjects: adminProcedure
.input(
z.object({
roundId: z.string(),
targetRoundId: z.string().optional(),
projectIds: z.array(z.string()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, targetRoundId, projectIds } = input
// Get current round with competition context
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true, competitionId: true, sortOrder: true },
})
// Determine target round
let targetRound: { id: string; name: string }
if (targetRoundId) {
targetRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: targetRoundId },
select: { id: true, name: true },
})
} else {
// Find next round in same competition by sortOrder
const nextRound = await ctx.prisma.round.findFirst({
where: {
competitionId: currentRound.competitionId,
sortOrder: { gt: currentRound.sortOrder },
},
orderBy: { sortOrder: 'asc' },
select: { id: true, name: true },
})
if (!nextRound) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No subsequent round exists in this competition. Create the next round first.',
})
}
targetRound = nextRound
}
// Determine which projects to advance
let idsToAdvance: string[]
if (projectIds && projectIds.length > 0) {
idsToAdvance = projectIds
} else {
// Default: all PASSED projects in current round
const passedStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'PASSED' },
select: { projectId: true },
})
idsToAdvance = passedStates.map((s) => s.projectId)
}
if (idsToAdvance.length === 0) {
return { advancedCount: 0, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
}
// Transaction: create entries in target round + mark current as COMPLETED
await ctx.prisma.$transaction(async (tx) => {
// Create ProjectRoundState in target round
await tx.projectRoundState.createMany({
data: idsToAdvance.map((projectId) => ({
projectId,
roundId: targetRound.id,
})),
skipDuplicates: true,
})
// Mark current round states as COMPLETED
await tx.projectRoundState.updateMany({
where: {
roundId,
projectId: { in: idsToAdvance },
state: 'PASSED',
},
data: { state: 'COMPLETED' },
})
// Update project status to ASSIGNED
await tx.project.updateMany({
where: { id: { in: idsToAdvance } },
data: { status: 'ASSIGNED' },
})
// Status history
await tx.projectStatusHistory.createMany({
data: idsToAdvance.map((projectId) => ({
projectId,
status: 'ASSIGNED',
changedBy: ctx.user?.id,
})),
})
})
// Audit
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ADVANCE_PROJECTS',
entityType: 'Round',
entityId: roundId,
detailsJson: {
fromRound: currentRound.name,
toRound: targetRound.name,
targetRoundId: targetRound.id,
projectCount: idsToAdvance.length,
projectIds: idsToAdvance,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
advancedCount: idsToAdvance.length,
targetRoundId: targetRound.id,
targetRoundName: targetRound.name,
}
}),
// =========================================================================
// Submission Window Management
// =========================================================================