Files
MOPC-Portal/src/server/routers/specialAward.ts

1216 lines
36 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { processEligibilityJob } from '../services/award-eligibility-job'
import type { PrismaClient } from '@prisma/client'
/**
* Verify the current session user exists in the database.
* Guards against stale JWT sessions (e.g., after database reseed).
*/
async function ensureUserExists(db: PrismaClient, userId: string): Promise<string> {
const user = await db.user.findUnique({
where: { id: userId },
select: { id: true },
})
if (!user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Your session refers to a user that no longer exists. Please log out and log back in.',
})
}
return user.id
}
export const specialAwardRouter = router({
// ─── Admin Queries ──────────────────────────────────────────────────────
/**
* List awards for a program
*/
list: protectedProcedure
.input(
z.object({
programId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.specialAward.findMany({
where: input.programId ? { programId: input.programId } : {},
orderBy: { sortOrder: 'asc' },
include: {
_count: {
select: {
eligibilities: { where: { eligible: true } },
jurors: true,
votes: true,
},
},
winnerProject: {
select: { id: true, title: true, teamName: true },
},
},
})
}),
/**
* Get award detail with stats
*/
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.id },
include: {
_count: {
select: {
eligibilities: { where: { eligible: true } },
jurors: true,
votes: true,
},
},
winnerProject: {
select: { id: true, title: true, teamName: true },
},
program: {
select: { id: true, name: true, year: true },
},
competition: {
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } },
},
evaluationRound: {
select: { id: true, name: true, roundType: true },
},
awardJuryGroup: {
select: { id: true, name: true },
},
},
})
// Auto-resolve competition if missing (legacy awards created without competitionId)
let { competition } = award
if (!competition && award.programId) {
const comp = await ctx.prisma.competition.findFirst({
where: { programId: award.programId },
orderBy: { createdAt: 'desc' },
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const } } },
})
if (comp) {
competition = comp
}
}
// Count eligible projects
const eligibleCount = await ctx.prisma.awardEligibility.count({
where: { awardId: input.id, eligible: true },
})
return { ...award, competition, eligibleCount }
}),
// ─── Admin Mutations ────────────────────────────────────────────────────
/**
* Create award
*/
create: adminProcedure
.input(
z.object({
programId: z.string(),
name: z.string().min(1),
description: z.string().optional(),
criteriaText: z.string().optional(),
useAiEligibility: z.boolean().optional(),
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
maxRankedPicks: z.number().int().min(1).max(20).optional(),
competitionId: z.string().optional(),
evaluationRoundId: z.string().optional(),
juryGroupId: z.string().optional(),
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Auto-resolve competitionId from program if not provided
let competitionId = input.competitionId
if (!competitionId) {
const comp = await ctx.prisma.competition.findFirst({
where: { programId: input.programId },
orderBy: { createdAt: 'desc' },
select: { id: true },
})
competitionId = comp?.id ?? undefined
}
const maxOrder = await ctx.prisma.specialAward.aggregate({
where: { programId: input.programId },
_max: { sortOrder: true },
})
const award = await ctx.prisma.specialAward.create({
data: {
programId: input.programId,
name: input.name,
description: input.description,
criteriaText: input.criteriaText,
useAiEligibility: input.useAiEligibility ?? true,
scoringMode: input.scoringMode,
maxRankedPicks: input.maxRankedPicks,
competitionId,
evaluationRoundId: input.evaluationRoundId,
juryGroupId: input.juryGroupId,
eligibilityMode: input.eligibilityMode,
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
},
})
// Audit outside transaction so failures don't roll back the create
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'SpecialAward',
entityId: award.id,
detailsJson: { name: input.name, scoringMode: input.scoringMode },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return award
}),
/**
* Update award config
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).optional(),
description: z.string().optional(),
criteriaText: z.string().optional(),
useAiEligibility: z.boolean().optional(),
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(),
maxRankedPicks: z.number().int().min(1).max(20).optional(),
votingStartAt: z.date().optional(),
votingEndAt: z.date().optional(),
competitionId: z.string().nullable().optional(),
evaluationRoundId: z.string().nullable().optional(),
juryGroupId: z.string().nullable().optional(),
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...rest } = input
// Auto-resolve competitionId if missing on existing award
if (rest.competitionId === undefined) {
const existing = await ctx.prisma.specialAward.findUnique({
where: { id },
select: { competitionId: true, programId: true },
})
if (existing && !existing.competitionId) {
const comp = await ctx.prisma.competition.findFirst({
where: { programId: existing.programId },
orderBy: { createdAt: 'desc' },
select: { id: true },
})
if (comp) rest.competitionId = comp.id
}
}
const award = await ctx.prisma.specialAward.update({
where: { id },
data: rest,
})
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: id,
})
return award
}),
/**
* Delete award
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.specialAward.delete({ where: { id: input.id } })
// Audit outside transaction so failures don't break the delete
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'SpecialAward',
entityId: input.id,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}),
/**
* Update award status
*/
updateStatus: adminProcedure
.input(
z.object({
id: z.string(),
status: z.enum([
'DRAFT',
'NOMINATIONS_OPEN',
'VOTING_OPEN',
'CLOSED',
'ARCHIVED',
]),
})
)
.mutation(async ({ ctx, input }) => {
const current = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.id },
select: { status: true, votingStartAt: true, votingEndAt: true },
})
const now = new Date()
// When opening voting, auto-set votingStartAt to now if it's in the future or not set
let votingStartAtUpdated = false
const updateData: Parameters<typeof ctx.prisma.specialAward.update>[0]['data'] = {
status: input.status,
}
if (input.status === 'VOTING_OPEN' && current.status !== 'VOTING_OPEN') {
// If no voting start date, or if it's in the future, set it to 1 minute ago
// to ensure voting is immediately open (avoids race condition with page render)
if (!current.votingStartAt || current.votingStartAt > now) {
const oneMinuteAgo = new Date(now.getTime() - 60 * 1000)
updateData.votingStartAt = oneMinuteAgo
votingStartAtUpdated = true
}
}
const award = await ctx.prisma.specialAward.update({
where: { id: input.id },
data: updateData,
})
// Audit outside transaction so failures don't break the status update
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_STATUS',
entityType: 'SpecialAward',
entityId: input.id,
detailsJson: {
previousStatus: current.status,
newStatus: input.status,
...(votingStartAtUpdated && {
votingStartAtUpdated: true,
previousVotingStartAt: current.votingStartAt,
newVotingStartAt: now,
}),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return award
}),
// ─── Eligibility ────────────────────────────────────────────────────────
/**
* Run auto-tag + AI eligibility
*/
runEligibility: adminProcedure
.input(z.object({
awardId: z.string(),
includeSubmitted: z.boolean().optional(),
}))
.mutation(async ({ ctx, input }) => {
// Set job status to PENDING immediately
await ctx.prisma.specialAward.update({
where: { id: input.awardId },
data: {
eligibilityJobStatus: 'PENDING',
eligibilityJobTotal: null,
eligibilityJobDone: null,
eligibilityJobError: null,
eligibilityJobStarted: null,
},
})
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: input.awardId,
detailsJson: { action: 'RUN_ELIGIBILITY_STARTED' },
})
// Fire and forget - process in background
void processEligibilityJob(
input.awardId,
input.includeSubmitted ?? false,
ctx.user.id
)
return { started: true }
}),
/**
* Get eligibility job status for polling
*/
getEligibilityJobStatus: protectedProcedure
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
select: {
eligibilityJobStatus: true,
eligibilityJobTotal: true,
eligibilityJobDone: true,
eligibilityJobError: true,
eligibilityJobStarted: true,
},
})
return award
}),
/**
* List eligible projects
*/
listEligible: protectedProcedure
.input(
z.object({
awardId: z.string(),
eligibleOnly: z.boolean().default(false),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
const { awardId, eligibleOnly, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = { awardId }
if (eligibleOnly) where.eligible = true
const [eligibilities, total] = await Promise.all([
ctx.prisma.awardEligibility.findMany({
where,
skip,
take: perPage,
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
country: true,
tags: true,
},
},
},
orderBy: { project: { title: 'asc' } },
}),
ctx.prisma.awardEligibility.count({ where }),
])
return { eligibilities, total, page, perPage, totalPages: Math.ceil(total / perPage) }
}),
/**
* Manual eligibility override
*/
setEligibility: adminProcedure
.input(
z.object({
awardId: z.string(),
projectId: z.string(),
eligible: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const verifiedUserId = await ensureUserExists(ctx.prisma, ctx.user.id)
await ctx.prisma.awardEligibility.upsert({
where: {
awardId_projectId: {
awardId: input.awardId,
projectId: input.projectId,
},
},
create: {
award: { connect: { id: input.awardId } },
project: { connect: { id: input.projectId } },
eligible: input.eligible,
method: 'MANUAL',
overriddenByUser: { connect: { id: verifiedUserId } },
overriddenAt: new Date(),
},
update: {
eligible: input.eligible,
overriddenByUser: { connect: { id: verifiedUserId } },
overriddenAt: new Date(),
},
})
}),
// ─── Jurors ─────────────────────────────────────────────────────────────
/**
* List jurors for an award
*/
listJurors: protectedProcedure
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.awardJuror.findMany({
where: { awardId: input.awardId },
include: {
user: {
select: {
id: true,
name: true,
email: true,
role: true,
profileImageKey: true,
profileImageProvider: true,
},
},
},
})
}),
/**
* Add juror
*/
addJuror: adminProcedure
.input(
z.object({
awardId: z.string(),
userId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.awardJuror.create({
data: {
awardId: input.awardId,
userId: input.userId,
},
})
}),
/**
* Remove juror
*/
removeJuror: adminProcedure
.input(
z.object({
awardId: z.string(),
userId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.awardJuror.delete({
where: {
awardId_userId: {
awardId: input.awardId,
userId: input.userId,
},
},
})
}),
/**
* Bulk add jurors
*/
bulkAddJurors: adminProcedure
.input(
z.object({
awardId: z.string(),
userIds: z.array(z.string()),
})
)
.mutation(async ({ ctx, input }) => {
const data = input.userIds.map((userId) => ({
awardId: input.awardId,
userId,
}))
await ctx.prisma.awardJuror.createMany({
data,
skipDuplicates: true,
})
return { added: input.userIds.length }
}),
// ─── Jury Queries ───────────────────────────────────────────────────────
/**
* Get awards where current user is a juror
*/
getMyAwards: protectedProcedure.query(async ({ ctx }) => {
const jurorships = await ctx.prisma.awardJuror.findMany({
where: { userId: ctx.user.id },
include: {
award: {
include: {
_count: {
select: { eligibilities: { where: { eligible: true } } },
},
},
},
},
})
return jurorships.map((j) => j.award)
}),
/**
* Get award detail for voting (jury view)
*/
getMyAwardDetail: protectedProcedure
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify user is a juror
const juror = await ctx.prisma.awardJuror.findUnique({
where: {
awardId_userId: {
awardId: input.awardId,
userId: ctx.user.id,
},
},
})
if (!juror) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not a juror for this award',
})
}
// Fetch award, eligible projects, and votes in parallel
const [award, eligibleProjects, myVotes] = await Promise.all([
ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
}),
ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, eligible: true },
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
description: true,
competitionCategory: true,
country: true,
tags: true,
},
},
},
}),
ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId, userId: ctx.user.id },
}),
])
return {
award,
projects: eligibleProjects.map((e) => e.project),
myVotes,
}
}),
// ─── Voting ─────────────────────────────────────────────────────────────
/**
* Submit vote (PICK_WINNER or RANKED)
*/
submitVote: protectedProcedure
.input(
z.object({
awardId: z.string(),
votes: z.array(
z.object({
projectId: z.string(),
rank: z.number().int().min(1).optional(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
// Verify juror
const juror = await ctx.prisma.awardJuror.findUnique({
where: {
awardId_userId: {
awardId: input.awardId,
userId: ctx.user.id,
},
},
})
if (!juror) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not a juror for this award',
})
}
// Verify award is open for voting
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
})
if (award.status !== 'VOTING_OPEN') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Voting is not currently open for this award',
})
}
// Delete existing votes and create new ones
await ctx.prisma.$transaction([
ctx.prisma.awardVote.deleteMany({
where: { awardId: input.awardId, userId: ctx.user.id },
}),
...input.votes.map((vote) =>
ctx.prisma.awardVote.create({
data: {
awardId: input.awardId,
userId: ctx.user.id,
projectId: vote.projectId,
rank: vote.rank,
},
})
),
])
await logAudit({
userId: ctx.user.id,
action: 'CREATE',
entityType: 'AwardVote',
entityId: input.awardId,
detailsJson: {
awardId: input.awardId,
voteCount: input.votes.length,
scoringMode: award.scoringMode,
},
})
return { submitted: input.votes.length }
}),
// ─── Results ────────────────────────────────────────────────────────────
/**
* Get aggregated vote results
*/
getVoteResults: adminProcedure
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
const [award, votes, jurorCount] = await Promise.all([
ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
}),
ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId },
include: {
project: {
select: { id: true, title: true, teamName: true },
},
user: {
select: { id: true, name: true, email: true },
},
},
}),
ctx.prisma.awardJuror.count({
where: { awardId: input.awardId },
}),
])
const votedJurorCount = new Set(votes.map((v) => v.userId)).size
// Tally by scoring mode
const projectTallies = new Map<
string,
{ project: { id: string; title: string; teamName: string | null }; votes: number; points: number }
>()
for (const vote of votes) {
const existing = projectTallies.get(vote.projectId) || {
project: vote.project,
votes: 0,
points: 0,
}
existing.votes += 1
if (award.scoringMode === 'RANKED' && vote.rank) {
existing.points += (award.maxRankedPicks || 5) - vote.rank + 1
} else {
existing.points += 1
}
projectTallies.set(vote.projectId, existing)
}
const ranked = Array.from(projectTallies.values()).sort(
(a, b) => b.points - a.points
)
return {
scoringMode: award.scoringMode,
jurorCount,
votedJurorCount,
results: ranked,
winnerId: award.winnerProjectId,
winnerOverridden: award.winnerOverridden,
}
}),
/**
* Set/override winner
*/
setWinner: adminProcedure
.input(
z.object({
awardId: z.string(),
projectId: z.string(),
overridden: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
const previous = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
select: { winnerProjectId: true },
})
const award = await ctx.prisma.specialAward.update({
where: { id: input.awardId },
data: {
winnerProjectId: input.projectId,
winnerOverridden: input.overridden,
winnerOverriddenBy: input.overridden ? ctx.user.id : null,
},
})
// Audit outside transaction so failures don't break the winner update
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: input.awardId,
detailsJson: {
action: 'SET_AWARD_WINNER',
previousWinner: previous.winnerProjectId,
newWinner: input.projectId,
overridden: input.overridden,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return award
}),
// ─── Round-Scoped Eligibility & Shortlists ──────────────────────────────
/**
* List awards for a competition (from a filtering round context)
*/
listForRound: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Get competition from round
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { competitionId: true },
})
const awards = await ctx.prisma.specialAward.findMany({
where: { competitionId: round.competitionId },
orderBy: { sortOrder: 'asc' },
include: {
_count: {
select: {
eligibilities: { where: { eligible: true } },
},
},
},
})
// Get shortlisted counts
const shortlistedCounts = await ctx.prisma.awardEligibility.groupBy({
by: ['awardId'],
where: {
awardId: { in: awards.map((a) => a.id) },
shortlisted: true,
},
_count: true,
})
const shortlistMap = new Map(shortlistedCounts.map((s) => [s.awardId, s._count]))
return awards.map((a) => ({
...a,
shortlistedCount: shortlistMap.get(a.id) ?? 0,
}))
}),
/**
* Run eligibility scoped to a filtering round's PASSED projects
*/
runEligibilityForRound: adminProcedure
.input(z.object({
awardId: z.string(),
roundId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
// Set job status to PENDING
await ctx.prisma.specialAward.update({
where: { id: input.awardId },
data: {
eligibilityJobStatus: 'PENDING',
eligibilityJobTotal: null,
eligibilityJobDone: null,
eligibilityJobError: null,
eligibilityJobStarted: null,
},
})
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: input.awardId,
detailsJson: { action: 'RUN_ELIGIBILITY_FOR_ROUND', roundId: input.roundId },
})
// Fire and forget - process in background with round scoping
void processEligibilityJob(
input.awardId,
true, // include submitted
ctx.user.id,
input.roundId
)
return { started: true }
}),
/**
* Get ranked shortlist for an award
*/
listShortlist: protectedProcedure
.input(z.object({
awardId: z.string(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(50),
}))
.query(async ({ ctx, input }) => {
const { awardId, page, perPage } = input
const skip = (page - 1) * perPage
const [eligibilities, total, award] = await Promise.all([
ctx.prisma.awardEligibility.findMany({
where: { awardId, eligible: true },
skip,
take: perPage,
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
country: true,
tags: true,
description: true,
},
},
},
orderBy: { qualityScore: 'desc' },
}),
ctx.prisma.awardEligibility.count({ where: { awardId, eligible: true } }),
ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: awardId },
select: { shortlistSize: true, eligibilityMode: true, name: true },
}),
])
return {
eligibilities,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
shortlistSize: award.shortlistSize,
eligibilityMode: award.eligibilityMode,
awardName: award.name,
}
}),
/**
* Toggle shortlisted status for a project-award pair
*/
toggleShortlisted: adminProcedure
.input(z.object({
awardId: z.string(),
projectId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
const current = await ctx.prisma.awardEligibility.findUniqueOrThrow({
where: {
awardId_projectId: {
awardId: input.awardId,
projectId: input.projectId,
},
},
select: { shortlisted: true },
})
const updated = await ctx.prisma.awardEligibility.update({
where: {
awardId_projectId: {
awardId: input.awardId,
projectId: input.projectId,
},
},
data: { shortlisted: !current.shortlisted },
})
return { shortlisted: updated.shortlisted }
}),
/**
* Bulk toggle shortlisted status for multiple projects
*/
bulkToggleShortlisted: adminProcedure
.input(z.object({
awardId: z.string(),
projectIds: z.array(z.string()),
shortlisted: z.boolean(),
}))
.mutation(async ({ ctx, input }) => {
const result = await ctx.prisma.awardEligibility.updateMany({
where: {
awardId: input.awardId,
projectId: { in: input.projectIds },
eligible: true,
},
data: { shortlisted: input.shortlisted },
})
return { updated: result.count, shortlisted: input.shortlisted }
}),
/**
* Confirm shortlist for SEPARATE_POOL awards, creates ProjectRoundState entries
*/
confirmShortlist: adminProcedure
.input(z.object({ awardId: z.string() }))
.mutation(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
select: { eligibilityMode: true, name: true, competitionId: true },
})
// Get shortlisted projects
const shortlisted = await ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, shortlisted: true, eligible: true },
select: { projectId: true },
})
if (shortlisted.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No shortlisted projects to confirm',
})
}
const verifiedUserId = await ensureUserExists(ctx.prisma, ctx.user.id)
// Mark all as confirmed
await ctx.prisma.awardEligibility.updateMany({
where: { awardId: input.awardId, shortlisted: true, eligible: true },
data: {
confirmedAt: new Date(),
confirmedBy: verifiedUserId,
},
})
// For SEPARATE_POOL: create ProjectRoundState entries in award rounds (if any exist)
let routedCount = 0
if (award.eligibilityMode === 'SEPARATE_POOL') {
const awardRounds = await ctx.prisma.round.findMany({
where: { specialAwardId: input.awardId },
select: { id: true },
orderBy: { sortOrder: 'asc' },
})
if (awardRounds.length > 0) {
const firstRound = awardRounds[0]
const projectIds = shortlisted.map((s) => s.projectId)
// Create ProjectRoundState entries for confirmed projects in the first award round
await ctx.prisma.projectRoundState.createMany({
data: projectIds.map((projectId) => ({
projectId,
roundId: firstRound.id,
state: 'PENDING' as const,
})),
skipDuplicates: true,
})
routedCount = projectIds.length
}
}
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: input.awardId,
detailsJson: {
action: 'CONFIRM_SHORTLIST',
confirmedCount: shortlisted.length,
eligibilityMode: award.eligibilityMode,
routedCount,
},
})
return {
confirmedCount: shortlisted.length,
routedCount,
eligibilityMode: award.eligibilityMode,
}
}),
// ─── Award Rounds ────────────────────────────────────────────────────────
/**
* List rounds belonging to an award
*/
listRounds: protectedProcedure
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.round.findMany({
where: { specialAwardId: input.awardId },
orderBy: { sortOrder: 'asc' },
include: {
juryGroup: { select: { id: true, name: true } },
_count: {
select: {
projectRoundStates: true,
assignments: true,
},
},
},
})
}),
/**
* Create a round linked to an award
*/
createRound: adminProcedure
.input(z.object({
awardId: z.string(),
name: z.string().min(1),
roundType: z.enum(['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION']).default('EVALUATION'),
}))
.mutation(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
select: { competitionId: true, name: true },
})
if (!award.competitionId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Award must be linked to a competition before creating rounds',
})
}
// Get max sort order for this award's rounds
const maxOrder = await ctx.prisma.round.aggregate({
where: { specialAwardId: input.awardId },
_max: { sortOrder: true },
})
// Also need max sortOrder across the competition to avoid unique constraint conflicts
const maxCompOrder = await ctx.prisma.round.aggregate({
where: { competitionId: award.competitionId },
_max: { sortOrder: true },
})
const sortOrder = Math.max(
(maxOrder._max.sortOrder ?? 0) + 1,
(maxCompOrder._max.sortOrder ?? 0) + 1
)
const slug = `${award.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${input.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
const round = await ctx.prisma.round.create({
data: {
competitionId: award.competitionId,
specialAwardId: input.awardId,
name: input.name,
slug,
roundType: input.roundType,
sortOrder,
},
})
await logAudit({
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Round',
entityId: round.id,
detailsJson: { awardId: input.awardId, awardName: award.name, roundType: input.roundType },
})
return round
}),
/**
* Delete an award round (only if DRAFT)
*/
deleteRound: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { status: true, specialAwardId: true },
})
if (!round.specialAwardId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This round is not an award round',
})
}
if (round.status !== 'ROUND_DRAFT') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Only draft rounds can be deleted',
})
}
await ctx.prisma.round.delete({ where: { id: input.roundId } })
await logAudit({
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { awardId: round.specialAwardId },
})
}),
})