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

768 lines
22 KiB
TypeScript

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'
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: 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: 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 },
},
},
})
// Count eligible projects
const eligibleCount = await ctx.prisma.awardEligibility.count({
where: { awardId: input.id, eligible: true },
})
return { ...award, 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(),
autoTagRulesJson: z.record(z.unknown()).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 }) => {
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,
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
competitionId: input.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(),
autoTagRulesJson: z.record(z.unknown()).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, autoTagRulesJson, ...rest } = input
const award = await ctx.prisma.specialAward.update({
where: { id },
data: {
...rest,
...(autoTagRulesJson !== undefined && { autoTagRulesJson: autoTagRulesJson as Prisma.InputJsonValue }),
},
})
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 }) => {
await ctx.prisma.awardEligibility.upsert({
where: {
awardId_projectId: {
awardId: input.awardId,
projectId: input.projectId,
},
},
create: {
awardId: input.awardId,
projectId: input.projectId,
eligible: input.eligible,
method: 'MANUAL',
overriddenBy: ctx.user.id,
overriddenAt: new Date(),
},
update: {
eligible: input.eligible,
overriddenBy: ctx.user.id,
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
}),
})