Fix AI filtering bugs, add special award shortlist integration
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:
Matt
2026-02-17 15:38:31 +01:00
parent 6743119c4d
commit a02ed59158
10 changed files with 1308 additions and 309 deletions

View File

@@ -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 },
})
}),
})