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'
|
2026-03-03 22:57:52 +01:00
|
|
|
import { sendStyledNotificationEmail, getAwardSelectionNotificationTemplate } from '@/lib/email'
|
2026-03-03 19:14:41 +01:00
|
|
|
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
2026-02-18 22:47:20 +01:00
|
|
|
import type { PrismaClient } from '@prisma/client'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Verify the current session user exists in the database.
|
|
|
|
|
* Guards against stale JWT sessions (e.g., after database reseed).
|
|
|
|
|
*/
|
|
|
|
|
async function ensureUserExists(db: PrismaClient, userId: string): Promise<string> {
|
|
|
|
|
const user = await db.user.findUnique({
|
|
|
|
|
where: { id: userId },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
if (!user) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'UNAUTHORIZED',
|
|
|
|
|
message: 'Your session refers to a user that no longer exists. Please log out and log back in.',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return user.id
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
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: {
|
2026-02-19 09:56:09 +01:00
|
|
|
eligibilities: { where: { eligible: true } },
|
2026-02-14 15:26:42 +01:00
|
|
|
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: {
|
2026-02-19 09:56:09 +01:00
|
|
|
eligibilities: { where: { eligible: true } },
|
2026-02-14 15:26:42 +01:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 11:11:00 +01:00
|
|
|
// Count eligible projects and total assessed
|
|
|
|
|
const [eligibleCount, totalAssessed] = await Promise.all([
|
|
|
|
|
ctx.prisma.awardEligibility.count({
|
|
|
|
|
where: { awardId: input.id, eligible: true },
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.awardEligibility.count({
|
|
|
|
|
where: { awardId: input.id },
|
|
|
|
|
}),
|
|
|
|
|
])
|
2026-02-14 15:26:42 +01:00
|
|
|
|
2026-02-19 11:11:00 +01:00
|
|
|
return { ...award, competition, eligibleCount, totalAssessed }
|
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 }) => {
|
2026-02-18 22:47:20 +01:00
|
|
|
const verifiedUserId = await ensureUserExists(ctx.prisma, ctx.user.id)
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
await ctx.prisma.awardEligibility.upsert({
|
|
|
|
|
where: {
|
|
|
|
|
awardId_projectId: {
|
|
|
|
|
awardId: input.awardId,
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
create: {
|
2026-02-18 22:47:20 +01:00
|
|
|
award: { connect: { id: input.awardId } },
|
|
|
|
|
project: { connect: { id: input.projectId } },
|
2026-02-14 15:26:42 +01:00
|
|
|
eligible: input.eligible,
|
|
|
|
|
method: 'MANUAL',
|
2026-02-18 22:47:20 +01:00
|
|
|
overriddenByUser: { connect: { id: verifiedUserId } },
|
2026-02-14 15:26:42 +01:00
|
|
|
overriddenAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
update: {
|
|
|
|
|
eligible: input.eligible,
|
2026-02-18 22:47:20 +01:00
|
|
|
overriddenByUser: { connect: { id: verifiedUserId } },
|
2026-02-14 15:26:42 +01:00
|
|
|
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',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 22:47:20 +01:00
|
|
|
const verifiedUserId = await ensureUserExists(ctx.prisma, ctx.user.id)
|
|
|
|
|
|
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
|
|
|
// Mark all as confirmed
|
|
|
|
|
await ctx.prisma.awardEligibility.updateMany({
|
|
|
|
|
where: { awardId: input.awardId, shortlisted: true, eligible: true },
|
|
|
|
|
data: {
|
|
|
|
|
confirmedAt: new Date(),
|
2026-02-18 22:47:20 +01:00
|
|
|
confirmedBy: verifiedUserId,
|
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
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}),
|
2026-03-03 19:14:41 +01:00
|
|
|
|
|
|
|
|
// ─── Pool Notifications ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get account stats for eligible projects (how many need invite vs have account)
|
|
|
|
|
*/
|
|
|
|
|
getNotificationStats: adminProcedure
|
|
|
|
|
.input(z.object({ awardId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const eligibilities = await ctx.prisma.awardEligibility.findMany({
|
|
|
|
|
where: { awardId: input.awardId, eligible: true },
|
|
|
|
|
select: {
|
|
|
|
|
project: {
|
|
|
|
|
select: {
|
|
|
|
|
submittedBy: { select: { id: true, passwordHash: true } },
|
|
|
|
|
teamMembers: {
|
|
|
|
|
select: { user: { select: { id: true, passwordHash: true } } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const seen = new Set<string>()
|
|
|
|
|
let needsInvite = 0
|
|
|
|
|
let hasAccount = 0
|
|
|
|
|
|
|
|
|
|
for (const e of eligibilities) {
|
|
|
|
|
const submitter = e.project.submittedBy
|
|
|
|
|
if (submitter && !seen.has(submitter.id)) {
|
|
|
|
|
seen.add(submitter.id)
|
|
|
|
|
if (submitter.passwordHash) hasAccount++
|
|
|
|
|
else needsInvite++
|
|
|
|
|
}
|
|
|
|
|
for (const tm of e.project.teamMembers) {
|
|
|
|
|
if (!seen.has(tm.user.id)) {
|
|
|
|
|
seen.add(tm.user.id)
|
|
|
|
|
if (tm.user.passwordHash) hasAccount++
|
|
|
|
|
else needsInvite++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { needsInvite, hasAccount, totalProjects: eligibilities.length }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-03-03 22:57:52 +01:00
|
|
|
previewAwardSelectionEmail: adminProcedure
|
|
|
|
|
.input(z.object({
|
|
|
|
|
awardId: z.string(),
|
|
|
|
|
customMessage: z.string().optional(),
|
|
|
|
|
}))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.awardId },
|
|
|
|
|
select: { name: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const eligibleCount = await ctx.prisma.awardEligibility.count({
|
|
|
|
|
where: { awardId: input.awardId, eligible: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const template = getAwardSelectionNotificationTemplate(
|
|
|
|
|
'Team Member',
|
|
|
|
|
'Your Project',
|
|
|
|
|
award.name,
|
|
|
|
|
input.customMessage || undefined,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return { html: template.html, subject: template.subject, recipientCount: eligibleCount }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
/**
|
|
|
|
|
* Notify eligible projects that they've been selected for an award.
|
|
|
|
|
* Generates invite tokens for passwordless users.
|
|
|
|
|
*/
|
|
|
|
|
notifyEligibleProjects: adminProcedure
|
|
|
|
|
.input(z.object({
|
|
|
|
|
awardId: z.string(),
|
|
|
|
|
customMessage: z.string().optional(),
|
|
|
|
|
}))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.awardId },
|
|
|
|
|
select: { id: true, name: true, description: true, status: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get eligible projects with submitter + team members
|
|
|
|
|
const eligibilities = await ctx.prisma.awardEligibility.findMany({
|
|
|
|
|
where: { awardId: input.awardId, eligible: true },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
projectId: true,
|
|
|
|
|
project: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
submittedBy: {
|
|
|
|
|
select: { id: true, email: true, name: true, passwordHash: true },
|
|
|
|
|
},
|
|
|
|
|
teamMembers: {
|
|
|
|
|
select: {
|
|
|
|
|
user: {
|
|
|
|
|
select: { id: true, email: true, name: true, passwordHash: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (eligibilities.length === 0) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'No eligible projects to notify',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pre-generate invite tokens for passwordless users
|
|
|
|
|
const expiryMs = await getInviteExpiryMs(ctx.prisma)
|
|
|
|
|
const expiresAt = new Date(Date.now() + expiryMs)
|
|
|
|
|
const tokenMap = new Map<string, string>() // userId -> token
|
|
|
|
|
|
|
|
|
|
const allUsers: Array<{ id: string; passwordHash: string | null }> = []
|
|
|
|
|
for (const e of eligibilities) {
|
|
|
|
|
if (e.project.submittedBy) allUsers.push(e.project.submittedBy)
|
|
|
|
|
for (const tm of e.project.teamMembers) allUsers.push(tm.user)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const passwordlessUsers = allUsers.filter((u) => !u.passwordHash)
|
|
|
|
|
const uniquePasswordless = [...new Map(passwordlessUsers.map((u) => [u.id, u])).values()]
|
|
|
|
|
|
|
|
|
|
for (const user of uniquePasswordless) {
|
|
|
|
|
const token = generateInviteToken()
|
|
|
|
|
tokenMap.set(user.id, token)
|
|
|
|
|
await ctx.prisma.user.update({
|
|
|
|
|
where: { id: user.id },
|
|
|
|
|
data: { inviteToken: token, inviteTokenExpiresAt: expiresAt },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send emails
|
|
|
|
|
let emailsSent = 0
|
|
|
|
|
let emailsFailed = 0
|
|
|
|
|
|
|
|
|
|
for (const e of eligibilities) {
|
|
|
|
|
const recipients: Array<{ id: string; email: string; name: string | null; passwordHash: string | null }> = []
|
|
|
|
|
if (e.project.submittedBy) recipients.push(e.project.submittedBy)
|
|
|
|
|
for (const tm of e.project.teamMembers) {
|
|
|
|
|
if (!recipients.some((r) => r.id === tm.user.id)) {
|
|
|
|
|
recipients.push(tm.user)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const recipient of recipients) {
|
|
|
|
|
const token = tokenMap.get(recipient.id)
|
|
|
|
|
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await sendStyledNotificationEmail(
|
|
|
|
|
recipient.email,
|
|
|
|
|
recipient.name || '',
|
|
|
|
|
'AWARD_SELECTION_NOTIFICATION',
|
|
|
|
|
{
|
2026-03-04 00:04:28 +01:00
|
|
|
title: `Under consideration for ${award.name}`,
|
2026-03-03 19:14:41 +01:00
|
|
|
message: input.customMessage || '',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectName: e.project.title,
|
|
|
|
|
awardName: award.name,
|
|
|
|
|
customMessage: input.customMessage,
|
|
|
|
|
accountUrl,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
emailsSent++
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[award-notify] Failed to email ${recipient.email}:`, err)
|
|
|
|
|
emailsFailed++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'UPDATE',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: input.awardId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
action: 'NOTIFY_ELIGIBLE_PROJECTS',
|
|
|
|
|
eligibleCount: eligibilities.length,
|
|
|
|
|
emailsSent,
|
|
|
|
|
emailsFailed,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { notified: eligibilities.length, emailsSent, emailsFailed }
|
|
|
|
|
}),
|
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
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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-03-03 23:42:21 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reorder award rounds via drag-and-drop.
|
|
|
|
|
* Uses a two-phase transaction: first set all to negative temps (avoid unique constraint),
|
|
|
|
|
* then set to final values.
|
|
|
|
|
*/
|
|
|
|
|
reorderAwardRounds: adminProcedure
|
|
|
|
|
.input(z.object({
|
|
|
|
|
awardId: z.string(),
|
|
|
|
|
roundIds: z.array(z.string()).min(1),
|
|
|
|
|
}))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const existingRounds = await ctx.prisma.round.findMany({
|
|
|
|
|
where: { specialAwardId: input.awardId },
|
|
|
|
|
select: { id: true, competitionId: true, sortOrder: true },
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (existingRounds.length !== input.roundIds.length) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Round list does not match existing award rounds',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const existingIds = new Set(existingRounds.map((r) => r.id))
|
|
|
|
|
for (const id of input.roundIds) {
|
|
|
|
|
if (!existingIds.has(id)) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: `Round ${id} does not belong to this award`,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect the existing sortOrder values (in ascending order) and reassign them
|
|
|
|
|
// to the new ordering. This keeps the same sortOrder slots, just remapped.
|
|
|
|
|
const sortSlots = existingRounds.map((r) => r.sortOrder).sort((a, b) => a - b)
|
|
|
|
|
const competitionId = existingRounds[0].competitionId
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.$transaction(async (tx) => {
|
|
|
|
|
// Phase 1: set all to negative temps to avoid unique constraint
|
|
|
|
|
for (let i = 0; i < existingRounds.length; i++) {
|
|
|
|
|
await tx.round.update({
|
|
|
|
|
where: { id: existingRounds[i].id },
|
|
|
|
|
data: { sortOrder: -(i + 1000) },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Phase 2: assign final sort orders based on new ordering
|
|
|
|
|
for (let i = 0; i < input.roundIds.length; i++) {
|
|
|
|
|
await tx.round.update({
|
|
|
|
|
where: { id: input.roundIds[i] },
|
|
|
|
|
data: { sortOrder: sortSlots[i] },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'UPDATE',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: input.awardId,
|
|
|
|
|
detailsJson: { action: 'REORDER_ROUNDS', newOrder: input.roundIds },
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Assign (or reassign) eligible projects to the first award round.
|
|
|
|
|
* Re-runnable: moves existing ProjectRoundState entries from other award rounds
|
|
|
|
|
* to the first, and creates new PENDING entries for unassigned projects.
|
|
|
|
|
*/
|
|
|
|
|
assignToFirstRound: 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 },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (award.eligibilityMode !== 'SEPARATE_POOL') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Assign to first round is only available for Separate Pool awards',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const awardRounds = await ctx.prisma.round.findMany({
|
|
|
|
|
where: { specialAwardId: input.awardId },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (awardRounds.length === 0) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Create at least one round before assigning projects',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const firstRound = awardRounds[0]
|
|
|
|
|
const otherRoundIds = awardRounds.slice(1).map((r) => r.id)
|
|
|
|
|
|
|
|
|
|
// Get all eligible projects (confirmed or not — any eligible project)
|
|
|
|
|
const eligible = await ctx.prisma.awardEligibility.findMany({
|
|
|
|
|
where: { awardId: input.awardId, eligible: true },
|
|
|
|
|
select: { projectId: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (eligible.length === 0) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'No eligible projects to assign',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const projectIds = eligible.map((e) => e.projectId)
|
|
|
|
|
|
|
|
|
|
// Move existing entries from other award rounds to the first round
|
|
|
|
|
let movedCount = 0
|
|
|
|
|
if (otherRoundIds.length > 0) {
|
|
|
|
|
const moved = await ctx.prisma.projectRoundState.updateMany({
|
|
|
|
|
where: {
|
|
|
|
|
roundId: { in: otherRoundIds },
|
|
|
|
|
projectId: { in: projectIds },
|
|
|
|
|
},
|
|
|
|
|
data: { roundId: firstRound.id, state: 'PENDING' },
|
|
|
|
|
})
|
|
|
|
|
movedCount = moved.count
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create PENDING entries for projects not yet in the first round
|
|
|
|
|
const existing = await ctx.prisma.projectRoundState.findMany({
|
|
|
|
|
where: { roundId: firstRound.id, projectId: { in: projectIds } },
|
|
|
|
|
select: { projectId: true },
|
|
|
|
|
})
|
|
|
|
|
const existingSet = new Set(existing.map((e) => e.projectId))
|
|
|
|
|
const newProjectIds = projectIds.filter((id) => !existingSet.has(id))
|
|
|
|
|
|
|
|
|
|
let createdCount = 0
|
|
|
|
|
if (newProjectIds.length > 0) {
|
|
|
|
|
await ctx.prisma.projectRoundState.createMany({
|
|
|
|
|
data: newProjectIds.map((projectId) => ({
|
|
|
|
|
projectId,
|
|
|
|
|
roundId: firstRound.id,
|
|
|
|
|
state: 'PENDING' as const,
|
|
|
|
|
})),
|
|
|
|
|
skipDuplicates: true,
|
|
|
|
|
})
|
|
|
|
|
createdCount = newProjectIds.length
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'UPDATE',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: input.awardId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
action: 'ASSIGN_TO_FIRST_ROUND',
|
|
|
|
|
firstRoundId: firstRound.id,
|
|
|
|
|
movedCount,
|
|
|
|
|
createdCount,
|
|
|
|
|
totalEligible: projectIds.length,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { movedCount, createdCount, totalAssigned: existingSet.size + createdCount }
|
|
|
|
|
}),
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|