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:
@@ -22,36 +22,42 @@ export const applicantRouter = router({
|
||||
getSubmissionBySlug: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Find the round by slug
|
||||
const round = await ctx.prisma.round.findFirst({
|
||||
const stage = await ctx.prisma.stage.findFirst({
|
||||
where: { slug: input.slug },
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true, description: true } },
|
||||
track: {
|
||||
include: {
|
||||
pipeline: {
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true, description: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!round) {
|
||||
if (!stage) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Round not found',
|
||||
message: 'Stage not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if submissions are open
|
||||
const now = new Date()
|
||||
const isOpen = round.submissionDeadline
|
||||
? now < round.submissionDeadline
|
||||
: round.status === 'ACTIVE'
|
||||
const isOpen = stage.windowCloseAt
|
||||
? now < stage.windowCloseAt
|
||||
: stage.status === 'STAGE_ACTIVE'
|
||||
|
||||
return {
|
||||
round: {
|
||||
id: round.id,
|
||||
name: round.name,
|
||||
slug: round.slug,
|
||||
submissionDeadline: round.submissionDeadline,
|
||||
stage: {
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
slug: stage.slug,
|
||||
windowCloseAt: stage.windowCloseAt,
|
||||
isOpen,
|
||||
},
|
||||
program: round.program,
|
||||
program: stage.track.pipeline.program,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -59,7 +65,7 @@ export const applicantRouter = router({
|
||||
* Get the current user's submission for a round (as submitter or team member)
|
||||
*/
|
||||
getMySubmission: protectedProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(z.object({ stageId: z.string().optional(), programId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Only applicants can use this
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
@@ -69,25 +75,29 @@ export const applicantRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{
|
||||
teamMembers: {
|
||||
some: { userId: ctx.user.id },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
files: true,
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
const where: Record<string, unknown> = {
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{
|
||||
teamMembers: {
|
||||
some: { userId: ctx.user.id },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if (input.stageId) {
|
||||
where.stageStates = { some: { stageId: input.stageId } }
|
||||
}
|
||||
if (input.programId) {
|
||||
where.programId = input.programId
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where,
|
||||
include: {
|
||||
files: true,
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
teamMembers: {
|
||||
include: {
|
||||
user: {
|
||||
@@ -116,14 +126,14 @@ export const applicantRouter = router({
|
||||
saveSubmission: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
projectId: z.string().optional(), // If updating existing
|
||||
programId: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
title: z.string().min(1).max(500),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
metadataJson: z.record(z.unknown()).optional(),
|
||||
submit: z.boolean().default(false), // Whether to submit or just save draft
|
||||
submit: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -135,20 +145,9 @@ export const applicantRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Check if the round is open for submissions
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
if (round.submissionDeadline && now > round.submissionDeadline) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Submission deadline has passed',
|
||||
})
|
||||
}
|
||||
|
||||
const { projectId, submit, roundId, metadataJson, ...data } = input
|
||||
const { projectId, submit, programId, metadataJson, ...data } = input
|
||||
|
||||
if (projectId) {
|
||||
// Update existing
|
||||
@@ -193,17 +192,17 @@ export const applicantRouter = router({
|
||||
|
||||
return project
|
||||
} else {
|
||||
// Get the round to find the programId
|
||||
const roundForCreate = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { programId: true },
|
||||
})
|
||||
if (!programId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'programId is required when creating a new submission',
|
||||
})
|
||||
}
|
||||
|
||||
// Create new project
|
||||
const project = await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId: roundForCreate.programId,
|
||||
roundId,
|
||||
programId,
|
||||
...data,
|
||||
metadataJson: metadataJson as unknown ?? undefined,
|
||||
submittedByUserId: ctx.user.id,
|
||||
@@ -240,7 +239,7 @@ export const applicantRouter = router({
|
||||
fileName: z.string(),
|
||||
mimeType: z.string(),
|
||||
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
||||
roundId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
requirementId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
@@ -269,9 +268,6 @@ export const applicantRouter = router({
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
round: { select: { id: true, votingStartAt: true, settingsJson: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
@@ -306,37 +302,9 @@ export const applicantRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
// Check round upload deadline policy if roundId provided
|
||||
let isLate = false
|
||||
const targetRoundId = input.roundId || project.roundId
|
||||
if (targetRoundId) {
|
||||
const round = input.roundId
|
||||
? await ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { votingStartAt: true, settingsJson: true },
|
||||
})
|
||||
: project.round
|
||||
|
||||
if (round) {
|
||||
const settings = round.settingsJson as Record<string, unknown> | null
|
||||
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
|
||||
const now = new Date()
|
||||
const roundStarted = round.votingStartAt && now > round.votingStartAt
|
||||
|
||||
if (roundStarted && uploadPolicy === 'BLOCK') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Uploads are blocked after the round has started',
|
||||
})
|
||||
}
|
||||
|
||||
if (roundStarted && uploadPolicy === 'ALLOW_LATE') {
|
||||
isLate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Can't upload if already submitted (unless round allows it)
|
||||
// Can't upload if already submitted
|
||||
if (project.submittedAt && !isLate) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
@@ -355,7 +323,7 @@ export const applicantRouter = router({
|
||||
bucket: SUBMISSIONS_BUCKET,
|
||||
objectKey,
|
||||
isLate,
|
||||
roundId: targetRoundId,
|
||||
stageId: input.stageId || null,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -372,7 +340,7 @@ export const applicantRouter = router({
|
||||
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
||||
bucket: z.string(),
|
||||
objectKey: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
isLate: z.boolean().optional(),
|
||||
requirementId: z.string().optional(),
|
||||
})
|
||||
@@ -410,15 +378,14 @@ export const applicantRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const { projectId, roundId, isLate, requirementId, ...fileData } = input
|
||||
const { projectId, stageId, isLate, requirementId, ...fileData } = input
|
||||
|
||||
// Delete existing file: by requirementId if provided, otherwise by fileType+roundId
|
||||
// Delete existing file: by requirementId if provided, otherwise by fileType
|
||||
if (requirementId) {
|
||||
await ctx.prisma.projectFile.deleteMany({
|
||||
where: {
|
||||
projectId,
|
||||
requirementId,
|
||||
...(roundId ? { roundId } : {}),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
@@ -426,17 +393,16 @@ export const applicantRouter = router({
|
||||
where: {
|
||||
projectId,
|
||||
fileType: input.fileType,
|
||||
...(roundId ? { roundId } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Create new file record
|
||||
// Create new file record (roundId column kept null for new data)
|
||||
const file = await ctx.prisma.projectFile.create({
|
||||
data: {
|
||||
projectId,
|
||||
...fileData,
|
||||
roundId: roundId || null,
|
||||
roundId: null,
|
||||
isLate: isLate || false,
|
||||
requirementId: requirementId || null,
|
||||
},
|
||||
@@ -543,11 +509,7 @@ export const applicantRouter = router({
|
||||
],
|
||||
},
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
files: true,
|
||||
teamMembers: {
|
||||
include: {
|
||||
@@ -686,11 +648,7 @@ export const applicantRouter = router({
|
||||
],
|
||||
},
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
files: true,
|
||||
teamMembers: {
|
||||
include: {
|
||||
@@ -764,7 +722,6 @@ export const applicantRouter = router({
|
||||
return {
|
||||
teamMembers: project.teamMembers,
|
||||
submittedBy: project.submittedBy,
|
||||
roundId: project.roundId,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1166,11 +1123,7 @@ export const applicantRouter = router({
|
||||
],
|
||||
},
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true, status: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { id: true, name: true, year: true, status: true } },
|
||||
files: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
@@ -1200,7 +1153,7 @@ export const applicantRouter = router({
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
return { project: null, openRounds: [], timeline: [], currentStatus: null }
|
||||
return { project: null, openStages: [], timeline: [], currentStatus: null }
|
||||
}
|
||||
|
||||
const currentStatus = project.status ?? 'SUBMITTED'
|
||||
@@ -1285,32 +1238,25 @@ export const applicantRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
// Find open rounds in the same program where documents can be submitted
|
||||
const programId = project.round?.programId || project.programId
|
||||
const now = new Date()
|
||||
|
||||
const openRounds = programId
|
||||
? await ctx.prisma.round.findMany({
|
||||
const programId = project.programId
|
||||
const openStages = programId
|
||||
? await ctx.prisma.stage.findMany({
|
||||
where: {
|
||||
programId,
|
||||
status: 'ACTIVE',
|
||||
track: { pipeline: { programId } },
|
||||
status: 'STAGE_ACTIVE',
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
stageType: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
},
|
||||
})
|
||||
: []
|
||||
|
||||
// Filter: only rounds that still accept uploads
|
||||
const uploadableRounds = openRounds.filter((round) => {
|
||||
const settings = round.settingsJson as Record<string, unknown> | null
|
||||
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
|
||||
const roundStarted = round.votingStartAt && now > round.votingStartAt
|
||||
|
||||
// If deadline passed and policy is BLOCK, skip
|
||||
if (roundStarted && uploadPolicy === 'BLOCK') return false
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Determine user's role in the project
|
||||
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
||||
const isTeamLead = project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD'
|
||||
@@ -1321,7 +1267,7 @@ export const applicantRouter = router({
|
||||
isTeamLead,
|
||||
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
|
||||
},
|
||||
openRounds: uploadableRounds,
|
||||
openStages,
|
||||
timeline,
|
||||
currentStatus,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user