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:
@@ -1,985 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { router, protectedProcedure, adminProcedure } from "../trpc";
|
||||
import {
|
||||
notifyRoundJury,
|
||||
notifyAdmins,
|
||||
NotificationTypes,
|
||||
} from "../services/in-app-notification";
|
||||
import { logAudit } from "@/server/utils/audit";
|
||||
import { runFilteringJob } from "./filtering";
|
||||
import { prisma as globalPrisma } from "@/lib/prisma";
|
||||
|
||||
// Valid round status transitions (state machine)
|
||||
const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
|
||||
DRAFT: ["ACTIVE", "ARCHIVED"], // Draft can be activated or archived
|
||||
ACTIVE: ["CLOSED"], // Active rounds can only be closed
|
||||
CLOSED: ["ARCHIVED"], // Closed rounds can be archived
|
||||
ARCHIVED: [], // Archived is terminal — no transitions out
|
||||
};
|
||||
|
||||
export const roundRouter = router({
|
||||
/**
|
||||
* List rounds for a program
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.round.findMany({
|
||||
where: { programId: input.programId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true, assignments: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* List all rounds across all programs (admin only, for messaging/filtering)
|
||||
*/
|
||||
listAll: adminProcedure.query(async ({ ctx }) => {
|
||||
return ctx.prisma.round.findMany({
|
||||
orderBy: [{ program: { name: "asc" } }, { sortOrder: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
programId: true,
|
||||
program: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* List rounds for a program (alias for list)
|
||||
*/
|
||||
listByProgram: protectedProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.round.findMany({
|
||||
where: { programId: input.programId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single round with stats
|
||||
*/
|
||||
get: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
program: true,
|
||||
_count: {
|
||||
select: { projects: true, assignments: true },
|
||||
},
|
||||
evaluationForms: {
|
||||
where: { isActive: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get evaluation stats + progress in parallel (avoids duplicate groupBy in getProgress)
|
||||
const [evaluationStats, totalAssignments, completedAssignments] =
|
||||
await Promise.all([
|
||||
ctx.prisma.evaluation.groupBy({
|
||||
by: ["status"],
|
||||
where: {
|
||||
assignment: { roundId: input.id },
|
||||
},
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.id } }),
|
||||
ctx.prisma.assignment.count({
|
||||
where: { roundId: input.id, isCompleted: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const evaluationsByStatus = evaluationStats.reduce(
|
||||
(acc, curr) => {
|
||||
acc[curr.status] = curr._count;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
return {
|
||||
...round,
|
||||
evaluationStats,
|
||||
// Inline progress data (eliminates need for separate getProgress call)
|
||||
progress: {
|
||||
totalProjects: round._count.projects,
|
||||
totalAssignments,
|
||||
completedAssignments,
|
||||
completionPercentage:
|
||||
totalAssignments > 0
|
||||
? Math.round((completedAssignments / totalAssignments) * 100)
|
||||
: 0,
|
||||
evaluationsByStatus,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new round (admin only)
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
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(0).max(10).default(3),
|
||||
minAssignmentsPerJuror: z.number().int().min(1).max(50).default(5),
|
||||
maxAssignmentsPerJuror: z.number().int().min(1).max(100).default(20),
|
||||
sortOrder: z.number().int().optional(),
|
||||
settingsJson: z.record(z.unknown()).optional(),
|
||||
votingStartAt: z.date().optional(),
|
||||
votingEndAt: z.date().optional(),
|
||||
submissionStartDate: z.date().optional(),
|
||||
submissionEndDate: z.date().optional(),
|
||||
lateSubmissionGrace: z.number().int().min(0).max(720).optional(),
|
||||
entryNotificationType: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Validate assignment constraints
|
||||
if (input.minAssignmentsPerJuror > input.maxAssignmentsPerJuror) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Min assignments per juror must be less than or equal to max",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate dates
|
||||
if (input.votingStartAt && input.votingEndAt) {
|
||||
if (input.votingEndAt <= input.votingStartAt) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "End date must be after start date",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.submissionStartDate && input.submissionEndDate) {
|
||||
if (input.submissionEndDate <= input.submissionStartDate) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Submission end date must be after start date",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Auto-activate if voting start date is in the past
|
||||
const now = new Date();
|
||||
const shouldAutoActivate =
|
||||
input.votingStartAt && input.votingStartAt <= now;
|
||||
|
||||
const round = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.round.create({
|
||||
data: {
|
||||
...rest,
|
||||
sortOrder,
|
||||
status: shouldAutoActivate ? "ACTIVE" : "DRAFT",
|
||||
settingsJson: (settingsJson as Prisma.InputJsonValue) ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// For FILTERING rounds, automatically move all projects from the program to this round
|
||||
if (input.roundType === "FILTERING") {
|
||||
await tx.project.updateMany({
|
||||
where: {
|
||||
programId: input.programId,
|
||||
roundId: { not: created.id },
|
||||
},
|
||||
data: {
|
||||
roundId: created.id,
|
||||
status: "SUBMITTED",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: "CREATE",
|
||||
entityType: "Round",
|
||||
entityId: created.id,
|
||||
detailsJson: { ...rest, settingsJson } as Record<string, unknown>,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return round;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update round details (admin only)
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-z0-9-]+$/)
|
||||
.optional()
|
||||
.nullable(),
|
||||
roundType: z.enum(["FILTERING", "EVALUATION", "LIVE_EVENT"]).optional(),
|
||||
requiredReviews: z.number().int().min(0).max(10).optional(),
|
||||
minAssignmentsPerJuror: z.number().int().min(1).max(50).optional(),
|
||||
maxAssignmentsPerJuror: z.number().int().min(1).max(100).optional(),
|
||||
submissionDeadline: z.date().optional().nullable(),
|
||||
votingStartAt: z.date().optional().nullable(),
|
||||
votingEndAt: z.date().optional().nullable(),
|
||||
settingsJson: z.record(z.unknown()).optional(),
|
||||
entryNotificationType: z.string().optional().nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, settingsJson, ...data } = input;
|
||||
|
||||
// Validate dates if both provided
|
||||
if (data.votingStartAt && data.votingEndAt) {
|
||||
if (data.votingEndAt <= data.votingStartAt) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "End date must be after start date",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate assignment constraints if either is provided
|
||||
if (
|
||||
data.minAssignmentsPerJuror !== undefined ||
|
||||
data.maxAssignmentsPerJuror !== undefined
|
||||
) {
|
||||
const existingRound = await ctx.prisma.round.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
minAssignmentsPerJuror: true,
|
||||
maxAssignmentsPerJuror: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
const newMin =
|
||||
data.minAssignmentsPerJuror ??
|
||||
existingRound?.minAssignmentsPerJuror ??
|
||||
5;
|
||||
const newMax =
|
||||
data.maxAssignmentsPerJuror ??
|
||||
existingRound?.maxAssignmentsPerJuror ??
|
||||
20;
|
||||
if (newMin > newMax) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Min assignments per juror must be less than or equal to max",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should auto-activate (if voting start is in the past and round is DRAFT)
|
||||
const now = new Date();
|
||||
let autoActivate = false;
|
||||
if (data.votingStartAt && data.votingStartAt <= now) {
|
||||
const existingRound = await ctx.prisma.round.findUnique({
|
||||
where: { id },
|
||||
select: { status: true },
|
||||
});
|
||||
if (existingRound?.status === "DRAFT") {
|
||||
autoActivate = true;
|
||||
}
|
||||
}
|
||||
|
||||
const round = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.round.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(autoActivate && { status: "ACTIVE" }),
|
||||
settingsJson: (settingsJson as Prisma.InputJsonValue) ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: "UPDATE",
|
||||
entityType: "Round",
|
||||
entityId: id,
|
||||
detailsJson: { ...data, settingsJson } as Record<string, unknown>,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
return round;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update round status (admin only)
|
||||
*/
|
||||
updateStatus: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
status: z.enum(["DRAFT", "ACTIVE", "CLOSED", "ARCHIVED"]),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Get previous status and voting dates for audit
|
||||
const previousRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
select: { status: true, votingStartAt: true, votingEndAt: true },
|
||||
});
|
||||
|
||||
// Validate status transition
|
||||
const allowedTransitions =
|
||||
VALID_ROUND_TRANSITIONS[previousRound.status] || [];
|
||||
if (!allowedTransitions.includes(input.status)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invalid status transition: cannot change from ${previousRound.status} to ${input.status}. Allowed transitions: ${allowedTransitions.join(", ") || "none (terminal state)"}`,
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// When activating a round, if votingStartAt is in the future, update it to now
|
||||
// This ensures voting actually starts when the admin opens the round
|
||||
let votingStartAtUpdated = false;
|
||||
const updateData: Parameters<typeof ctx.prisma.round.update>[0]["data"] =
|
||||
{
|
||||
status: input.status,
|
||||
};
|
||||
|
||||
if (input.status === "ACTIVE" && previousRound.status !== "ACTIVE") {
|
||||
if (previousRound.votingStartAt && previousRound.votingStartAt > now) {
|
||||
// Set to 1 minute in the past to ensure voting is immediately open
|
||||
const oneMinuteAgo = new Date(now.getTime() - 60 * 1000);
|
||||
updateData.votingStartAt = oneMinuteAgo;
|
||||
votingStartAtUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Map status to specific action name
|
||||
const statusActionMap: Record<string, string> = {
|
||||
ACTIVE: "ROUND_ACTIVATED",
|
||||
CLOSED: "ROUND_CLOSED",
|
||||
ARCHIVED: "ROUND_ARCHIVED",
|
||||
};
|
||||
const action = statusActionMap[input.status] || "UPDATE_STATUS";
|
||||
|
||||
const round = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.round.update({
|
||||
where: { id: input.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action,
|
||||
entityType: "Round",
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
status: input.status,
|
||||
previousStatus: previousRound.status,
|
||||
...(votingStartAtUpdated && {
|
||||
votingStartAtUpdated: true,
|
||||
previousVotingStartAt: previousRound.votingStartAt,
|
||||
newVotingStartAt: now,
|
||||
}),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Notify jury members when round is activated
|
||||
if (input.status === "ACTIVE" && previousRound.status !== "ACTIVE") {
|
||||
// Get round details and assignment counts per user
|
||||
const roundDetails = await ctx.prisma.round.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Get count of distinct jury members assigned
|
||||
const juryCount = await ctx.prisma.assignment.groupBy({
|
||||
by: ["userId"],
|
||||
where: { roundId: input.id },
|
||||
_count: true,
|
||||
});
|
||||
|
||||
if (roundDetails && juryCount.length > 0) {
|
||||
const deadline = roundDetails.votingEndAt
|
||||
? new Date(roundDetails.votingEndAt).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// Notify all jury members with assignments in this round
|
||||
await notifyRoundJury(input.id, {
|
||||
type: NotificationTypes.ROUND_NOW_OPEN,
|
||||
title: `${roundDetails.name} is Now Open`,
|
||||
message: `The evaluation round is now open. Please review your assigned projects and submit your evaluations before the deadline.`,
|
||||
linkUrl: `/jury/assignments`,
|
||||
linkLabel: "Start Evaluating",
|
||||
priority: "high",
|
||||
metadata: {
|
||||
roundName: roundDetails.name,
|
||||
projectCount: roundDetails._count.assignments,
|
||||
deadline,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-run filtering when a FILTERING round is closed (if enabled in settings)
|
||||
const roundSettings =
|
||||
(round.settingsJson as Record<string, unknown>) || {};
|
||||
const autoFilterEnabled = roundSettings.autoFilterOnClose !== false; // Default to true
|
||||
if (
|
||||
input.status === "CLOSED" &&
|
||||
round.roundType === "FILTERING" &&
|
||||
autoFilterEnabled
|
||||
) {
|
||||
try {
|
||||
const [filteringRules, projectCount] = await Promise.all([
|
||||
ctx.prisma.filteringRule.findMany({
|
||||
where: { roundId: input.id, isActive: true },
|
||||
}),
|
||||
ctx.prisma.project.count({ where: { roundId: input.id } }),
|
||||
]);
|
||||
|
||||
// Check for existing running job
|
||||
const existingJob = await ctx.prisma.filteringJob.findFirst({
|
||||
where: { roundId: input.id, status: "RUNNING" },
|
||||
});
|
||||
|
||||
if (filteringRules.length > 0 && projectCount > 0 && !existingJob) {
|
||||
// Create filtering job
|
||||
const job = await globalPrisma.filteringJob.create({
|
||||
data: {
|
||||
roundId: input.id,
|
||||
status: "PENDING",
|
||||
totalProjects: projectCount,
|
||||
},
|
||||
});
|
||||
|
||||
// Start background execution (non-blocking)
|
||||
setImmediate(() => {
|
||||
runFilteringJob(job.id, input.id, ctx.user.id).catch(
|
||||
console.error,
|
||||
);
|
||||
});
|
||||
|
||||
// Notify admins that auto-filtering has started
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.FILTERING_COMPLETE,
|
||||
title: "Auto-Filtering Started",
|
||||
message: `Filtering automatically started for "${round.name}" after closing. ${projectCount} projects will be processed.`,
|
||||
linkUrl: `/admin/rounds/${input.id}/filtering`,
|
||||
linkLabel: "View Progress",
|
||||
metadata: {
|
||||
roundId: input.id,
|
||||
roundName: round.name,
|
||||
projectCount,
|
||||
ruleCount: filteringRules.length,
|
||||
autoTriggered: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Auto-filtering failure should not block round closure
|
||||
console.error("[Auto-Filtering] Failed to start:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return round;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check if voting is currently open for a round
|
||||
*/
|
||||
isVotingOpen: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const isOpen =
|
||||
round.status === "ACTIVE" &&
|
||||
round.votingStartAt !== null &&
|
||||
round.votingEndAt !== null &&
|
||||
now >= round.votingStartAt &&
|
||||
now <= round.votingEndAt;
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
startsAt: round.votingStartAt,
|
||||
endsAt: round.votingEndAt,
|
||||
status: round.status,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get round progress statistics
|
||||
*/
|
||||
getProgress: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [totalProjects, totalAssignments, completedAssignments] =
|
||||
await Promise.all([
|
||||
ctx.prisma.project.count({ where: { roundId: input.id } }),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.id } }),
|
||||
ctx.prisma.assignment.count({
|
||||
where: { roundId: input.id, isCompleted: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const evaluationsByStatus = await ctx.prisma.evaluation.groupBy({
|
||||
by: ["status"],
|
||||
where: {
|
||||
assignment: { roundId: input.id },
|
||||
},
|
||||
_count: true,
|
||||
});
|
||||
|
||||
return {
|
||||
totalProjects,
|
||||
totalAssignments,
|
||||
completedAssignments,
|
||||
completionPercentage:
|
||||
totalAssignments > 0
|
||||
? Math.round((completedAssignments / totalAssignments) * 100)
|
||||
: 0,
|
||||
evaluationsByStatus: evaluationsByStatus.reduce(
|
||||
(acc, curr) => {
|
||||
acc[curr.status] = curr._count;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update or create evaluation form for a round (admin only)
|
||||
*/
|
||||
updateEvaluationForm: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
criteria: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
type: z
|
||||
.enum(["numeric", "text", "boolean", "section_header"])
|
||||
.default("numeric"),
|
||||
// Numeric fields
|
||||
scale: z.number().int().min(1).max(10).optional(),
|
||||
weight: z.number().optional(),
|
||||
required: z.boolean().optional(),
|
||||
// Text fields
|
||||
maxLength: z.number().int().min(1).max(10000).optional(),
|
||||
placeholder: z.string().optional(),
|
||||
// Boolean fields
|
||||
trueLabel: z.string().optional(),
|
||||
falseLabel: z.string().optional(),
|
||||
// Conditional visibility
|
||||
condition: z
|
||||
.object({
|
||||
criterionId: z.string(),
|
||||
operator: z.enum(["equals", "greaterThan", "lessThan"]),
|
||||
value: z.union([z.number(), z.string(), z.boolean()]),
|
||||
})
|
||||
.optional(),
|
||||
// Section grouping
|
||||
sectionId: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId, criteria } = input;
|
||||
|
||||
// Check if there are existing evaluations
|
||||
const existingEvaluations = await ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { roundId },
|
||||
status: { in: ["SUBMITTED", "LOCKED"] },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEvaluations > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Cannot modify criteria after evaluations have been submitted",
|
||||
});
|
||||
}
|
||||
|
||||
// Get or create the active evaluation form
|
||||
const existingForm = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { roundId, isActive: true },
|
||||
});
|
||||
|
||||
let form;
|
||||
|
||||
if (existingForm) {
|
||||
// Update existing form
|
||||
form = await ctx.prisma.evaluationForm.update({
|
||||
where: { id: existingForm.id },
|
||||
data: { criteriaJson: criteria },
|
||||
});
|
||||
} else {
|
||||
// Create new form
|
||||
form = await ctx.prisma.evaluationForm.create({
|
||||
data: {
|
||||
roundId,
|
||||
criteriaJson: criteria,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: "UPDATE_EVALUATION_FORM",
|
||||
entityType: "EvaluationForm",
|
||||
entityId: form.id,
|
||||
detailsJson: { roundId, criteriaCount: criteria.length },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return form;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get evaluation form for a round
|
||||
*/
|
||||
getEvaluationForm: protectedProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.evaluationForm.findFirst({
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a round (admin only)
|
||||
* Cascades to projects, assignments, evaluations, etc.
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
_count: { select: { projects: true, assignments: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: "DELETE",
|
||||
entityType: "Round",
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
name: round.name,
|
||||
status: round.status,
|
||||
projectsDeleted: round._count.projects,
|
||||
assignmentsDeleted: round._count.assignments,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
// Reset status for projects that will lose their roundId (ON DELETE SET NULL)
|
||||
await tx.project.updateMany({
|
||||
where: { roundId: input.id },
|
||||
data: { status: "SUBMITTED" },
|
||||
});
|
||||
|
||||
// Delete evaluations first to avoid FK constraint on Evaluation.formId
|
||||
// (formId FK may not have CASCADE in older DB schemas)
|
||||
await tx.evaluation.deleteMany({
|
||||
where: { form: { roundId: input.id } },
|
||||
});
|
||||
|
||||
await tx.round.delete({
|
||||
where: { id: input.id },
|
||||
});
|
||||
});
|
||||
|
||||
return round;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check if a round has any submitted evaluations
|
||||
*/
|
||||
hasEvaluations: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const count = await ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: { in: ["SUBMITTED", "LOCKED"] },
|
||||
},
|
||||
});
|
||||
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 },
|
||||
});
|
||||
|
||||
// Update projects to assign them to this round
|
||||
const updated = await ctx.prisma.project.updateMany({
|
||||
where: {
|
||||
id: { in: input.projectIds },
|
||||
programId: round.programId,
|
||||
},
|
||||
data: {
|
||||
roundId: input.roundId,
|
||||
status: "SUBMITTED",
|
||||
},
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"No projects were assigned. Projects may not belong to this program.",
|
||||
});
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: "ASSIGN_PROJECTS_TO_ROUND",
|
||||
entityType: "Round",
|
||||
entityId: input.roundId,
|
||||
detailsJson: { projectCount: updated.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return { assigned: updated.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 }) => {
|
||||
// Set roundId to null for these projects (remove from round)
|
||||
const updated = await ctx.prisma.project.updateMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
id: { in: input.projectIds },
|
||||
},
|
||||
data: {
|
||||
roundId: null as unknown as string, // Projects need to be orphaned
|
||||
},
|
||||
});
|
||||
const deleted = { count: updated.count };
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
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.project.findMany({
|
||||
where: {
|
||||
roundId: input.fromRoundId,
|
||||
id: { in: input.projectIds },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (sourceProjects.length !== input.projectIds.length) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Some projects are not in the source round",
|
||||
});
|
||||
}
|
||||
|
||||
// Move projects to target round
|
||||
const updated = await ctx.prisma.project.updateMany({
|
||||
where: {
|
||||
id: { in: input.projectIds },
|
||||
roundId: input.fromRoundId,
|
||||
},
|
||||
data: {
|
||||
roundId: input.toRoundId,
|
||||
status: "SUBMITTED",
|
||||
},
|
||||
});
|
||||
const created = { count: updated.count };
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
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 logAudit({
|
||||
prisma: ctx.prisma,
|
||||
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