Decouple projects from rounds with RoundProject join table

Projects now exist at the program level instead of being locked to a
single round. A new RoundProject join table enables many-to-many
relationships with per-round status tracking. Rounds have sortOrder
for configurable progression paths.

- Add RoundProject model, programId on Project, sortOrder on Round
- Migration preserves existing data (roundId -> RoundProject entries)
- Update all routers to query through RoundProject join
- Add assign/remove/advance/reorder round endpoints
- Add Assign, Advance, Remove Projects dialogs on round detail page
- Add round reorder controls (up/down arrows) on rounds list
- Show all rounds on project detail page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 22:33:55 +01:00
parent 0d2bc4db7e
commit fd5e5222da
52 changed files with 1892 additions and 326 deletions

View File

@@ -12,10 +12,10 @@ export const roundRouter = router({
.query(async ({ ctx, input }) => {
return ctx.prisma.round.findMany({
where: { programId: input.programId },
orderBy: { createdAt: 'asc' },
orderBy: { sortOrder: 'asc' },
include: {
_count: {
select: { projects: true, assignments: true },
select: { roundProjects: true, assignments: true },
},
},
})
@@ -32,7 +32,7 @@ export const roundRouter = router({
include: {
program: true,
_count: {
select: { projects: true, assignments: true },
select: { roundProjects: true, assignments: true },
},
evaluationForms: {
where: { isActive: true },
@@ -64,7 +64,10 @@ export const roundRouter = router({
z.object({
programId: z.string(),
name: z.string().min(1).max(255),
roundType: z.enum(['FILTERING', 'EVALUATION', 'LIVE_EVENT']).default('EVALUATION'),
requiredReviews: z.number().int().min(1).max(10).default(3),
sortOrder: z.number().int().optional(),
settingsJson: z.record(z.unknown()).optional(),
votingStartAt: z.date().optional(),
votingEndAt: z.date().optional(),
})
@@ -80,8 +83,23 @@ export const roundRouter = router({
}
}
// Auto-set sortOrder if not provided (append to end)
let sortOrder = input.sortOrder
if (sortOrder === undefined) {
const maxOrder = await ctx.prisma.round.aggregate({
where: { programId: input.programId },
_max: { sortOrder: true },
})
sortOrder = (maxOrder._max.sortOrder ?? -1) + 1
}
const { settingsJson, sortOrder: _so, ...rest } = input
const round = await ctx.prisma.round.create({
data: input,
data: {
...rest,
sortOrder,
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
},
})
// Audit log
@@ -91,7 +109,7 @@ export const roundRouter = router({
action: 'CREATE',
entityType: 'Round',
entityId: round.id,
detailsJson: input,
detailsJson: { ...rest, settingsJson } as Prisma.InputJsonValue,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
@@ -234,7 +252,7 @@ export const roundRouter = router({
.query(async ({ ctx, input }) => {
const [totalProjects, totalAssignments, completedAssignments] =
await Promise.all([
ctx.prisma.project.count({ where: { roundId: input.id } }),
ctx.prisma.roundProject.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({
where: { roundId: input.id, isCompleted: true },
@@ -365,7 +383,7 @@ export const roundRouter = router({
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.id },
include: {
_count: { select: { projects: true, assignments: true } },
_count: { select: { roundProjects: true, assignments: true } },
},
})
@@ -383,7 +401,7 @@ export const roundRouter = router({
detailsJson: {
name: round.name,
status: round.status,
projectsDeleted: round._count.projects,
projectsDeleted: round._count.roundProjects,
assignmentsDeleted: round._count.assignments,
},
ipAddress: ctx.ip,
@@ -408,4 +426,202 @@ export const roundRouter = router({
})
return count > 0
}),
/**
* Assign projects from the program pool to a round
*/
assignProjects: adminProcedure
.input(
z.object({
roundId: z.string(),
projectIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
// Verify round exists and get programId
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
// Verify all projects belong to the same program
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.projectIds }, programId: round.programId },
select: { id: true },
})
if (projects.length !== input.projectIds.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Some projects do not belong to this program',
})
}
// Create RoundProject entries (skip duplicates)
const created = await ctx.prisma.roundProject.createMany({
data: input.projectIds.map((projectId) => ({
roundId: input.roundId,
projectId,
status: 'SUBMITTED' as const,
})),
skipDuplicates: true,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'ASSIGN_PROJECTS_TO_ROUND',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { projectCount: created.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { assigned: created.count }
}),
/**
* Remove projects from a round
*/
removeProjects: adminProcedure
.input(
z.object({
roundId: z.string(),
projectIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
const deleted = await ctx.prisma.roundProject.deleteMany({
where: {
roundId: input.roundId,
projectId: { in: input.projectIds },
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'REMOVE_PROJECTS_FROM_ROUND',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { projectCount: deleted.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { removed: deleted.count }
}),
/**
* Advance projects from one round to the next
* Creates new RoundProject entries in the target round (keeps them in source round too)
*/
advanceProjects: adminProcedure
.input(
z.object({
fromRoundId: z.string(),
toRoundId: z.string(),
projectIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
// Verify both rounds exist and belong to the same program
const [fromRound, toRound] = await Promise.all([
ctx.prisma.round.findUniqueOrThrow({ where: { id: input.fromRoundId } }),
ctx.prisma.round.findUniqueOrThrow({ where: { id: input.toRoundId } }),
])
if (fromRound.programId !== toRound.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Rounds must belong to the same program',
})
}
// Verify all projects are in the source round
const sourceProjects = await ctx.prisma.roundProject.findMany({
where: {
roundId: input.fromRoundId,
projectId: { in: input.projectIds },
},
select: { projectId: true },
})
if (sourceProjects.length !== input.projectIds.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Some projects are not in the source round',
})
}
// Create entries in target round (skip duplicates)
const created = await ctx.prisma.roundProject.createMany({
data: input.projectIds.map((projectId) => ({
roundId: input.toRoundId,
projectId,
status: 'SUBMITTED' as const,
})),
skipDuplicates: true,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'ADVANCE_PROJECTS',
entityType: 'Round',
entityId: input.toRoundId,
detailsJson: {
fromRoundId: input.fromRoundId,
toRoundId: input.toRoundId,
projectCount: created.count,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { advanced: created.count }
}),
/**
* Reorder rounds within a program
*/
reorder: adminProcedure
.input(
z.object({
programId: z.string(),
roundIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
// Update sortOrder for each round based on array position
await ctx.prisma.$transaction(
input.roundIds.map((roundId, index) =>
ctx.prisma.round.update({
where: { id: roundId },
data: { sortOrder: index },
})
)
)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'REORDER_ROUNDS',
entityType: 'Program',
entityId: input.programId,
detailsJson: { roundIds: input.roundIds },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
})