MOPC-App/src/server/routers/roundEngine.ts

304 lines
8.6 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import {
activateRound,
closeRound,
archiveRound,
reopenRound,
transitionProject,
batchTransitionProjects,
getProjectRoundStates,
getProjectRoundState,
} from '../services/round-engine'
const projectRoundStateEnum = z.enum([
'PENDING',
'IN_PROGRESS',
'PASSED',
'REJECTED',
'COMPLETED',
'WITHDRAWN',
])
export const roundEngineRouter = router({
/**
* Activate a round: ROUND_DRAFT ROUND_ACTIVE
*/
activate: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await activateRound(input.roundId, ctx.user.id, ctx.prisma)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to activate round',
})
}
return result
}),
/**
* Close a round: ROUND_ACTIVE ROUND_CLOSED
*/
close: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await closeRound(input.roundId, ctx.user.id, ctx.prisma)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to close round',
})
}
return result
}),
/**
* Reopen a round: ROUND_CLOSED ROUND_ACTIVE
* Pauses any subsequent active rounds in the same competition.
*/
reopen: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await reopenRound(input.roundId, ctx.user.id, ctx.prisma)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to reopen round',
})
}
return result
}),
/**
* Archive a round: ROUND_CLOSED ROUND_ARCHIVED
*/
archive: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await archiveRound(input.roundId, ctx.user.id, ctx.prisma)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to archive round',
})
}
return result
}),
/**
* Transition a single project within a round
*/
transitionProject: adminProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
newState: projectRoundStateEnum,
})
)
.mutation(async ({ ctx, input }) => {
const result = await transitionProject(
input.projectId,
input.roundId,
input.newState,
ctx.user.id,
ctx.prisma,
)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to transition project',
})
}
return result
}),
/**
* Batch transition multiple projects within a round
*/
batchTransition: adminProcedure
.input(
z.object({
projectIds: z.array(z.string()).min(1),
roundId: z.string(),
newState: projectRoundStateEnum,
})
)
.mutation(async ({ ctx, input }) => {
return batchTransitionProjects(
input.projectIds,
input.roundId,
input.newState,
ctx.user.id,
ctx.prisma,
)
}),
/**
* Get all project round states for a round
*/
getProjectStates: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return getProjectRoundStates(input.roundId, ctx.prisma)
}),
/**
* Get a single project's state within a round
*/
getProjectState: protectedProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
})
)
.query(async ({ ctx, input }) => {
return getProjectRoundState(input.projectId, input.roundId, ctx.prisma)
}),
/**
* Remove a project from a round (and all subsequent rounds in that competition).
* The project remains in all prior rounds.
*/
removeFromRound: adminProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Get the round to know its competition and sort order
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, competitionId: true, sortOrder: true },
})
// Find all rounds at this sort order or later in the same competition
const roundsToRemoveFrom = await ctx.prisma.round.findMany({
where: {
competitionId: round.competitionId,
sortOrder: { gte: round.sortOrder },
},
select: { id: true },
})
const roundIds = roundsToRemoveFrom.map((r) => r.id)
// Delete ProjectRoundState entries for this project in all affected rounds
const deleted = await ctx.prisma.projectRoundState.deleteMany({
where: {
projectId: input.projectId,
roundId: { in: roundIds },
},
})
// Check if the project is still in any round at all
const remainingStates = await ctx.prisma.projectRoundState.count({
where: { projectId: input.projectId },
})
// If no longer in any round, reset project status back to SUBMITTED
if (remainingStates === 0) {
await ctx.prisma.project.update({
where: { id: input.projectId },
data: { status: 'SUBMITTED' },
})
}
return { success: true, removedFromRounds: deleted.count }
}),
/**
* Batch remove projects from a round (and all subsequent rounds).
*/
batchRemoveFromRound: adminProcedure
.input(
z.object({
projectIds: z.array(z.string()).min(1),
roundId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, competitionId: true, sortOrder: true },
})
const roundsToRemoveFrom = await ctx.prisma.round.findMany({
where: {
competitionId: round.competitionId,
sortOrder: { gte: round.sortOrder },
},
select: { id: true },
})
const roundIds = roundsToRemoveFrom.map((r) => r.id)
const deleted = await ctx.prisma.projectRoundState.deleteMany({
where: {
projectId: { in: input.projectIds },
roundId: { in: roundIds },
},
})
// For projects with no remaining round states, reset to SUBMITTED
const projectsStillInRounds = await ctx.prisma.projectRoundState.findMany({
where: { projectId: { in: input.projectIds } },
select: { projectId: true },
distinct: ['projectId'],
})
const stillInRoundIds = new Set(projectsStillInRounds.map((p) => p.projectId))
const orphanedIds = input.projectIds.filter((id) => !stillInRoundIds.has(id))
if (orphanedIds.length > 0) {
await ctx.prisma.project.updateMany({
where: { id: { in: orphanedIds } },
data: { status: 'SUBMITTED' },
})
}
return { success: true, removedCount: deleted.count }
}),
/**
* Retroactive document check: auto-PASS any PENDING/IN_PROGRESS projects
* that already have all required documents uploaded for this round.
* Useful for rounds activated before the auto-transition feature was deployed.
*/
checkDocumentCompletion: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const { batchCheckRequirementsAndTransition } = await import('../services/round-engine')
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: {
roundId: input.roundId,
state: { in: ['PENDING', 'IN_PROGRESS'] },
},
select: { projectId: true },
})
if (projectStates.length === 0) {
return { transitionedCount: 0, checkedCount: 0, projectIds: [] }
}
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
const result = await batchCheckRequirementsAndTransition(
input.roundId,
projectIds,
ctx.user.id,
ctx.prisma,
)
return {
transitionedCount: result.transitionedCount,
checkedCount: projectIds.length,
projectIds: result.projectIds,
}
}),
})