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:
@@ -8,7 +8,6 @@ import {
|
||||
notifyProjectTeam,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { getFirstRoundForProgram } from '@/server/utils/round-helpers'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { parseWizardConfig } from '@/lib/wizard-config'
|
||||
@@ -97,7 +96,7 @@ export const applicationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
slug: z.string(),
|
||||
mode: z.enum(['edition', 'round']).default('round'),
|
||||
mode: z.enum(['edition', 'stage']).default('stage'),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -171,71 +170,62 @@ export const applicationRouter = router({
|
||||
competitionCategories: wizardConfig.competitionCategories ?? [],
|
||||
}
|
||||
} else {
|
||||
// Round-specific application mode (backward compatible)
|
||||
const round = await ctx.prisma.round.findFirst({
|
||||
// Stage-specific application mode (backward compatible with round slug)
|
||||
const stage = await ctx.prisma.stage.findFirst({
|
||||
where: { slug: input.slug },
|
||||
include: {
|
||||
program: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
year: true,
|
||||
description: true,
|
||||
settingsJson: true,
|
||||
track: {
|
||||
include: {
|
||||
pipeline: {
|
||||
include: {
|
||||
program: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
year: true,
|
||||
description: true,
|
||||
settingsJson: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!round) {
|
||||
if (!stage) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Application round not found',
|
||||
message: 'Application stage not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if submissions are open
|
||||
let isOpen = false
|
||||
const stageProgram = stage.track.pipeline.program
|
||||
const isOpen = stage.windowOpenAt && stage.windowCloseAt
|
||||
? now >= stage.windowOpenAt && now <= stage.windowCloseAt
|
||||
: stage.status === 'STAGE_ACTIVE'
|
||||
|
||||
if (round.submissionStartDate && round.submissionEndDate) {
|
||||
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
|
||||
} else if (round.submissionDeadline) {
|
||||
isOpen = now <= round.submissionDeadline
|
||||
} else {
|
||||
isOpen = round.status === 'ACTIVE'
|
||||
}
|
||||
|
||||
// Calculate grace period if applicable
|
||||
let gracePeriodEnd: Date | null = null
|
||||
if (round.lateSubmissionGrace && round.submissionEndDate) {
|
||||
gracePeriodEnd = new Date(round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000)
|
||||
if (now <= gracePeriodEnd) {
|
||||
isOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
const roundWizardConfig = parseWizardConfig(round.program.settingsJson)
|
||||
const { settingsJson: _s, ...programData } = round.program
|
||||
const stageWizardConfig = parseWizardConfig(stageProgram.settingsJson)
|
||||
const { settingsJson: _s, ...programData } = stageProgram
|
||||
|
||||
return {
|
||||
mode: 'round' as const,
|
||||
round: {
|
||||
id: round.id,
|
||||
name: round.name,
|
||||
slug: round.slug,
|
||||
submissionStartDate: round.submissionStartDate,
|
||||
submissionEndDate: round.submissionEndDate,
|
||||
submissionDeadline: round.submissionDeadline,
|
||||
lateSubmissionGrace: round.lateSubmissionGrace,
|
||||
gracePeriodEnd,
|
||||
phase1Deadline: round.phase1Deadline,
|
||||
phase2Deadline: round.phase2Deadline,
|
||||
mode: 'stage' as const,
|
||||
stage: {
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
slug: stage.slug,
|
||||
submissionStartDate: stage.windowOpenAt,
|
||||
submissionEndDate: stage.windowCloseAt,
|
||||
submissionDeadline: stage.windowCloseAt,
|
||||
lateSubmissionGrace: null,
|
||||
gracePeriodEnd: null,
|
||||
isOpen,
|
||||
},
|
||||
program: programData,
|
||||
wizardConfig: roundWizardConfig,
|
||||
oceanIssueOptions: roundWizardConfig.oceanIssues ?? [],
|
||||
competitionCategories: roundWizardConfig.competitionCategories ?? [],
|
||||
wizardConfig: stageWizardConfig,
|
||||
oceanIssueOptions: stageWizardConfig.oceanIssues ?? [],
|
||||
competitionCategories: stageWizardConfig.competitionCategories ?? [],
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -246,9 +236,9 @@ export const applicationRouter = router({
|
||||
submit: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
mode: z.enum(['edition', 'round']).default('round'),
|
||||
mode: z.enum(['edition', 'stage']).default('stage'),
|
||||
programId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
data: applicationInputSchema,
|
||||
})
|
||||
)
|
||||
@@ -263,7 +253,7 @@ export const applicationRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const { mode, programId, roundId, data } = input
|
||||
const { mode, programId, stageId, data } = input
|
||||
|
||||
// Validate input based on mode
|
||||
if (mode === 'edition' && !programId) {
|
||||
@@ -273,10 +263,10 @@ export const applicationRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
if (mode === 'round' && !roundId) {
|
||||
if (mode === 'stage' && !stageId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'roundId is required for round-specific applications',
|
||||
message: 'stageId is required for stage-specific applications',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -340,7 +330,6 @@ export const applicationRouter = router({
|
||||
const existingProject = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
programId,
|
||||
roundId: null,
|
||||
submittedByEmail: data.contactEmail,
|
||||
},
|
||||
})
|
||||
@@ -352,42 +341,38 @@ export const applicationRouter = router({
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Round-specific application (backward compatible)
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
include: { program: true },
|
||||
// Stage-specific application
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: stageId! },
|
||||
include: {
|
||||
track: {
|
||||
include: {
|
||||
pipeline: { include: { program: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
program = round.program
|
||||
program = stage.track.pipeline.program
|
||||
|
||||
// Check submission window
|
||||
if (round.submissionStartDate && round.submissionEndDate) {
|
||||
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
|
||||
|
||||
// Check grace period
|
||||
if (!isOpen && round.lateSubmissionGrace) {
|
||||
const gracePeriodEnd = new Date(
|
||||
round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000
|
||||
)
|
||||
isOpen = now <= gracePeriodEnd
|
||||
}
|
||||
} else if (round.submissionDeadline) {
|
||||
isOpen = now <= round.submissionDeadline
|
||||
if (stage.windowOpenAt && stage.windowCloseAt) {
|
||||
isOpen = now >= stage.windowOpenAt && now <= stage.windowCloseAt
|
||||
} else {
|
||||
isOpen = round.status === 'ACTIVE'
|
||||
isOpen = stage.status === 'STAGE_ACTIVE'
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Applications are currently closed for this round',
|
||||
message: 'Applications are currently closed for this stage',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if email already submitted for this round
|
||||
// Check if email already submitted for this stage
|
||||
const existingProject = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundId,
|
||||
programId: program.id,
|
||||
submittedByEmail: data.contactEmail,
|
||||
},
|
||||
})
|
||||
@@ -395,7 +380,7 @@ export const applicationRouter = router({
|
||||
if (existingProject) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'An application with this email already exists for this round',
|
||||
message: 'An application with this email already exists for this stage',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -431,7 +416,6 @@ export const applicationRouter = router({
|
||||
const project = await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId: program.id,
|
||||
roundId: mode === 'round' ? roundId! : null,
|
||||
title: data.projectName,
|
||||
teamName: data.teamName,
|
||||
description: data.description,
|
||||
@@ -460,18 +444,6 @@ export const applicationRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-assign to first round if project has no roundId (edition-wide mode)
|
||||
let assignedRound: { id: string; name: string; entryNotificationType: string | null } | null = null
|
||||
if (!project.roundId) {
|
||||
assignedRound = await getFirstRoundForProgram(ctx.prisma, program.id)
|
||||
if (assignedRound) {
|
||||
await ctx.prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: { roundId: assignedRound.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create team lead membership
|
||||
await ctx.prisma.teamMember.create({
|
||||
data: {
|
||||
@@ -524,7 +496,7 @@ export const applicationRouter = router({
|
||||
source: 'public_application_form',
|
||||
title: data.projectName,
|
||||
category: data.competitionCategory,
|
||||
autoAssignedRound: assignedRound?.name || null,
|
||||
autoAssignedStage: null,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
@@ -559,26 +531,6 @@ export const applicationRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Send SUBMISSION_RECEIVED notification if the round is configured for it
|
||||
if (assignedRound?.entryNotificationType === 'SUBMISSION_RECEIVED') {
|
||||
try {
|
||||
await notifyProjectTeam(project.id, {
|
||||
type: NotificationTypes.SUBMISSION_RECEIVED,
|
||||
title: 'Submission Received',
|
||||
message: `Your submission "${data.projectName}" has been received and is now under review.`,
|
||||
linkUrl: `/team/projects/${project.id}`,
|
||||
linkLabel: 'View Submission',
|
||||
metadata: {
|
||||
projectName: data.projectName,
|
||||
roundName: assignedRound.name,
|
||||
programName: program.name,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Never fail on notification
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
projectId: project.id,
|
||||
@@ -592,9 +544,9 @@ export const applicationRouter = router({
|
||||
checkEmailAvailability: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
mode: z.enum(['edition', 'round']).default('round'),
|
||||
mode: z.enum(['edition', 'stage']).default('stage'),
|
||||
programId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
})
|
||||
)
|
||||
@@ -614,23 +566,31 @@ export const applicationRouter = router({
|
||||
existing = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
programId: input.programId,
|
||||
roundId: null,
|
||||
submittedByEmail: input.email,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
existing = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
submittedByEmail: input.email,
|
||||
},
|
||||
})
|
||||
// For stage-specific applications, check by program (derived from stage)
|
||||
if (input.stageId) {
|
||||
const stage = await ctx.prisma.stage.findUnique({
|
||||
where: { id: input.stageId },
|
||||
include: { track: { include: { pipeline: { select: { programId: true } } } } },
|
||||
})
|
||||
if (stage) {
|
||||
existing = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
programId: stage.track.pipeline.programId,
|
||||
submittedByEmail: input.email,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: !existing,
|
||||
message: existing
|
||||
? `An application with this email already exists for this ${input.mode === 'edition' ? 'edition' : 'round'}`
|
||||
? `An application with this email already exists for this ${input.mode === 'edition' ? 'edition' : 'stage'}`
|
||||
: null,
|
||||
}
|
||||
}),
|
||||
@@ -646,52 +606,57 @@ export const applicationRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
roundSlug: z.string(),
|
||||
programId: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
draftDataJson: z.record(z.unknown()),
|
||||
title: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Find round by slug
|
||||
const round = await ctx.prisma.round.findFirst({
|
||||
// Find stage by slug
|
||||
const stage = await ctx.prisma.stage.findFirst({
|
||||
where: { slug: input.roundSlug },
|
||||
include: {
|
||||
track: {
|
||||
include: {
|
||||
pipeline: { select: { programId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!round) {
|
||||
if (!stage) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Round not found',
|
||||
message: 'Stage not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if drafts are enabled
|
||||
const settings = (round.settingsJson as Record<string, unknown>) || {}
|
||||
if (settings.drafts_enabled === false) {
|
||||
const stageConfig = (stage.configJson as Record<string, unknown>) || {}
|
||||
if (stageConfig.drafts_enabled === false) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Draft saving is not enabled for this round',
|
||||
message: 'Draft saving is not enabled for this stage',
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate draft expiry
|
||||
const draftExpiryDays = (settings.draft_expiry_days as number) || 30
|
||||
const draftExpiryDays = (stageConfig.draft_expiry_days as number) || 30
|
||||
const draftExpiresAt = new Date()
|
||||
draftExpiresAt.setDate(draftExpiresAt.getDate() + draftExpiryDays)
|
||||
|
||||
// Generate resume token
|
||||
const draftToken = `draft_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
|
||||
|
||||
// Find or create draft project for this email+round
|
||||
const programId = input.programId || stage.track.pipeline.programId
|
||||
|
||||
const existingDraft = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundId: round.id,
|
||||
programId,
|
||||
submittedByEmail: input.email,
|
||||
isDraft: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (existingDraft) {
|
||||
// Update existing draft
|
||||
const updated = await ctx.prisma.project.update({
|
||||
where: { id: existingDraft.id },
|
||||
data: {
|
||||
@@ -708,11 +673,9 @@ export const applicationRouter = router({
|
||||
return { projectId: updated.id, draftToken }
|
||||
}
|
||||
|
||||
// Create new draft project
|
||||
const project = await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId: round.programId,
|
||||
roundId: round.id,
|
||||
programId,
|
||||
title: input.title || 'Untitled Draft',
|
||||
isDraft: true,
|
||||
draftDataJson: input.draftDataJson as Prisma.InputJsonValue,
|
||||
@@ -764,7 +727,6 @@ export const applicationRouter = router({
|
||||
projectId: project.id,
|
||||
draftDataJson: project.draftDataJson,
|
||||
title: project.title,
|
||||
roundId: project.roundId,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -782,7 +744,7 @@ export const applicationRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id: input.projectId },
|
||||
include: { round: { include: { program: true } } },
|
||||
include: { program: true },
|
||||
})
|
||||
|
||||
// Verify token
|
||||
@@ -851,18 +813,6 @@ export const applicationRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-assign to first round if project has no roundId
|
||||
let draftAssignedRound: { id: string; name: string; entryNotificationType: string | null } | null = null
|
||||
if (!updated.roundId) {
|
||||
draftAssignedRound = await getFirstRoundForProgram(ctx.prisma, updated.programId)
|
||||
if (draftAssignedRound) {
|
||||
await ctx.prisma.project.update({
|
||||
where: { id: updated.id },
|
||||
data: { roundId: draftAssignedRound.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
try {
|
||||
await logAudit({
|
||||
@@ -875,7 +825,6 @@ export const applicationRouter = router({
|
||||
source: 'draft_submission',
|
||||
title: data.projectName,
|
||||
category: data.competitionCategory,
|
||||
autoAssignedRound: draftAssignedRound?.name || null,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
@@ -884,29 +833,10 @@ export const applicationRouter = router({
|
||||
// Never throw on audit failure
|
||||
}
|
||||
|
||||
// Send SUBMISSION_RECEIVED notification if the round is configured for it
|
||||
if (draftAssignedRound?.entryNotificationType === 'SUBMISSION_RECEIVED') {
|
||||
try {
|
||||
await notifyProjectTeam(updated.id, {
|
||||
type: NotificationTypes.SUBMISSION_RECEIVED,
|
||||
title: 'Submission Received',
|
||||
message: `Your submission "${data.projectName}" has been received and is now under review.`,
|
||||
linkUrl: `/team/projects/${updated.id}`,
|
||||
linkLabel: 'View Submission',
|
||||
metadata: {
|
||||
projectName: data.projectName,
|
||||
roundName: draftAssignedRound.name,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Never fail on notification
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
projectId: updated.id,
|
||||
message: `Thank you for applying to ${project.round?.program.name ?? 'the program'}!`,
|
||||
message: `Thank you for applying to ${project.program?.name ?? 'the program'}!`,
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user