Round system redesign: Phases 1-7 complete

Full pipeline/track/stage architecture replacing the legacy round system.

Schema: 11 new models (Pipeline, Track, Stage, StageTransition,
ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor,
OverrideAction, AudienceVoter) + 8 new enums.

Backend: 9 new routers (pipeline, stage, routing, stageFiltering,
stageAssignment, cohort, live, decision, award) + 6 new services
(stage-engine, routing-engine, stage-filtering, stage-assignment,
stage-notifications, live-control).

Frontend: Pipeline wizard (17 components), jury stage pages (7),
applicant pipeline pages (3), public stage pages (2), admin pipeline
pages (5), shared stage components (3), SSE route, live hook.

Phase 6 refit: 23 routers/services migrated from roundId to stageId,
all frontend components refitted. Deleted round.ts (985 lines),
roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx,
10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs.

Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing,
TypeScript 0 errors, Next.js build succeeds, 13 integrity checks,
legacy symbol sweep clean, auto-seed on first Docker startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 13:57:09 +01:00
parent 8a328357e3
commit 331b67dae0
256 changed files with 29117 additions and 21424 deletions

View File

@@ -8,7 +8,6 @@ import {
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
import { getFirstRoundForProgram } from '@/server/utils/round-helpers'
import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit'
import { sendInvitationEmail } from '@/lib/email'
@@ -34,7 +33,7 @@ export const projectRouter = router({
.input(
z.object({
programId: z.string().optional(),
roundId: z.string().optional(),
stageId: z.string().optional(),
status: z
.enum([
'SUBMITTED',
@@ -55,8 +54,8 @@ export const projectRouter = router({
'REJECTED',
])
).optional(),
notInRoundId: z.string().optional(), // Exclude projects already in this round
unassignedOnly: z.boolean().optional(), // Projects not in any round
excludeInStageId: z.string().optional(), // Exclude projects already in this stage
unassignedOnly: z.boolean().optional(), // Projects not in any stage
search: z.string().optional(),
tags: z.array(z.string()).optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
@@ -76,7 +75,7 @@ export const projectRouter = router({
)
.query(async ({ ctx, input }) => {
const {
programId, roundId, notInRoundId, status, statuses, unassignedOnly, search, tags,
programId, stageId, excludeInStageId, status, statuses, unassignedOnly, search, tags,
competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments,
page, perPage,
@@ -89,25 +88,19 @@ export const projectRouter = router({
// Filter by program
if (programId) where.programId = programId
// Filter by round
if (roundId) {
where.roundId = roundId
// Filter by stage (via ProjectStageState join)
if (stageId) {
where.stageStates = { some: { stageId } }
}
// Exclude projects in a specific round (include unassigned projects with roundId=null)
if (notInRoundId) {
if (!where.AND) where.AND = []
;(where.AND as unknown[]).push({
OR: [
{ roundId: null },
{ roundId: { not: notInRoundId } },
],
})
// Exclude projects already in a specific stage
if (excludeInStageId) {
where.stageStates = { none: { stageId: excludeInStageId } }
}
// Filter by unassigned (no round)
// Filter by unassigned (not in any stage)
if (unassignedOnly) {
where.roundId = null
where.stageStates = { none: {} }
}
// Status filter
@@ -153,13 +146,7 @@ export const projectRouter = router({
take: perPage,
orderBy: { createdAt: 'desc' },
include: {
round: {
select: {
id: true,
name: true,
program: { select: { id: true, name: true, year: true } },
},
},
program: { select: { id: true, name: true, year: true } },
_count: { select: { assignments: true, files: true } },
},
}),
@@ -183,8 +170,8 @@ export const projectRouter = router({
.input(
z.object({
programId: z.string().optional(),
roundId: z.string().optional(),
notInRoundId: z.string().optional(),
stageId: z.string().optional(),
excludeInStageId: z.string().optional(),
unassignedOnly: z.boolean().optional(),
search: z.string().optional(),
statuses: z.array(
@@ -213,7 +200,7 @@ export const projectRouter = router({
)
.query(async ({ ctx, input }) => {
const {
programId, roundId, notInRoundId, unassignedOnly,
programId, stageId, excludeInStageId, unassignedOnly,
search, statuses, tags,
competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments,
@@ -222,17 +209,15 @@ export const projectRouter = router({
const where: Record<string, unknown> = {}
if (programId) where.programId = programId
if (roundId) where.roundId = roundId
if (notInRoundId) {
if (!where.AND) where.AND = []
;(where.AND as unknown[]).push({
OR: [
{ roundId: null },
{ roundId: { not: notInRoundId } },
],
})
if (stageId) {
where.stageStates = { some: { stageId } }
}
if (excludeInStageId) {
where.stageStates = { none: { stageId: excludeInStageId } }
}
if (unassignedOnly) {
where.stageStates = { none: {} }
}
if (unassignedOnly) where.roundId = null
if (statuses?.length) where.status = { in: statuses }
if (tags && tags.length > 0) where.tags = { hasSome: tags }
if (competitionCategory) where.competitionCategory = competitionCategory
@@ -265,11 +250,7 @@ export const projectRouter = router({
*/
getFilterOptions: protectedProcedure
.query(async ({ ctx }) => {
const [rounds, countries, categories, issues] = await Promise.all([
ctx.prisma.round.findMany({
select: { id: true, name: true, program: { select: { id: true, name: true, year: true } } },
orderBy: [{ program: { year: 'desc' } }, { createdAt: 'asc' }],
}),
const [countries, categories, issues] = await Promise.all([
ctx.prisma.project.findMany({
where: { country: { not: null } },
select: { country: true },
@@ -289,7 +270,6 @@ export const projectRouter = router({
])
return {
rounds,
countries: countries.map((c) => c.country).filter(Boolean) as string[],
categories: categories.map((c) => ({
value: c.competitionCategory!,
@@ -312,7 +292,6 @@ export const projectRouter = router({
where: { id: input.id },
include: {
files: true,
round: true,
teamMembers: {
include: {
user: {
@@ -394,13 +373,12 @@ export const projectRouter = router({
/**
* Create a single project (admin only)
* Projects belong to a round.
* Projects belong to a program.
*/
create: adminProcedure
.input(
z.object({
programId: z.string(),
roundId: z.string().optional(),
title: z.string().min(1).max(500),
teamName: z.string().optional(),
description: z.string().optional(),
@@ -437,15 +415,7 @@ export const projectRouter = router({
...rest
} = input
// If roundId provided, derive programId from round for validation
let resolvedProgramId = input.programId
if (input.roundId) {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { programId: true },
})
resolvedProgramId = round.programId
}
const resolvedProgramId = input.programId
// Build metadata from contact fields + any additional metadata
const fullMetadata: Record<string, unknown> = { ...metadataJson }
@@ -460,19 +430,9 @@ export const projectRouter = router({
: undefined
const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => {
// Auto-assign to first round if no roundId provided
let resolvedRoundId = input.roundId || null
if (!resolvedRoundId) {
const firstRound = await getFirstRoundForProgram(tx, resolvedProgramId)
if (firstRound) {
resolvedRoundId = firstRound.id
}
}
const created = await tx.project.create({
data: {
programId: resolvedProgramId,
roundId: resolvedRoundId,
title: input.title,
teamName: input.teamName,
description: input.description,
@@ -545,7 +505,6 @@ export const projectRouter = router({
entityId: created.id,
detailsJson: {
title: input.title,
roundId: input.roundId,
programId: resolvedProgramId,
teamMembersCount: teamMembersInput?.length || 0,
},
@@ -599,7 +558,6 @@ export const projectRouter = router({
/**
* Update a project (admin only)
* Status updates require a roundId context since status is per-round.
*/
update: adminProcedure
.input(
@@ -609,8 +567,6 @@ export const projectRouter = router({
teamName: z.string().optional().nullable(),
description: z.string().optional().nullable(),
country: z.string().optional().nullable(), // ISO-2 code or country name (will be normalized)
// Status update requires roundId
roundId: z.string().optional(),
status: z
.enum([
'SUBMITTED',
@@ -626,7 +582,7 @@ export const projectRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const { id, metadataJson, status, roundId, country, ...data } = input
const { id, metadataJson, status, country, ...data } = input
// Normalize country to ISO-2 code if provided
const normalizedCountry = country !== undefined
@@ -675,90 +631,40 @@ export const projectRouter = router({
// Send notifications if status changed
if (status) {
// Get round details for notification
const projectWithRound = await ctx.prisma.project.findUnique({
where: { id },
include: { round: { select: { name: true, entryNotificationType: true, program: { select: { name: true } } } } },
})
const round = projectWithRound?.round
// Helper to get notification title based on type
const getNotificationTitle = (type: string): string => {
const titles: Record<string, string> = {
ADVANCED_SEMIFINAL: "Congratulations! You're a Semi-Finalist",
ADVANCED_FINAL: "Amazing News! You're a Finalist",
NOT_SELECTED: 'Application Status Update',
WINNER_ANNOUNCEMENT: 'Congratulations! You Won!',
}
return titles[type] || 'Project Update'
const notificationConfig: Record<
string,
{ type: string; title: string; message: string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
title: "Congratulations! You're a Semi-Finalist",
message: `Your project "${project.title}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
title: "Amazing News! You're a Finalist",
message: `Your project "${project.title}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
title: 'Application Status Update',
message: `We regret to inform you that "${project.title}" was not selected for the next round.`,
},
}
// Helper to get notification message based on type
const getNotificationMessage = (type: string, projectName: string): string => {
const messages: Record<string, (name: string) => string> = {
ADVANCED_SEMIFINAL: (name) => `Your project "${name}" has advanced to the semi-finals!`,
ADVANCED_FINAL: (name) => `Your project "${name}" has been selected as a finalist!`,
NOT_SELECTED: (name) => `We regret to inform you that "${name}" was not selected for the next round.`,
WINNER_ANNOUNCEMENT: (name) => `Your project "${name}" has been selected as a winner!`,
}
return messages[type]?.(projectName) || `Update regarding your project "${projectName}".`
}
// Use round's configured notification type, or fall back to status-based defaults
if (round?.entryNotificationType) {
const config = notificationConfig[status]
if (config) {
await notifyProjectTeam(id, {
type: round.entryNotificationType,
title: getNotificationTitle(round.entryNotificationType),
message: getNotificationMessage(round.entryNotificationType, project.title),
type: config.type,
title: config.title,
message: config.message,
linkUrl: `/team/projects/${id}`,
linkLabel: 'View Project',
priority: round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high',
priority: status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round.name,
programName: round.program?.name,
},
})
} else if (round) {
// Fall back to hardcoded status-based notifications
const notificationConfig: Record<
string,
{ type: string; title: string; message: string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
title: "Congratulations! You're a Semi-Finalist",
message: `Your project "${project.title}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
title: "Amazing News! You're a Finalist",
message: `Your project "${project.title}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
title: 'Application Status Update',
message: `We regret to inform you that "${project.title}" was not selected for the next round.`,
},
}
const config = notificationConfig[status]
if (config) {
await notifyProjectTeam(id, {
type: config.type,
title: config.title,
message: config.message,
linkUrl: `/team/projects/${id}`,
linkLabel: 'View Project',
priority: status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round?.name,
programName: round?.program?.name,
},
})
}
}
}
@@ -855,13 +761,12 @@ export const projectRouter = router({
/**
* Import projects from CSV data (admin only)
* Projects belong to a program. Optionally assign to a round.
* Projects belong to a program.
*/
importCSV: adminProcedure
.input(
z.object({
programId: z.string(),
roundId: z.string().optional(),
projects: z.array(
z.object({
title: z.string().min(1),
@@ -879,37 +784,13 @@ export const projectRouter = router({
where: { id: input.programId },
})
// Verify round exists and belongs to program if provided
if (input.roundId) {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
if (round.programId !== input.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Round does not belong to the selected program',
})
}
}
// Auto-assign to first round if no roundId provided
let resolvedImportRoundId = input.roundId || null
if (!resolvedImportRoundId) {
const firstRound = await getFirstRoundForProgram(ctx.prisma, input.programId)
if (firstRound) {
resolvedImportRoundId = firstRound.id
}
}
// Create projects in a transaction
const result = await ctx.prisma.$transaction(async (tx) => {
// Create all projects with roundId and programId
const projectData = input.projects.map((p) => {
const { metadataJson, ...rest } = p
return {
...rest,
programId: input.programId,
roundId: resolvedImportRoundId,
status: 'SUBMITTED' as const,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
}
@@ -929,7 +810,7 @@ export const projectRouter = router({
userId: ctx.user.id,
action: 'IMPORT',
entityType: 'Project',
detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
detailsJson: { programId: input.programId, count: result.imported },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
@@ -942,13 +823,11 @@ export const projectRouter = router({
*/
getTags: protectedProcedure
.input(z.object({
roundId: z.string().optional(),
programId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.programId) where.round = { programId: input.programId }
if (input.roundId) where.roundId = input.roundId
if (input.programId) where.programId = input.programId
const projects = await ctx.prisma.project.findMany({
where: Object.keys(where).length > 0 ? where : undefined,
@@ -963,13 +842,11 @@ export const projectRouter = router({
/**
* Update project status in bulk (admin only)
* Status is per-round, so roundId is required.
*/
bulkUpdateStatus: adminProcedure
.input(
z.object({
ids: z.array(z.string()),
roundId: z.string(),
status: z.enum([
'SUBMITTED',
'ELIGIBLE',
@@ -982,25 +859,18 @@ export const projectRouter = router({
)
.mutation(async ({ ctx, input }) => {
// Fetch matching projects BEFORE update so notifications match actually-updated records
const [projects, round] = await Promise.all([
ctx.prisma.project.findMany({
where: {
id: { in: input.ids },
roundId: input.roundId,
},
select: { id: true, title: true },
}),
ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
}),
])
const projects = await ctx.prisma.project.findMany({
where: {
id: { in: input.ids },
},
select: { id: true, title: true },
})
const matchingIds = projects.map((p) => p.id)
// Validate status transitions for all projects
const projectsWithStatus = await ctx.prisma.project.findMany({
where: { id: { in: matchingIds }, roundId: input.roundId },
where: { id: { in: matchingIds } },
select: { id: true, title: true, status: true },
})
const invalidTransitions: string[] = []
@@ -1019,7 +889,7 @@ export const projectRouter = router({
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.project.updateMany({
where: { id: { in: matchingIds }, roundId: input.roundId },
where: { id: { in: matchingIds } },
data: { status: input.status },
})
@@ -1038,7 +908,7 @@ export const projectRouter = router({
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'Project',
detailsJson: { ids: matchingIds, roundId: input.roundId, status: input.status, count: result.count },
detailsJson: { ids: matchingIds, status: input.status, count: result.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
@@ -1046,89 +916,45 @@ export const projectRouter = router({
return result
})
// Helper to get notification title based on type
const getNotificationTitle = (type: string): string => {
const titles: Record<string, string> = {
ADVANCED_SEMIFINAL: "Congratulations! You're a Semi-Finalist",
ADVANCED_FINAL: "Amazing News! You're a Finalist",
NOT_SELECTED: 'Application Status Update',
WINNER_ANNOUNCEMENT: 'Congratulations! You Won!',
}
return titles[type] || 'Project Update'
}
// Helper to get notification message based on type
const getNotificationMessage = (type: string, projectName: string): string => {
const messages: Record<string, (name: string) => string> = {
ADVANCED_SEMIFINAL: (name) => `Your project "${name}" has advanced to the semi-finals!`,
ADVANCED_FINAL: (name) => `Your project "${name}" has been selected as a finalist!`,
NOT_SELECTED: (name) => `We regret to inform you that "${name}" was not selected for the next round.`,
WINNER_ANNOUNCEMENT: (name) => `Your project "${name}" has been selected as a winner!`,
}
return messages[type]?.(projectName) || `Update regarding your project "${projectName}".`
}
// Notify project teams based on round's configured notification or status-based fallback
// Notify project teams based on status
if (projects.length > 0) {
if (round?.entryNotificationType) {
// Use round's configured notification type
const notificationConfig: Record<
string,
{ type: string; titleFn: (name: string) => string; messageFn: (name: string) => string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
titleFn: () => "Congratulations! You're a Semi-Finalist",
messageFn: (name) => `Your project "${name}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
titleFn: () => "Amazing News! You're a Finalist",
messageFn: (name) => `Your project "${name}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
titleFn: () => 'Application Status Update',
messageFn: (name) =>
`We regret to inform you that "${name}" was not selected for the next round.`,
},
}
const config = notificationConfig[input.status]
if (config) {
for (const project of projects) {
await notifyProjectTeam(project.id, {
type: round.entryNotificationType,
title: getNotificationTitle(round.entryNotificationType),
message: getNotificationMessage(round.entryNotificationType, project.title),
type: config.type,
title: config.titleFn(project.title),
message: config.messageFn(project.title),
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high',
priority: input.status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round.name,
programName: round.program?.name,
},
})
}
} else {
// Fall back to hardcoded status-based notifications
const notificationConfig: Record<
string,
{ type: string; titleFn: (name: string) => string; messageFn: (name: string) => string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
titleFn: () => "Congratulations! You're a Semi-Finalist",
messageFn: (name) => `Your project "${name}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
titleFn: () => "Amazing News! You're a Finalist",
messageFn: (name) => `Your project "${name}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
titleFn: () => 'Application Status Update',
messageFn: (name) =>
`We regret to inform you that "${name}" was not selected for the next round.`,
},
}
const config = notificationConfig[input.status]
if (config) {
for (const project of projects) {
await notifyProjectTeam(project.id, {
type: config.type,
title: config.titleFn(project.title),
message: config.messageFn(project.title),
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: input.status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round?.name,
programName: round?.program?.name,
},
})
}
}
}
}
@@ -1136,7 +962,7 @@ export const projectRouter = router({
}),
/**
* List projects in a program's pool (not assigned to any round)
* List projects in a program's pool (not assigned to any stage)
*/
listPool: adminProcedure
.input(
@@ -1153,7 +979,7 @@ export const projectRouter = router({
const where: Record<string, unknown> = {
programId,
roundId: null,
stageStates: { none: {} }, // Projects not assigned to any stage
}
if (search) {
@@ -1196,7 +1022,6 @@ export const projectRouter = router({
where: { id: input.id },
include: {
files: true,
round: true,
teamMembers: {
include: {
user: {