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:
2026-02-13 13:57:09 +01:00
parent 8a328357e3
commit 331b67dae0
256 changed files with 29117 additions and 21424 deletions

View File

@@ -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 };
}),
});