All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
- 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>
529 lines
16 KiB
TypeScript
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 }
|
|
}),
|
|
})
|