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:
@@ -20,13 +20,10 @@ export const fileRouter = router({
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
// Find the file record to get the project and round info
|
||||
const file = await ctx.prisma.projectFile.findFirst({
|
||||
where: { bucket: input.bucket, objectKey: input.objectKey },
|
||||
select: {
|
||||
projectId: true,
|
||||
roundId: true,
|
||||
round: { select: { programId: true, sortOrder: true } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -37,11 +34,10 @@ export const fileRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user is assigned as jury, mentor, or team member for this project
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: file.projectId },
|
||||
select: { id: true, roundId: true },
|
||||
select: { id: true, stageId: true },
|
||||
}),
|
||||
ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { mentorId: ctx.user.id, projectId: file.projectId },
|
||||
@@ -66,23 +62,47 @@ export const fileRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// For jury members, verify round-scoped access:
|
||||
// File must belong to the jury's assigned round or a prior round in the same program
|
||||
if (juryAssignment && !mentorAssignment && !teamMembership && file.roundId && file.round) {
|
||||
const assignedRound = await ctx.prisma.round.findUnique({
|
||||
where: { id: juryAssignment.roundId },
|
||||
select: { programId: true, sortOrder: true },
|
||||
if (juryAssignment && !mentorAssignment && !teamMembership) {
|
||||
const assignedStage = await ctx.prisma.stage.findUnique({
|
||||
where: { id: juryAssignment.stageId },
|
||||
select: { trackId: true, sortOrder: true },
|
||||
})
|
||||
|
||||
if (assignedRound) {
|
||||
const sameProgram = assignedRound.programId === file.round.programId
|
||||
const priorOrSameRound = file.round.sortOrder <= assignedRound.sortOrder
|
||||
if (assignedStage) {
|
||||
const priorOrCurrentStages = await ctx.prisma.stage.findMany({
|
||||
where: {
|
||||
trackId: assignedStage.trackId,
|
||||
sortOrder: { lte: assignedStage.sortOrder },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!sameProgram || !priorOrSameRound) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this file',
|
||||
const stageIds = priorOrCurrentStages.map((s) => s.id)
|
||||
|
||||
const hasFileRequirement = await ctx.prisma.fileRequirement.findFirst({
|
||||
where: {
|
||||
stageId: { in: stageIds },
|
||||
files: { some: { bucket: input.bucket, objectKey: input.objectKey } },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!hasFileRequirement) {
|
||||
const fileInProject = await ctx.prisma.projectFile.findFirst({
|
||||
where: {
|
||||
bucket: input.bucket,
|
||||
objectKey: input.objectKey,
|
||||
requirementId: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!fileInProject) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this file',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +135,7 @@ export const fileRouter = router({
|
||||
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
|
||||
mimeType: z.string(),
|
||||
size: z.number().int().positive(),
|
||||
roundId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -129,16 +149,15 @@ export const fileRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate isLate flag if roundId is provided
|
||||
let isLate = false
|
||||
if (input.roundId) {
|
||||
const round = await ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { votingEndAt: true },
|
||||
if (input.stageId) {
|
||||
const stage = await ctx.prisma.stage.findUnique({
|
||||
where: { id: input.stageId },
|
||||
select: { windowCloseAt: true },
|
||||
})
|
||||
|
||||
if (round?.votingEndAt) {
|
||||
isLate = new Date() > round.votingEndAt
|
||||
if (stage?.windowCloseAt) {
|
||||
isLate = new Date() > stage.windowCloseAt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +176,6 @@ export const fileRouter = router({
|
||||
size: input.size,
|
||||
bucket,
|
||||
objectKey,
|
||||
roundId: input.roundId,
|
||||
isLate,
|
||||
},
|
||||
})
|
||||
@@ -173,7 +191,7 @@ export const fileRouter = router({
|
||||
projectId: input.projectId,
|
||||
fileName: input.fileName,
|
||||
fileType: input.fileType,
|
||||
roundId: input.roundId,
|
||||
stageId: input.stageId,
|
||||
isLate,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
@@ -244,7 +262,7 @@ export const fileRouter = router({
|
||||
listByProject: protectedProcedure
|
||||
.input(z.object({
|
||||
projectId: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
@@ -280,28 +298,36 @@ export const fileRouter = router({
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { projectId: input.projectId }
|
||||
if (input.roundId) {
|
||||
where.roundId = input.roundId
|
||||
if (input.stageId) {
|
||||
where.requirement = { stageId: input.stageId }
|
||||
}
|
||||
|
||||
return ctx.prisma.projectFile.findMany({
|
||||
where,
|
||||
include: {
|
||||
round: { select: { id: true, name: true, sortOrder: true } },
|
||||
requirement: { select: { id: true, name: true, description: true, isRequired: true } },
|
||||
requirement: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
isRequired: true,
|
||||
stageId: true,
|
||||
stage: { select: { id: true, name: true, sortOrder: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* List files for a project grouped by round
|
||||
* Returns files for the specified round + all prior rounds in the same program
|
||||
* List files for a project grouped by stage
|
||||
* Returns files for the specified stage + all prior stages in the same track
|
||||
*/
|
||||
listByProjectForRound: protectedProcedure
|
||||
listByProjectForStage: protectedProcedure
|
||||
.input(z.object({
|
||||
projectId: z.string(),
|
||||
roundId: z.string(),
|
||||
stageId: z.string(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
@@ -310,7 +336,7 @@ export const fileRouter = router({
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true, roundId: true },
|
||||
select: { id: true, stageId: true },
|
||||
}),
|
||||
ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { mentorId: ctx.user.id, projectId: input.projectId },
|
||||
@@ -336,68 +362,70 @@ export const fileRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
// Get the target round with its program and sortOrder
|
||||
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { programId: true, sortOrder: true },
|
||||
const targetStage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
select: { trackId: true, sortOrder: true },
|
||||
})
|
||||
|
||||
// Get all rounds in the same program with sortOrder <= target
|
||||
const eligibleRounds = await ctx.prisma.round.findMany({
|
||||
const eligibleStages = await ctx.prisma.stage.findMany({
|
||||
where: {
|
||||
programId: targetRound.programId,
|
||||
sortOrder: { lte: targetRound.sortOrder },
|
||||
trackId: targetStage.trackId,
|
||||
sortOrder: { lte: targetStage.sortOrder },
|
||||
},
|
||||
select: { id: true, name: true, sortOrder: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
|
||||
const eligibleStageIds = eligibleStages.map((s) => s.id)
|
||||
|
||||
// Get files for these rounds (or files with no roundId)
|
||||
const files = await ctx.prisma.projectFile.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
OR: [
|
||||
{ roundId: { in: eligibleRoundIds } },
|
||||
{ roundId: null },
|
||||
{ requirement: { stageId: { in: eligibleStageIds } } },
|
||||
{ requirementId: null },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
round: { select: { id: true, name: true, sortOrder: true } },
|
||||
requirement: { select: { id: true, name: true, description: true, isRequired: true } },
|
||||
requirement: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
isRequired: true,
|
||||
stageId: true,
|
||||
stage: { select: { id: true, name: true, sortOrder: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: 'asc' }],
|
||||
})
|
||||
|
||||
// Group by round
|
||||
const grouped: Array<{
|
||||
roundId: string | null
|
||||
roundName: string
|
||||
stageId: string | null
|
||||
stageName: string
|
||||
sortOrder: number
|
||||
files: typeof files
|
||||
}> = []
|
||||
|
||||
// Add "General" group for files with no round
|
||||
const generalFiles = files.filter((f) => !f.roundId)
|
||||
const generalFiles = files.filter((f) => !f.requirementId)
|
||||
if (generalFiles.length > 0) {
|
||||
grouped.push({
|
||||
roundId: null,
|
||||
roundName: 'General',
|
||||
stageId: null,
|
||||
stageName: 'General',
|
||||
sortOrder: -1,
|
||||
files: generalFiles,
|
||||
})
|
||||
}
|
||||
|
||||
// Add groups for each round
|
||||
for (const round of eligibleRounds) {
|
||||
const roundFiles = files.filter((f) => f.roundId === round.id)
|
||||
if (roundFiles.length > 0) {
|
||||
for (const stage of eligibleStages) {
|
||||
const stageFiles = files.filter((f) => f.requirement?.stageId === stage.id)
|
||||
if (stageFiles.length > 0) {
|
||||
grouped.push({
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
sortOrder: round.sortOrder,
|
||||
files: roundFiles,
|
||||
stageId: stage.id,
|
||||
stageName: stage.name,
|
||||
sortOrder: stage.sortOrder,
|
||||
files: stageFiles,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -673,24 +701,24 @@ export const fileRouter = router({
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* List file requirements for a round (available to any authenticated user)
|
||||
* List file requirements for a stage (available to any authenticated user)
|
||||
*/
|
||||
listRequirements: protectedProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.fileRequirement.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
where: { stageId: input.stageId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a file requirement for a round (admin only)
|
||||
* Create a file requirement for a stage (admin only)
|
||||
*/
|
||||
createRequirement: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
stageId: z.string(),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(1000).optional(),
|
||||
acceptedMimeTypes: z.array(z.string()).default([]),
|
||||
@@ -711,7 +739,7 @@ export const fileRouter = router({
|
||||
action: 'CREATE',
|
||||
entityType: 'FileRequirement',
|
||||
entityId: requirement.id,
|
||||
detailsJson: { name: input.name, roundId: input.roundId },
|
||||
detailsJson: { name: input.name, stageId: input.stageId },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
@@ -783,7 +811,7 @@ export const fileRouter = router({
|
||||
reorderRequirements: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
stageId: z.string(),
|
||||
orderedIds: z.array(z.string()),
|
||||
})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user