Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination - Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence - Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility - Founding Date Field: add foundedAt to Project model with CSV import support - Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate - Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility - Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures - Reusable pagination component extracted to src/components/shared/pagination.tsx - Old /admin/users and /admin/mentors routes redirect to /admin/members - Prisma migration for all schema additions (additive, no data loss) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
775
src/server/routers/specialAward.ts
Normal file
775
src/server/routers/specialAward.ts
Normal file
@@ -0,0 +1,775 @@
|
||||
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 {
|
||||
applyAutoTagRules,
|
||||
aiInterpretCriteria,
|
||||
type AutoTagRule,
|
||||
} from '../services/ai-award-eligibility'
|
||||
|
||||
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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 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(),
|
||||
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
|
||||
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
||||
autoTagRulesJson: z.record(z.unknown()).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,
|
||||
scoringMode: input.scoringMode,
|
||||
maxRankedPicks: input.maxRankedPicks,
|
||||
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
|
||||
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: award.id,
|
||||
detailsJson: { name: input.name, scoringMode: input.scoringMode },
|
||||
})
|
||||
|
||||
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(),
|
||||
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(),
|
||||
})
|
||||
)
|
||||
.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 } })
|
||||
|
||||
await logAudit({
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
})
|
||||
|
||||
const award = await ctx.prisma.specialAward.update({
|
||||
where: { id: input.id },
|
||||
data: { status: input.status },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_STATUS',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
previousStatus: current.status,
|
||||
newStatus: input.status,
|
||||
},
|
||||
})
|
||||
|
||||
return award
|
||||
}),
|
||||
|
||||
// ─── Eligibility ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run auto-tag + AI eligibility
|
||||
*/
|
||||
runEligibility: adminProcedure
|
||||
.input(z.object({ awardId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
include: { program: true },
|
||||
})
|
||||
|
||||
// Get all projects in the program's rounds
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
round: { programId: award.programId },
|
||||
status: { in: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
geographicZone: true,
|
||||
tags: true,
|
||||
oceanIssue: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No eligible projects found',
|
||||
})
|
||||
}
|
||||
|
||||
// Phase 1: Auto-tag rules (deterministic)
|
||||
const autoTagRules = award.autoTagRulesJson as unknown as AutoTagRule[] | null
|
||||
let autoResults: Map<string, boolean> | undefined
|
||||
if (autoTagRules && Array.isArray(autoTagRules) && autoTagRules.length > 0) {
|
||||
autoResults = applyAutoTagRules(autoTagRules, projects)
|
||||
}
|
||||
|
||||
// Phase 2: AI interpretation (if criteria text exists)
|
||||
let aiResults: Map<string, { eligible: boolean; confidence: number; reasoning: string }> | undefined
|
||||
if (award.criteriaText) {
|
||||
const aiEvals = await aiInterpretCriteria(award.criteriaText, projects)
|
||||
aiResults = new Map(
|
||||
aiEvals.map((e) => [
|
||||
e.projectId,
|
||||
{ eligible: e.eligible, confidence: e.confidence, reasoning: e.reasoning },
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
// Combine results: auto-tag AND AI must agree (or just one if only one configured)
|
||||
const eligibilities = projects.map((project) => {
|
||||
const autoEligible = autoResults?.get(project.id) ?? true
|
||||
const aiEval = aiResults?.get(project.id)
|
||||
const aiEligible = aiEval?.eligible ?? true
|
||||
|
||||
const eligible = autoEligible && aiEligible
|
||||
const method = autoResults && aiResults ? 'AUTO' : autoResults ? 'AUTO' : 'MANUAL'
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
eligible,
|
||||
method,
|
||||
aiReasoningJson: aiEval
|
||||
? { confidence: aiEval.confidence, reasoning: aiEval.reasoning }
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
// Upsert eligibilities
|
||||
await ctx.prisma.$transaction(
|
||||
eligibilities.map((e) =>
|
||||
ctx.prisma.awardEligibility.upsert({
|
||||
where: {
|
||||
awardId_projectId: {
|
||||
awardId: input.awardId,
|
||||
projectId: e.projectId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
awardId: input.awardId,
|
||||
projectId: e.projectId,
|
||||
eligible: e.eligible,
|
||||
method: e.method as 'AUTO' | 'MANUAL',
|
||||
aiReasoningJson: e.aiReasoningJson ?? undefined,
|
||||
},
|
||||
update: {
|
||||
eligible: e.eligible,
|
||||
method: e.method as 'AUTO' | 'MANUAL',
|
||||
aiReasoningJson: e.aiReasoningJson ?? undefined,
|
||||
// Clear overrides
|
||||
overriddenBy: null,
|
||||
overriddenAt: null,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const eligibleCount = eligibilities.filter((e) => e.eligible).length
|
||||
|
||||
await logAudit({
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.awardId,
|
||||
detailsJson: {
|
||||
action: 'RUN_ELIGIBILITY',
|
||||
totalProjects: projects.length,
|
||||
eligible: eligibleCount,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
total: projects.length,
|
||||
eligible: eligibleCount,
|
||||
ineligible: projects.length - eligibleCount,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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',
|
||||
})
|
||||
}
|
||||
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
})
|
||||
|
||||
// Get eligible projects
|
||||
const eligibleProjects = await 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Get user's existing votes
|
||||
const myVotes = await 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 = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
})
|
||||
|
||||
const votes = await 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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const jurorCount = await 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,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
return award
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user