Fix AI filtering bugs, add special award shortlist integration
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m20s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m20s
Part 1 - Bug Fixes: - Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.) - Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID - Fix getAIConfidenceScore() with same nesting issue (always returned 0) Part 2 - Special Award Track Integration: - Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility - Add specialAwardId to Round for award-owned rounds - Update AI eligibility service to return qualityScore (0-100) for ranking - Update eligibility job with filteringRoundId scoping and auto-shortlist top N - Add 8 new specialAward router procedures (listForRound, runEligibilityForRound, listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound) - Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog - Add "Special Award Tracks" section to filtering dashboard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -764,4 +764,372 @@ export const specialAwardRouter = router({
|
||||
|
||||
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 }
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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',
|
||||
})
|
||||
}
|
||||
|
||||
// Mark all as confirmed
|
||||
await ctx.prisma.awardEligibility.updateMany({
|
||||
where: { awardId: input.awardId, shortlisted: true, eligible: true },
|
||||
data: {
|
||||
confirmedAt: new Date(),
|
||||
confirmedBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 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 },
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user