2026-02-14 15:26:42 +01:00
|
|
|
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 },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 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(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const maxOrder = await ctx.prisma.specialAward.aggregate({
|
|
|
|
|
where: { programId: input.programId },
|
|
|
|
|
_max: { sortOrder: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const award = await ctx.prisma.$transaction(async (tx) => {
|
|
|
|
|
const created = await tx.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,
|
|
|
|
|
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await tx.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CREATE',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: created.id,
|
|
|
|
|
detailsJson: { name: input.name, scoringMode: input.scoringMode } as Prisma.InputJsonValue,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return created
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.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.$transaction(async (tx) => {
|
|
|
|
|
await tx.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'DELETE',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: input.id,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await tx.specialAward.delete({ where: { id: 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, 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.$transaction(async (tx) => {
|
|
|
|
|
const updated = await tx.specialAward.update({
|
|
|
|
|
where: { id: input.id },
|
|
|
|
|
data: updateData,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await tx.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
} as Prisma.InputJsonValue,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return updated
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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.$transaction(async (tx) => {
|
|
|
|
|
const updated = await tx.specialAward.update({
|
|
|
|
|
where: { id: input.awardId },
|
|
|
|
|
data: {
|
|
|
|
|
winnerProjectId: input.projectId,
|
|
|
|
|
winnerOverridden: input.overridden,
|
|
|
|
|
winnerOverriddenBy: input.overridden ? ctx.user.id : null,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await tx.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
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,
|
|
|
|
|
} as Prisma.InputJsonValue,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return updated
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return award
|
|
|
|
|
}),
|
|
|
|
|
})
|