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 },
|
|
|
|
|
},
|
Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION
- Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards)
- Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review)
- Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone
- Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields
- Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish
- Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors
- Bulk upload page for admin project imports
- File router enhanced with admin upload and submission window procedures
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:16:55 +01:00
|
|
|
competition: {
|
|
|
|
|
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } },
|
|
|
|
|
},
|
|
|
|
|
evaluationRound: {
|
|
|
|
|
select: { id: true, name: true, roundType: true },
|
|
|
|
|
},
|
|
|
|
|
awardJuryGroup: {
|
|
|
|
|
select: { id: true, name: true },
|
|
|
|
|
},
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-17 21:00:20 +01:00
|
|
|
// Auto-resolve competition if missing (legacy awards created without competitionId)
|
|
|
|
|
let { competition } = award
|
|
|
|
|
if (!competition && award.programId) {
|
|
|
|
|
const comp = await ctx.prisma.competition.findFirst({
|
|
|
|
|
where: { programId: award.programId },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const } } },
|
|
|
|
|
})
|
|
|
|
|
if (comp) {
|
|
|
|
|
competition = comp
|
|
|
|
|
// Backfill competitionId on the award
|
|
|
|
|
await ctx.prisma.specialAward.update({
|
|
|
|
|
where: { id: input.id },
|
|
|
|
|
data: { competitionId: comp.id },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
// Count eligible projects
|
|
|
|
|
const eligibleCount = await ctx.prisma.awardEligibility.count({
|
|
|
|
|
where: { awardId: input.id, eligible: true },
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-17 21:00:20 +01:00
|
|
|
return { ...award, competition, eligibleCount }
|
2026-02-14 15:26:42 +01:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
// ─── 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(),
|
Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION
- Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards)
- Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review)
- Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone
- Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields
- Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish
- Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors
- Bulk upload page for admin project imports
- File router enhanced with admin upload and submission window procedures
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:16:55 +01:00
|
|
|
competitionId: z.string().optional(),
|
|
|
|
|
evaluationRoundId: z.string().optional(),
|
|
|
|
|
juryGroupId: z.string().optional(),
|
|
|
|
|
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
2026-02-17 21:00:20 +01:00
|
|
|
// Auto-resolve competitionId from program if not provided
|
|
|
|
|
let competitionId = input.competitionId
|
|
|
|
|
if (!competitionId) {
|
|
|
|
|
const comp = await ctx.prisma.competition.findFirst({
|
|
|
|
|
where: { programId: input.programId },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
competitionId = comp?.id ?? undefined
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
const maxOrder = await ctx.prisma.specialAward.aggregate({
|
|
|
|
|
where: { programId: input.programId },
|
|
|
|
|
_max: { sortOrder: true },
|
|
|
|
|
})
|
|
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
const award = await ctx.prisma.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,
|
2026-02-17 21:00:20 +01:00
|
|
|
competitionId,
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
evaluationRoundId: input.evaluationRoundId,
|
|
|
|
|
juryGroupId: input.juryGroupId,
|
|
|
|
|
eligibilityMode: input.eligibilityMode,
|
|
|
|
|
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-02-14 15:26:42 +01:00
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
// Audit outside transaction so failures don't roll back the create
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CREATE',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: award.id,
|
|
|
|
|
detailsJson: { name: input.name, scoringMode: input.scoringMode },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
votingStartAt: z.date().optional(),
|
|
|
|
|
votingEndAt: z.date().optional(),
|
Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION
- Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards)
- Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review)
- Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone
- Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields
- Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish
- Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors
- Bulk upload page for admin project imports
- File router enhanced with admin upload and submission window procedures
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:16:55 +01:00
|
|
|
competitionId: z.string().nullable().optional(),
|
|
|
|
|
evaluationRoundId: z.string().nullable().optional(),
|
|
|
|
|
juryGroupId: z.string().nullable().optional(),
|
|
|
|
|
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
2026-02-17 19:53:20 +01:00
|
|
|
const { id, ...rest } = input
|
2026-02-17 21:00:20 +01:00
|
|
|
|
|
|
|
|
// Auto-resolve competitionId if missing on existing award
|
|
|
|
|
if (rest.competitionId === undefined) {
|
|
|
|
|
const existing = await ctx.prisma.specialAward.findUnique({
|
|
|
|
|
where: { id },
|
|
|
|
|
select: { competitionId: true, programId: true },
|
|
|
|
|
})
|
|
|
|
|
if (existing && !existing.competitionId) {
|
|
|
|
|
const comp = await ctx.prisma.competition.findFirst({
|
|
|
|
|
where: { programId: existing.programId },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
if (comp) rest.competitionId = comp.id
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
const award = await ctx.prisma.specialAward.update({
|
|
|
|
|
where: { id },
|
2026-02-17 19:53:20 +01:00
|
|
|
data: rest,
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 }) => {
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
await ctx.prisma.specialAward.delete({ where: { id: input.id } })
|
2026-02-14 15:26:42 +01:00
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
// Audit outside transaction so failures don't break the delete
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'DELETE',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: input.id,
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
const award = await ctx.prisma.specialAward.update({
|
|
|
|
|
where: { id: input.id },
|
|
|
|
|
data: updateData,
|
|
|
|
|
})
|
2026-02-14 15:26:42 +01:00
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
// Audit outside transaction so failures don't break the status update
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
})
|
|
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-02-14 15:26:42 +01:00
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
// Audit outside transaction so failures don't break the winner update
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return award
|
|
|
|
|
}),
|
Fix AI filtering bugs, add special award shortlist integration
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>
2026-02-17 15:38:31 +01:00
|
|
|
|
|
|
|
|
// ─── 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 }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-17 22:05:58 +01:00
|
|
|
/**
|
|
|
|
|
* Bulk toggle shortlisted status for multiple projects
|
|
|
|
|
*/
|
|
|
|
|
bulkToggleShortlisted: adminProcedure
|
|
|
|
|
.input(z.object({
|
|
|
|
|
awardId: z.string(),
|
|
|
|
|
projectIds: z.array(z.string()),
|
|
|
|
|
shortlisted: z.boolean(),
|
|
|
|
|
}))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const result = await ctx.prisma.awardEligibility.updateMany({
|
|
|
|
|
where: {
|
|
|
|
|
awardId: input.awardId,
|
|
|
|
|
projectId: { in: input.projectIds },
|
|
|
|
|
eligible: true,
|
|
|
|
|
},
|
|
|
|
|
data: { shortlisted: input.shortlisted },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { updated: result.count, shortlisted: input.shortlisted }
|
|
|
|
|
}),
|
|
|
|
|
|
Fix AI filtering bugs, add special award shortlist integration
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>
2026-02-17 15:38:31 +01:00
|
|
|
/**
|
|
|
|
|
* 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 },
|
|
|
|
|
})
|
|
|
|
|
}),
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|