Consolidated round management, AI filtering enhancements, MinIO storage restructure
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
@@ -67,6 +67,8 @@ export type FieldRuleConfig = {
|
||||
export type DocumentCheckConfig = {
|
||||
requiredFileTypes?: string[] // e.g. ['pdf', 'docx']
|
||||
minFileCount?: number
|
||||
maxPages?: number // Max pages for ANY file
|
||||
maxPagesByFileType?: Record<string, number> // e.g. { "EXECUTIVE_SUMMARY": 2, "PITCH_DECK": 10 }
|
||||
action: 'PASS' | 'REJECT' | 'FLAG'
|
||||
}
|
||||
|
||||
@@ -110,7 +112,7 @@ interface ProjectForFiltering {
|
||||
institution?: string | null
|
||||
submissionSource?: SubmissionSource
|
||||
submittedAt?: Date | null
|
||||
files: Array<{ id: string; fileName: string; fileType?: FileType | null }>
|
||||
files: Array<{ id: string; fileName: string; fileType?: FileType | null; size?: number; pageCount?: number | null }>
|
||||
_count?: {
|
||||
teamMembers?: number
|
||||
files?: number
|
||||
@@ -162,10 +164,22 @@ Return a JSON object with this exact structure:
|
||||
- 3-4: Weak — significant shortcomings against criteria
|
||||
- 1-2: Poor — does not meet criteria or appears low-quality/spam
|
||||
|
||||
## Available Data Per Project
|
||||
- category: STARTUP or BUSINESS_CONCEPT
|
||||
- country, region: geographic location (use for regional considerations)
|
||||
- founded_year: when the company/initiative was founded (use for age checks)
|
||||
- ocean_issue: the ocean conservation area
|
||||
- file_count, file_types: uploaded documents summary
|
||||
- files[]: per-file details with file_type, page_count (if known), and size_kb
|
||||
- description: project summary text
|
||||
- tags: topic tags
|
||||
|
||||
## Guidelines
|
||||
- Evaluate ONLY against the provided criteria, not your own standards
|
||||
- A confidence of 1.0 means absolute certainty; 0.5 means borderline
|
||||
- Flag spam_risk=true for: AI-generated filler text, copied content, or irrelevant submissions
|
||||
- When criteria differ by category (e.g. stricter for STARTUP vs BUSINESS_CONCEPT), apply the appropriate threshold
|
||||
- When criteria mention regional considerations (e.g. African projects), use the country/region fields
|
||||
- Do not include any personal identifiers in reasoning
|
||||
- If project data is insufficient to evaluate, set confidence below 0.3`
|
||||
|
||||
@@ -293,6 +307,25 @@ export function evaluateDocumentRule(
|
||||
}
|
||||
}
|
||||
|
||||
// Check global max pages (any file exceeding limit fails)
|
||||
if (config.maxPages !== undefined) {
|
||||
const overLimit = files.some((f) => f.pageCount != null && f.pageCount > config.maxPages!)
|
||||
if (overLimit) {
|
||||
return { passed: false, action: config.action }
|
||||
}
|
||||
}
|
||||
|
||||
// Check per-fileType max pages (e.g. EXECUTIVE_SUMMARY: 2, PITCH_DECK: 10)
|
||||
if (config.maxPagesByFileType && Object.keys(config.maxPagesByFileType).length > 0) {
|
||||
for (const file of files) {
|
||||
if (!file.fileType || file.pageCount == null) continue
|
||||
const limit = config.maxPagesByFileType[file.fileType]
|
||||
if (limit !== undefined && file.pageCount > limit) {
|
||||
return { passed: false, action: config.action }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { passed: true, action: config.action }
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ export interface AnonymizationResult {
|
||||
* Comprehensive anonymized project data for AI filtering
|
||||
* Includes all fields needed for flexible filtering criteria
|
||||
*/
|
||||
export interface AnonymizedFileInfo {
|
||||
file_type: string // FileType enum value
|
||||
page_count: number | null // Number of pages if known
|
||||
size_kb: number // File size in KB
|
||||
}
|
||||
|
||||
export interface AnonymizedProjectForAI {
|
||||
project_id: string // P1, P2, etc.
|
||||
title: string // Sanitized
|
||||
@@ -94,6 +100,7 @@ export interface AnonymizedProjectForAI {
|
||||
has_description: boolean
|
||||
file_count: number
|
||||
file_types: string[] // FileType values
|
||||
files: AnonymizedFileInfo[] // Per-file details for document analysis
|
||||
wants_mentorship: boolean
|
||||
submission_source: SubmissionSource
|
||||
submitted_date: string | null // YYYY-MM-DD only
|
||||
@@ -121,7 +128,7 @@ export interface ProjectWithRelations {
|
||||
teamMembers?: number
|
||||
files?: number
|
||||
}
|
||||
files?: Array<{ fileType: FileType | null }>
|
||||
files?: Array<{ fileType: FileType | null; size?: number; pageCount?: number | null }>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,7 +160,7 @@ export function toProjectWithRelations(project: {
|
||||
submissionSource?: string
|
||||
submittedAt?: Date | null
|
||||
_count?: { teamMembers?: number; files?: number }
|
||||
files?: Array<{ fileType?: string | null; [key: string]: unknown }>
|
||||
files?: Array<{ fileType?: string | null; size?: number; pageCount?: number | null; [key: string]: unknown }>
|
||||
}): ProjectWithRelations {
|
||||
return {
|
||||
id: project.id,
|
||||
@@ -173,7 +180,11 @@ export function toProjectWithRelations(project: {
|
||||
teamMembers: project._count?.teamMembers ?? 0,
|
||||
files: project._count?.files ?? project.files?.length ?? 0,
|
||||
},
|
||||
files: project.files?.map((f) => ({ fileType: (f.fileType as FileType) ?? null })) ?? [],
|
||||
files: project.files?.map((f) => ({
|
||||
fileType: (f.fileType as FileType) ?? null,
|
||||
size: f.size,
|
||||
pageCount: f.pageCount ?? null,
|
||||
})) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +299,11 @@ export function anonymizeProjectForAI(
|
||||
file_types: project.files
|
||||
?.map((f) => f.fileType)
|
||||
.filter((ft): ft is FileType => ft !== null) ?? [],
|
||||
files: project.files?.map((f) => ({
|
||||
file_type: f.fileType ?? 'OTHER',
|
||||
page_count: f.pageCount ?? null,
|
||||
size_kb: Math.round((f.size ?? 0) / 1024),
|
||||
})) ?? [],
|
||||
wants_mentorship: project.wantsMentorship ?? false,
|
||||
submission_source: project.submissionSource,
|
||||
submitted_date: project.submittedAt?.toISOString().split('T')[0] ?? null,
|
||||
|
||||
Reference in New Issue
Block a user