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, awardMasterProcedure } from '../trpc'
|
|
|
|
|
import { logAudit } from '@/server/utils/audit'
|
|
|
|
|
|
|
|
|
|
export const awardRouter = router({
|
|
|
|
|
/**
|
|
|
|
|
* Create a new award track within a pipeline
|
|
|
|
|
*/
|
|
|
|
|
createTrack: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
pipelineId: z.string(),
|
|
|
|
|
name: z.string().min(1).max(255),
|
|
|
|
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
|
|
|
|
routingMode: z.enum(['PARALLEL', 'EXCLUSIVE', 'POST_MAIN']).optional(),
|
|
|
|
|
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
|
|
|
|
|
settingsJson: z.record(z.unknown()).optional(),
|
|
|
|
|
awardConfig: z.object({
|
|
|
|
|
name: z.string().min(1).max(255),
|
|
|
|
|
description: z.string().max(5000).optional(),
|
|
|
|
|
criteriaText: z.string().max(5000).optional(),
|
|
|
|
|
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).default('PICK_WINNER'),
|
|
|
|
|
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
|
|
|
|
useAiEligibility: z.boolean().default(true),
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Verify pipeline exists
|
|
|
|
|
const pipeline = await ctx.prisma.pipeline.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.pipelineId },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Check slug uniqueness within pipeline
|
|
|
|
|
const existingTrack = await ctx.prisma.track.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
pipelineId: input.pipelineId,
|
|
|
|
|
slug: input.slug,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if (existingTrack) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'CONFLICT',
|
|
|
|
|
message: `A track with slug "${input.slug}" already exists in this pipeline`,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-set sortOrder
|
|
|
|
|
const maxOrder = await ctx.prisma.track.aggregate({
|
|
|
|
|
where: { pipelineId: input.pipelineId },
|
|
|
|
|
_max: { sortOrder: true },
|
|
|
|
|
})
|
|
|
|
|
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1
|
|
|
|
|
|
|
|
|
|
const { awardConfig, settingsJson, ...trackData } = input
|
|
|
|
|
|
|
|
|
|
const result = await ctx.prisma.$transaction(async (tx) => {
|
|
|
|
|
// Create the track
|
|
|
|
|
const track = await tx.track.create({
|
|
|
|
|
data: {
|
|
|
|
|
pipelineId: input.pipelineId,
|
|
|
|
|
name: trackData.name,
|
|
|
|
|
slug: trackData.slug,
|
|
|
|
|
kind: 'AWARD',
|
|
|
|
|
routingMode: trackData.routingMode ?? null,
|
|
|
|
|
decisionMode: trackData.decisionMode ?? 'JURY_VOTE',
|
|
|
|
|
sortOrder,
|
|
|
|
|
settingsJson: (settingsJson as Prisma.InputJsonValue) ?? undefined,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Create the linked SpecialAward
|
|
|
|
|
const award = await tx.specialAward.create({
|
|
|
|
|
data: {
|
|
|
|
|
programId: pipeline.programId,
|
|
|
|
|
name: awardConfig.name,
|
|
|
|
|
description: awardConfig.description ?? null,
|
|
|
|
|
criteriaText: awardConfig.criteriaText ?? null,
|
|
|
|
|
scoringMode: awardConfig.scoringMode,
|
|
|
|
|
maxRankedPicks: awardConfig.maxRankedPicks ?? null,
|
|
|
|
|
useAiEligibility: awardConfig.useAiEligibility,
|
|
|
|
|
trackId: track.id,
|
|
|
|
|
sortOrder,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: tx,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CREATE_AWARD_TRACK',
|
|
|
|
|
entityType: 'Track',
|
|
|
|
|
entityId: track.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
pipelineId: input.pipelineId,
|
|
|
|
|
trackName: track.name,
|
|
|
|
|
awardId: award.id,
|
|
|
|
|
awardName: award.name,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { track, award }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Configure governance settings for an award track
|
|
|
|
|
*/
|
|
|
|
|
configureGovernance: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
trackId: z.string(),
|
|
|
|
|
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
|
|
|
|
|
jurorIds: z.array(z.string()).optional(),
|
|
|
|
|
votingStartAt: z.date().optional().nullable(),
|
|
|
|
|
votingEndAt: z.date().optional().nullable(),
|
|
|
|
|
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(),
|
|
|
|
|
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const track = await ctx.prisma.track.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.trackId },
|
|
|
|
|
include: { specialAward: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (track.kind !== 'AWARD') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'This track is not an AWARD track',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!track.specialAward) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'No award linked to this track',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate voting dates
|
|
|
|
|
if (input.votingStartAt && input.votingEndAt) {
|
|
|
|
|
if (input.votingEndAt <= input.votingStartAt) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Voting end date must be after start date',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await ctx.prisma.$transaction(async (tx) => {
|
|
|
|
|
// Update track decision mode
|
|
|
|
|
if (input.decisionMode) {
|
|
|
|
|
await tx.track.update({
|
|
|
|
|
where: { id: input.trackId },
|
|
|
|
|
data: { decisionMode: input.decisionMode },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update award config
|
|
|
|
|
const awardUpdate: Record<string, unknown> = {}
|
|
|
|
|
if (input.votingStartAt !== undefined) awardUpdate.votingStartAt = input.votingStartAt
|
|
|
|
|
if (input.votingEndAt !== undefined) awardUpdate.votingEndAt = input.votingEndAt
|
|
|
|
|
if (input.scoringMode) awardUpdate.scoringMode = input.scoringMode
|
|
|
|
|
if (input.maxRankedPicks !== undefined) awardUpdate.maxRankedPicks = input.maxRankedPicks
|
|
|
|
|
|
|
|
|
|
let updatedAward = track.specialAward
|
|
|
|
|
if (Object.keys(awardUpdate).length > 0) {
|
|
|
|
|
updatedAward = await tx.specialAward.update({
|
|
|
|
|
where: { id: track.specialAward!.id },
|
|
|
|
|
data: awardUpdate,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Manage jurors if provided
|
|
|
|
|
if (input.jurorIds) {
|
|
|
|
|
// Remove all existing jurors
|
|
|
|
|
await tx.awardJuror.deleteMany({
|
|
|
|
|
where: { awardId: track.specialAward!.id },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Add new jurors
|
|
|
|
|
if (input.jurorIds.length > 0) {
|
|
|
|
|
await tx.awardJuror.createMany({
|
|
|
|
|
data: input.jurorIds.map((userId) => ({
|
|
|
|
|
awardId: track.specialAward!.id,
|
|
|
|
|
userId,
|
|
|
|
|
})),
|
|
|
|
|
skipDuplicates: true,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: tx,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CONFIGURE_AWARD_GOVERNANCE',
|
|
|
|
|
entityType: 'Track',
|
|
|
|
|
entityId: input.trackId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
awardId: track.specialAward!.id,
|
|
|
|
|
decisionMode: input.decisionMode,
|
|
|
|
|
jurorCount: input.jurorIds?.length,
|
|
|
|
|
scoringMode: input.scoringMode,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
track: { id: track.id, decisionMode: input.decisionMode ?? track.decisionMode },
|
|
|
|
|
award: updatedAward,
|
|
|
|
|
jurorsSet: input.jurorIds?.length ?? null,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Route projects to an award track (set eligibility)
|
|
|
|
|
*/
|
|
|
|
|
routeProjects: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
trackId: z.string(),
|
|
|
|
|
projectIds: z.array(z.string()).min(1).max(500),
|
|
|
|
|
eligible: z.boolean().default(true),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const track = await ctx.prisma.track.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.trackId },
|
|
|
|
|
include: { specialAward: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!track.specialAward) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'No award linked to this track',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const awardId = track.specialAward.id
|
|
|
|
|
|
|
|
|
|
// Upsert eligibility records
|
|
|
|
|
let createdCount = 0
|
|
|
|
|
let updatedCount = 0
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.$transaction(async (tx) => {
|
|
|
|
|
for (const projectId of input.projectIds) {
|
|
|
|
|
const existing = await tx.awardEligibility.findUnique({
|
|
|
|
|
where: { awardId_projectId: { awardId, projectId } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
await tx.awardEligibility.update({
|
|
|
|
|
where: { id: existing.id },
|
|
|
|
|
data: {
|
|
|
|
|
eligible: input.eligible,
|
|
|
|
|
method: 'MANUAL',
|
|
|
|
|
overriddenBy: ctx.user.id,
|
|
|
|
|
overriddenAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
updatedCount++
|
|
|
|
|
} else {
|
|
|
|
|
await tx.awardEligibility.create({
|
|
|
|
|
data: {
|
|
|
|
|
awardId,
|
|
|
|
|
projectId,
|
|
|
|
|
eligible: input.eligible,
|
|
|
|
|
method: 'MANUAL',
|
|
|
|
|
overriddenBy: ctx.user.id,
|
|
|
|
|
overriddenAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
createdCount++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Also create ProjectStageState entries for routing through pipeline
|
|
|
|
|
const firstStage = await tx.stage.findFirst({
|
|
|
|
|
where: { trackId: input.trackId },
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (firstStage) {
|
|
|
|
|
for (const projectId of input.projectIds) {
|
|
|
|
|
await tx.projectStageState.upsert({
|
|
|
|
|
where: {
|
|
|
|
|
projectId_trackId_stageId: {
|
|
|
|
|
projectId,
|
|
|
|
|
trackId: input.trackId,
|
|
|
|
|
stageId: firstStage.id,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
create: {
|
|
|
|
|
projectId,
|
|
|
|
|
trackId: input.trackId,
|
|
|
|
|
stageId: firstStage.id,
|
|
|
|
|
state: input.eligible ? 'PENDING' : 'REJECTED',
|
|
|
|
|
},
|
|
|
|
|
update: {
|
|
|
|
|
state: input.eligible ? 'PENDING' : 'REJECTED',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: tx,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'ROUTE_PROJECTS_TO_AWARD',
|
|
|
|
|
entityType: 'Track',
|
|
|
|
|
entityId: input.trackId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
awardId,
|
|
|
|
|
projectCount: input.projectIds.length,
|
|
|
|
|
eligible: input.eligible,
|
|
|
|
|
created: createdCount,
|
|
|
|
|
updated: updatedCount,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { created: createdCount, updated: updatedCount, total: input.projectIds.length }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Finalize winners for an award (Award Master only)
|
|
|
|
|
*/
|
|
|
|
|
finalizeWinners: awardMasterProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
trackId: z.string(),
|
|
|
|
|
winnerProjectId: z.string(),
|
|
|
|
|
override: z.boolean().default(false),
|
|
|
|
|
reasonText: z.string().max(2000).optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const track = await ctx.prisma.track.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.trackId },
|
|
|
|
|
include: { specialAward: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!track.specialAward) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'No award linked to this track',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const award = track.specialAward
|
|
|
|
|
|
|
|
|
|
// Verify the winning project is eligible
|
|
|
|
|
const eligibility = await ctx.prisma.awardEligibility.findUnique({
|
|
|
|
|
where: {
|
|
|
|
|
awardId_projectId: {
|
|
|
|
|
awardId: award.id,
|
|
|
|
|
projectId: input.winnerProjectId,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!eligibility || !eligibility.eligible) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'PRECONDITION_FAILED',
|
|
|
|
|
message: 'Selected project is not eligible for this award',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if award already has a winner
|
|
|
|
|
if (award.winnerProjectId && !input.override) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'CONFLICT',
|
|
|
|
|
message: `Award already has a winner. Set override=true to change it.`,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate award is in VOTING_OPEN or CLOSED status (appropriate for finalization)
|
|
|
|
|
if (!['VOTING_OPEN', 'CLOSED'].includes(award.status)) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'PRECONDITION_FAILED',
|
|
|
|
|
message: `Award must be in VOTING_OPEN or CLOSED status to finalize. Current: ${award.status}`,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const previousWinnerId = award.winnerProjectId
|
|
|
|
|
|
|
|
|
|
const result = await ctx.prisma.$transaction(async (tx) => {
|
|
|
|
|
const updated = await tx.specialAward.update({
|
|
|
|
|
where: { id: award.id },
|
|
|
|
|
data: {
|
|
|
|
|
winnerProjectId: input.winnerProjectId,
|
|
|
|
|
winnerOverridden: input.override,
|
|
|
|
|
winnerOverriddenBy: input.override ? ctx.user.id : null,
|
|
|
|
|
status: 'CLOSED',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Mark winner project as COMPLETED in the award track
|
|
|
|
|
const firstStage = await tx.stage.findFirst({
|
|
|
|
|
where: { trackId: input.trackId },
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (firstStage) {
|
|
|
|
|
await tx.projectStageState.updateMany({
|
|
|
|
|
where: {
|
|
|
|
|
trackId: input.trackId,
|
|
|
|
|
stageId: firstStage.id,
|
|
|
|
|
projectId: input.winnerProjectId,
|
|
|
|
|
},
|
|
|
|
|
data: { state: 'COMPLETED' },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Record in decision audit
|
|
|
|
|
await tx.decisionAuditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
eventType: 'award.winner_finalized',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: award.id,
|
|
|
|
|
actorId: ctx.user.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
winnerProjectId: input.winnerProjectId,
|
|
|
|
|
previousWinnerId,
|
|
|
|
|
override: input.override,
|
|
|
|
|
reasonText: input.reasonText,
|
|
|
|
|
} as Prisma.InputJsonValue,
|
|
|
|
|
snapshotJson: {
|
|
|
|
|
awardName: award.name,
|
|
|
|
|
previousStatus: award.status,
|
|
|
|
|
previousWinner: previousWinnerId,
|
|
|
|
|
} as Prisma.InputJsonValue,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (input.override && previousWinnerId) {
|
|
|
|
|
await tx.overrideAction.create({
|
|
|
|
|
data: {
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: award.id,
|
|
|
|
|
previousValue: { winnerProjectId: previousWinnerId } as Prisma.InputJsonValue,
|
|
|
|
|
newValueJson: { winnerProjectId: input.winnerProjectId } as Prisma.InputJsonValue,
|
|
|
|
|
reasonCode: 'ADMIN_DISCRETION',
|
|
|
|
|
reasonText: input.reasonText ?? null,
|
|
|
|
|
actorId: ctx.user.id,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: tx,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'AWARD_WINNER_FINALIZED',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: award.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
awardName: award.name,
|
|
|
|
|
winnerProjectId: input.winnerProjectId,
|
|
|
|
|
override: input.override,
|
|
|
|
|
previousWinnerId,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return updated
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get projects routed to an award track with eligibility and votes
|
|
|
|
|
*/
|
|
|
|
|
getTrackProjects: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
trackId: z.string(),
|
|
|
|
|
eligibleOnly: z.boolean().default(false),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const track = await ctx.prisma.track.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.trackId },
|
|
|
|
|
include: { specialAward: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!track.specialAward) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'No award linked to this track',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const awardId = track.specialAward.id
|
|
|
|
|
|
|
|
|
|
const eligibilityWhere: Prisma.AwardEligibilityWhereInput = {
|
|
|
|
|
awardId,
|
|
|
|
|
}
|
|
|
|
|
if (input.eligibleOnly) {
|
|
|
|
|
eligibilityWhere.eligible = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const eligibilities = await ctx.prisma.awardEligibility.findMany({
|
|
|
|
|
where: eligibilityWhere,
|
|
|
|
|
include: {
|
|
|
|
|
project: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
teamName: true,
|
|
|
|
|
tags: true,
|
|
|
|
|
description: true,
|
|
|
|
|
status: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get vote counts per project
|
|
|
|
|
const projectIds = eligibilities.map((e) => e.projectId)
|
|
|
|
|
const voteSummary =
|
|
|
|
|
projectIds.length > 0
|
|
|
|
|
? await ctx.prisma.awardVote.groupBy({
|
|
|
|
|
by: ['projectId'],
|
|
|
|
|
where: { awardId, projectId: { in: projectIds } },
|
|
|
|
|
_count: true,
|
|
|
|
|
})
|
|
|
|
|
: []
|
|
|
|
|
|
|
|
|
|
const voteMap = new Map(
|
|
|
|
|
voteSummary.map((v) => [v.projectId, v._count])
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
trackId: input.trackId,
|
|
|
|
|
awardId,
|
|
|
|
|
awardName: track.specialAward.name,
|
|
|
|
|
winnerProjectId: track.specialAward.winnerProjectId,
|
|
|
|
|
status: track.specialAward.status,
|
|
|
|
|
projects: eligibilities.map((e) => ({
|
|
|
|
|
...e,
|
|
|
|
|
voteCount: voteMap.get(e.projectId) ?? 0,
|
|
|
|
|
isWinner: e.projectId === track.specialAward!.winnerProjectId,
|
|
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
})
|