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:
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user