Files
MOPC-Portal/src/server/routers/project-pool.ts
Matt cfee3bc8a9
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
feat: round finalization with ranking-based outcomes + award pool notifications
- processRoundClose EVALUATION uses ranking scores + advanceMode config
  (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED
- Advancement emails generate invite tokens for passwordless users with
  "Create Your Account" CTA; rejection emails have no link
- Finalization UI shows account stats (invite vs dashboard link counts)
- Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson)
- New award pool notification system: getAwardSelectionNotificationTemplate email,
  notifyEligibleProjects mutation with invite token generation,
  "Notify Pool" button on award detail page with custom message dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:14:41 +01:00

529 lines
16 KiB
TypeScript

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
/**
* Project Pool Router
*
* Manages the project pool for assigning projects to competition rounds.
* Shows all projects by default, with optional filtering for unassigned-only
* or projects not yet in a specific round.
*/
export const projectPoolRouter = router({
/**
* List projects in the pool with filtering and pagination.
* By default shows ALL projects. Use filters to narrow:
* - unassignedOnly: true → only projects not in any round
* - excludeRoundId: "..." → only projects not already in that round
*/
listUnassigned: adminProcedure
.input(
z.object({
programId: z.string(),
competitionCategory: z
.enum(['STARTUP', 'BUSINESS_CONCEPT'])
.optional(),
search: z.string().optional(),
unassignedOnly: z.boolean().optional().default(false),
excludeRoundId: z.string().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(200).default(50),
})
)
.query(async ({ ctx, input }) => {
const { programId, competitionCategory, search, unassignedOnly, excludeRoundId, page, perPage } = input
const skip = (page - 1) * perPage
// Build where clause
const where: Record<string, unknown> = {
programId,
}
// Optional: only show projects not in any round
if (unassignedOnly) {
where.projectRoundStates = { none: {} }
}
// Optional: exclude projects already in a specific round
if (excludeRoundId && !unassignedOnly) {
where.projectRoundStates = {
none: { roundId: excludeRoundId },
}
}
// Filter by competition category
if (competitionCategory) {
where.competitionCategory = competitionCategory
}
// Search in title, teamName, description, institution, country, geographicZone, team member names
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ institution: { contains: search, mode: 'insensitive' } },
{ country: { contains: search, mode: 'insensitive' } },
{ geographicZone: { contains: search, mode: 'insensitive' } },
{ teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
]
}
// Execute queries in parallel
const [projects, total] = await Promise.all([
ctx.prisma.project.findMany({
where,
skip,
take: perPage,
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
teamName: true,
description: true,
competitionCategory: true,
oceanIssue: true,
country: true,
status: true,
submittedAt: true,
createdAt: true,
tags: true,
wantsMentorship: true,
programId: true,
_count: {
select: {
files: true,
teamMembers: true,
},
},
projectRoundStates: {
select: {
roundId: true,
state: true,
round: {
select: {
name: true,
roundType: true,
sortOrder: true,
},
},
},
orderBy: {
round: { sortOrder: 'asc' },
},
},
},
}),
ctx.prisma.project.count({ where }),
])
return {
projects,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
}
}),
/**
* Bulk assign projects to a round
*/
assignToRound: adminProcedure
.input(
z.object({
projectIds: z.array(z.string()).min(1).max(1000),
roundId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const { projectIds, roundId } = input
// Step 1: Fetch all projects to validate
const projects = await ctx.prisma.project.findMany({
where: {
id: { in: projectIds },
},
select: {
id: true,
title: true,
programId: true,
},
})
// Validate all projects were found
if (projects.length !== projectIds.length) {
const foundIds = new Set(projects.map((p) => p.id))
const missingIds = projectIds.filter((id) => !foundIds.has(id))
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Some projects were not found: ${missingIds.join(', ')}`,
})
}
// Verify round exists
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true },
})
// Step 2: Perform bulk assignment in a transaction
const result = await ctx.prisma.$transaction(async (tx) => {
// Create ProjectRoundState entries for each project (skip existing)
const assignmentData = projectIds.map((projectId) => ({
projectId,
roundId,
}))
await tx.projectRoundState.createMany({
data: assignmentData,
skipDuplicates: true,
})
// Update project statuses
const updatedProjects = await tx.project.updateMany({
where: {
id: { in: projectIds },
},
data: {
status: 'ASSIGNED',
},
})
// Create status history records for each project
await tx.projectStatusHistory.createMany({
data: projectIds.map((projectId) => ({
projectId,
status: 'ASSIGNED',
changedBy: ctx.user?.id,
})),
})
return updatedProjects
})
// Audit outside transaction so failures don't roll back the assignment
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'BULK_ASSIGN_TO_ROUND',
entityType: 'Project',
detailsJson: {
roundId,
projectCount: projectIds.length,
projectIds,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
success: true,
assignedCount: result.count,
roundId,
}
}),
/**
* Assign ALL matching projects in a program to a round (server-side, no ID limit).
* Skips projects already in the target round.
*/
assignAllToRound: adminProcedure
.input(
z.object({
programId: z.string(),
roundId: z.string(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
unassignedOnly: z.boolean().optional().default(false),
})
)
.mutation(async ({ ctx, input }) => {
const { programId, roundId, competitionCategory, unassignedOnly } = input
// Verify round exists
await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true },
})
// Find projects to assign
const where: Record<string, unknown> = {
programId,
}
if (unassignedOnly) {
// Only projects not in any round
where.projectRoundStates = { none: {} }
} else {
// All projects not already in the target round
where.projectRoundStates = {
none: { roundId },
}
}
if (competitionCategory) {
where.competitionCategory = competitionCategory
}
const projects = await ctx.prisma.project.findMany({
where,
select: { id: true },
})
if (projects.length === 0) {
return { success: true, assignedCount: 0, roundId }
}
const projectIds = projects.map((p) => p.id)
const result = await ctx.prisma.$transaction(async (tx) => {
await tx.projectRoundState.createMany({
data: projectIds.map((projectId) => ({ projectId, roundId })),
skipDuplicates: true,
})
const updated = await tx.project.updateMany({
where: { id: { in: projectIds } },
data: { status: 'ASSIGNED' },
})
await tx.projectStatusHistory.createMany({
data: projectIds.map((projectId) => ({
projectId,
status: 'ASSIGNED',
changedBy: ctx.user?.id,
})),
})
return updated
})
// Audit outside transaction so failures don't roll back the assignment
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'BULK_ASSIGN_ALL_TO_ROUND',
entityType: 'Project',
detailsJson: {
roundId,
programId,
competitionCategory: competitionCategory || 'ALL',
unassignedOnly,
projectCount: projectIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true, assignedCount: result.count, roundId }
}),
/**
* List projects in a specific round (for import-from-round picker).
*/
getProjectsInRound: adminProcedure
.input(
z.object({
roundId: z.string(),
states: z.array(z.string()).optional(),
search: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const { roundId, states, search } = input
const where: Record<string, unknown> = { roundId }
if (states && states.length > 0) {
where.state = { in: states }
}
if (search?.trim()) {
where.project = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
],
}
}
const projectStates = await ctx.prisma.projectRoundState.findMany({
where,
select: {
projectId: true,
state: true,
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
country: true,
},
},
},
orderBy: { project: { title: 'asc' } },
})
return projectStates.map((ps) => ({
id: ps.project.id,
title: ps.project.title,
teamName: ps.project.teamName,
competitionCategory: ps.project.competitionCategory,
country: ps.project.country,
state: ps.state,
}))
}),
/**
* Import projects from an earlier round into a later round.
* Fills intermediate rounds with COMPLETED states to keep history clean.
*/
importFromRound: adminProcedure
.input(
z.object({
sourceRoundId: z.string(),
targetRoundId: z.string(),
projectIds: z.array(z.string()).min(1).max(1000),
})
)
.mutation(async ({ ctx, input }) => {
const { sourceRoundId, targetRoundId, projectIds } = input
// Validate both rounds exist and belong to the same competition
const [sourceRound, targetRound] = await Promise.all([
ctx.prisma.round.findUniqueOrThrow({
where: { id: sourceRoundId },
select: { id: true, name: true, competitionId: true, sortOrder: true },
}),
ctx.prisma.round.findUniqueOrThrow({
where: { id: targetRoundId },
select: { id: true, name: true, competitionId: true, sortOrder: true },
}),
])
if (sourceRound.competitionId !== targetRound.competitionId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Source and target rounds must belong to the same competition',
})
}
if (sourceRound.sortOrder >= targetRound.sortOrder) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Source round must come before target round',
})
}
// Validate all projectIds exist in the source round
const sourceStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: sourceRoundId, projectId: { in: projectIds } },
select: { projectId: true, state: true },
})
if (sourceStates.length !== projectIds.length) {
const foundIds = new Set(sourceStates.map((s) => s.projectId))
const missing = projectIds.filter((id) => !foundIds.has(id))
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${missing.length} project(s) not found in source round`,
})
}
// Find intermediate rounds
const intermediateRounds = await ctx.prisma.round.findMany({
where: {
competitionId: sourceRound.competitionId,
sortOrder: { gt: sourceRound.sortOrder, lt: targetRound.sortOrder },
},
select: { id: true },
orderBy: { sortOrder: 'asc' },
})
// Check which projects are already in the target round
const existingInTarget = await ctx.prisma.projectRoundState.findMany({
where: { roundId: targetRoundId, projectId: { in: projectIds } },
select: { projectId: true },
})
const alreadyInTarget = new Set(existingInTarget.map((e) => e.projectId))
const toImport = projectIds.filter((id) => !alreadyInTarget.has(id))
if (toImport.length === 0) {
return { imported: 0, skipped: projectIds.length }
}
await ctx.prisma.$transaction(async (tx) => {
// Update source round states to COMPLETED (if PASSED or PENDING)
await tx.projectRoundState.updateMany({
where: {
roundId: sourceRoundId,
projectId: { in: toImport },
state: { in: ['PASSED', 'PENDING', 'IN_PROGRESS'] },
},
data: { state: 'COMPLETED' },
})
// Create COMPLETED states for intermediate rounds
if (intermediateRounds.length > 0) {
const intermediateData = intermediateRounds.flatMap((round) =>
toImport.map((projectId) => ({
projectId,
roundId: round.id,
state: 'COMPLETED' as const,
}))
)
await tx.projectRoundState.createMany({
data: intermediateData,
skipDuplicates: true,
})
}
// Create PENDING states in the target round
await tx.projectRoundState.createMany({
data: toImport.map((projectId) => ({
projectId,
roundId: targetRoundId,
})),
skipDuplicates: true,
})
// Update project status to ASSIGNED
await tx.project.updateMany({
where: { id: { in: toImport } },
data: { status: 'ASSIGNED' },
})
// Create status history records
await tx.projectStatusHistory.createMany({
data: toImport.map((projectId) => ({
projectId,
status: 'ASSIGNED',
changedBy: ctx.user?.id,
})),
})
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'IMPORT_FROM_ROUND',
entityType: 'Project',
detailsJson: {
sourceRoundId,
sourceRoundName: sourceRound.name,
targetRoundId,
targetRoundName: targetRound.name,
importedCount: toImport.length,
skippedCount: alreadyInTarget.size,
intermediateRounds: intermediateRounds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { imported: toImport.length, skipped: alreadyInTarget.size }
}),
})