2026-02-14 15:26:42 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
|
|
|
|
import { Prisma } from '@prisma/client'
|
2026-04-06 16:42:21 -04:00
|
|
|
import { router, protectedProcedure, adminProcedure, awardMasterProcedure } from '../trpc'
|
2026-03-04 20:18:50 +01:00
|
|
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
2026-02-14 15:26:42 +01:00
|
|
|
import { logAudit } from '../utils/audit'
|
|
|
|
|
import { processEligibilityJob } from '../services/award-eligibility-job'
|
2026-04-06 16:42:21 -04:00
|
|
|
import { resolveAwardWinner } from '../services/award-winner-resolver'
|
2026-04-07 20:13:51 -04:00
|
|
|
import { getAwardSelectionNotificationTemplate, sendJuryInvitationEmail } from '@/lib/email'
|
2026-03-03 19:14:41 +01:00
|
|
|
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
2026-03-04 13:29:22 +01:00
|
|
|
import { sendBatchNotifications } from '../services/notification-sender'
|
|
|
|
|
import type { NotificationItem } from '../services/notification-sender'
|
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-04-07 20:13:51 -04:00
|
|
|
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).nullable().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
|
2026-03-07 16:18:24 +01:00
|
|
|
processEligibilityJob(
|
2026-02-14 15:26:42 +01:00
|
|
|
input.awardId,
|
|
|
|
|
input.includeSubmitted ?? false,
|
|
|
|
|
ctx.user.id
|
2026-03-07 16:18:24 +01:00
|
|
|
).catch((err) => {
|
|
|
|
|
console.error('[SpecialAward] processEligibilityJob failed:', err)
|
|
|
|
|
})
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
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 }) => {
|
2026-03-04 20:18:50 +01:00
|
|
|
const jurors = await ctx.prisma.awardJuror.findMany({
|
2026-02-14 15:26:42 +01:00
|
|
|
where: { awardId: input.awardId },
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
role: true,
|
|
|
|
|
profileImageKey: true,
|
|
|
|
|
profileImageProvider: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-03-04 20:18:50 +01:00
|
|
|
return Promise.all(
|
|
|
|
|
jurors.map(async (j) => ({
|
|
|
|
|
...j,
|
|
|
|
|
user: {
|
|
|
|
|
...j.user,
|
|
|
|
|
avatarUrl: await getUserAvatarUrl(j.user.profileImageKey, j.user.profileImageProvider),
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
)
|
2026-02-14 15:26:42 +01:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-07 20:13:51 -04:00
|
|
|
/**
|
|
|
|
|
* Bulk invite new users as award jurors — creates accounts, assigns role, sends invite emails
|
|
|
|
|
*/
|
|
|
|
|
bulkInviteJurors: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
awardId: z.string(),
|
|
|
|
|
role: z.enum(['JURY_MEMBER', 'AWARD_MASTER']).default('AWARD_MASTER'),
|
|
|
|
|
invitees: z.array(
|
|
|
|
|
z.object({
|
|
|
|
|
name: z.string().optional(),
|
|
|
|
|
email: z.string().email(),
|
|
|
|
|
})
|
|
|
|
|
).min(1).max(50),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.awardId },
|
|
|
|
|
select: { id: true, name: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const results: Array<{ email: string; status: 'created' | 'existing' | 'error'; error?: string }> = []
|
|
|
|
|
|
|
|
|
|
for (const invitee of input.invitees) {
|
|
|
|
|
try {
|
|
|
|
|
let user = await ctx.prisma.user.findUnique({
|
|
|
|
|
where: { email: invitee.email },
|
|
|
|
|
select: { id: true, status: true, role: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
const inviteToken = generateInviteToken()
|
|
|
|
|
const expiryMs = await getInviteExpiryMs(ctx.prisma)
|
|
|
|
|
|
|
|
|
|
user = await ctx.prisma.user.create({
|
|
|
|
|
data: {
|
|
|
|
|
email: invitee.email,
|
|
|
|
|
name: invitee.name || null,
|
|
|
|
|
role: input.role,
|
|
|
|
|
status: 'INVITED',
|
|
|
|
|
inviteToken,
|
|
|
|
|
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
|
|
|
|
},
|
|
|
|
|
select: { id: true, status: true, role: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const inviteUrl = `${process.env.NEXTAUTH_URL}/accept-invite?token=${inviteToken}`
|
|
|
|
|
try {
|
|
|
|
|
await sendJuryInvitationEmail(
|
|
|
|
|
invitee.email,
|
|
|
|
|
invitee.name || null,
|
|
|
|
|
inviteUrl,
|
|
|
|
|
award.name
|
|
|
|
|
)
|
|
|
|
|
} catch {
|
|
|
|
|
// Email failure shouldn't block the invite
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
results.push({ email: invitee.email, status: 'created' })
|
|
|
|
|
} else {
|
|
|
|
|
results.push({ email: invitee.email, status: 'existing' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.awardJuror.upsert({
|
|
|
|
|
where: {
|
|
|
|
|
awardId_userId: { awardId: input.awardId, userId: user.id },
|
|
|
|
|
},
|
|
|
|
|
update: {},
|
|
|
|
|
create: { awardId: input.awardId, userId: user.id },
|
|
|
|
|
})
|
|
|
|
|
} catch (err) {
|
|
|
|
|
results.push({
|
|
|
|
|
email: invitee.email,
|
|
|
|
|
status: 'error',
|
|
|
|
|
error: err instanceof Error ? err.message : 'Unknown error',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CREATE',
|
|
|
|
|
entityType: 'AwardJuror',
|
|
|
|
|
entityId: input.awardId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
action: 'BULK_INVITE',
|
|
|
|
|
awardName: award.name,
|
|
|
|
|
role: input.role,
|
|
|
|
|
count: input.invitees.length,
|
|
|
|
|
results,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
created: results.filter((r) => r.status === 'created').length,
|
|
|
|
|
existing: results.filter((r) => r.status === 'existing').length,
|
|
|
|
|
errors: results.filter((r) => r.status === 'error').length,
|
|
|
|
|
results,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
// ─── 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,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-06 16:42:21 -04:00
|
|
|
/**
|
|
|
|
|
* Enhanced award detail for Award Master — includes project scores and chair vote visibility
|
|
|
|
|
*/
|
|
|
|
|
getMyAwardDetailEnhanced: awardMasterProcedure
|
|
|
|
|
.input(z.object({ awardId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
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 assigned to this award' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [award, eligibleProjects, myVotes, allJurors] = await Promise.all([
|
|
|
|
|
ctx.prisma.specialAward.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.awardId },
|
|
|
|
|
include: {
|
|
|
|
|
competition: { select: { id: true, name: true } },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
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 },
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.awardJuror.findMany({
|
|
|
|
|
where: { awardId: input.awardId },
|
|
|
|
|
select: { userId: true, isChair: true, user: { select: { name: true } } },
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// Fetch evaluation scores for eligible projects
|
|
|
|
|
const projectIds = eligibleProjects.map((e) => e.project.id)
|
|
|
|
|
const projectScores: Record<string, { avg: number; count: number }> = {}
|
|
|
|
|
|
|
|
|
|
if (award.evaluationRoundId) {
|
|
|
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
status: 'SUBMITTED',
|
|
|
|
|
assignment: {
|
|
|
|
|
roundId: award.evaluationRoundId,
|
|
|
|
|
projectId: { in: projectIds },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
globalScore: true,
|
|
|
|
|
assignment: { select: { projectId: true } },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const scoreMap = new Map<string, number[]>()
|
|
|
|
|
for (const ev of evaluations) {
|
|
|
|
|
if (ev.globalScore !== null) {
|
|
|
|
|
const pid = ev.assignment.projectId
|
|
|
|
|
if (!scoreMap.has(pid)) scoreMap.set(pid, [])
|
|
|
|
|
scoreMap.get(pid)!.push(ev.globalScore)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const [pid, scores] of scoreMap) {
|
|
|
|
|
projectScores[pid] = {
|
|
|
|
|
avg: scores.reduce((a, b) => a + b, 0) / scores.length,
|
|
|
|
|
count: scores.length,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Chair sees other votes
|
|
|
|
|
const isSolo = allJurors.length === 1
|
|
|
|
|
const isChair = juror.isChair || isSolo
|
|
|
|
|
let otherVotes: Array<{ userId: string; userName: string | null; projectId: string; justification: string | null }> = []
|
|
|
|
|
if (isChair && !isSolo) {
|
|
|
|
|
const votes = await ctx.prisma.awardVote.findMany({
|
|
|
|
|
where: { awardId: input.awardId, userId: { not: ctx.user.id } },
|
|
|
|
|
select: {
|
|
|
|
|
userId: true, projectId: true, justification: true,
|
|
|
|
|
user: { select: { name: true } },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
otherVotes = votes.map((v) => ({
|
|
|
|
|
userId: v.userId,
|
|
|
|
|
userName: v.user.name,
|
|
|
|
|
projectId: v.projectId,
|
|
|
|
|
justification: v.justification,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
award,
|
|
|
|
|
projects: eligibleProjects.map((e) => ({
|
|
|
|
|
...e.project,
|
|
|
|
|
evaluationScore: projectScores[e.project.id] ?? null,
|
|
|
|
|
})),
|
|
|
|
|
myVotes,
|
|
|
|
|
isChair,
|
|
|
|
|
otherVotes,
|
|
|
|
|
totalJurors: allJurors.length,
|
|
|
|
|
jurors: allJurors.map((j) => ({ userId: j.userId, name: j.user.name, isChair: j.isChair })),
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
// ─── 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 }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-04-06 16:42:21 -04:00
|
|
|
/**
|
|
|
|
|
* Submit award master vote with optional justification (PICK_WINNER only)
|
|
|
|
|
*/
|
|
|
|
|
submitAwardMasterVote: awardMasterProcedure
|
|
|
|
|
.input(z.object({
|
|
|
|
|
awardId: z.string(),
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
justification: z.string().max(2000).optional(),
|
|
|
|
|
}))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
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: 'Not assigned to this award' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 open' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.$transaction([
|
|
|
|
|
ctx.prisma.awardVote.deleteMany({
|
|
|
|
|
where: { awardId: input.awardId, userId: ctx.user.id },
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.awardVote.create({
|
|
|
|
|
data: {
|
|
|
|
|
awardId: input.awardId,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
justification: input.justification || null,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CREATE',
|
|
|
|
|
entityType: 'AwardVote',
|
|
|
|
|
entityId: input.awardId,
|
|
|
|
|
detailsJson: { awardId: input.awardId, projectId: input.projectId, mode: 'AWARD_MASTER_PICK' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { submitted: true }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
// ─── 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
|
|
|
|
2026-04-06 16:42:21 -04:00
|
|
|
/**
|
|
|
|
|
* Chair confirms the winner — resolves tiebreaks, sets winner, closes the award
|
|
|
|
|
*/
|
|
|
|
|
confirmWinner: awardMasterProcedure
|
|
|
|
|
.input(z.object({ awardId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const allJurors = await ctx.prisma.awardJuror.findMany({
|
|
|
|
|
where: { awardId: input.awardId },
|
|
|
|
|
select: { userId: true, isChair: true },
|
|
|
|
|
})
|
|
|
|
|
const myJuror = allJurors.find((j) => j.userId === ctx.user.id)
|
|
|
|
|
if (!myJuror) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Not assigned to this award' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isSolo = allJurors.length === 1
|
|
|
|
|
if (!myJuror.isChair && !isSolo) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the chair can confirm the winner' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.awardId },
|
|
|
|
|
})
|
|
|
|
|
if (award.status !== 'VOTING_OPEN') {
|
|
|
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Award must be in VOTING_OPEN status' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const chairVote = await ctx.prisma.awardVote.findFirst({
|
|
|
|
|
where: { awardId: input.awardId, userId: ctx.user.id },
|
|
|
|
|
})
|
|
|
|
|
if (!chairVote) {
|
|
|
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'You must vote before confirming' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const allVotes = await ctx.prisma.awardVote.findMany({
|
|
|
|
|
where: { awardId: input.awardId },
|
|
|
|
|
select: { projectId: true, userId: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const winnerId = resolveAwardWinner(allVotes, ctx.user.id)
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.specialAward.update({
|
|
|
|
|
where: { id: input.awardId },
|
|
|
|
|
data: {
|
|
|
|
|
winnerProjectId: winnerId,
|
|
|
|
|
status: 'CLOSED',
|
|
|
|
|
winnerOverridden: false,
|
|
|
|
|
winnerOverriddenBy: null,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'UPDATE',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: input.awardId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
action: 'CONFIRM_WINNER',
|
|
|
|
|
winnerId,
|
|
|
|
|
totalVotes: allVotes.length,
|
|
|
|
|
confirmedBy: ctx.user.id,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { winnerId, closed: true }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Admin: set/unset chair status for an award juror (only one chair per award)
|
|
|
|
|
*/
|
|
|
|
|
setChair: adminProcedure
|
|
|
|
|
.input(z.object({
|
|
|
|
|
awardId: z.string(),
|
|
|
|
|
userId: z.string(),
|
|
|
|
|
isChair: z.boolean(),
|
|
|
|
|
}))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
if (input.isChair) {
|
|
|
|
|
await ctx.prisma.awardJuror.updateMany({
|
|
|
|
|
where: { awardId: input.awardId, isChair: true },
|
|
|
|
|
data: { isChair: false },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.awardJuror.update({
|
|
|
|
|
where: { awardId_userId: { awardId: input.awardId, userId: input.userId } },
|
|
|
|
|
data: { isChair: input.isChair },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'UPDATE',
|
|
|
|
|
entityType: 'AwardJuror',
|
|
|
|
|
entityId: `${input.awardId}:${input.userId}`,
|
|
|
|
|
detailsJson: { action: 'SET_CHAIR', isChair: input.isChair },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true }
|
|
|
|
|
}),
|
|
|
|
|
|
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
|
2026-03-07 16:18:24 +01:00
|
|
|
processEligibilityJob(
|
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
|
|
|
input.awardId,
|
|
|
|
|
true, // include submitted
|
|
|
|
|
ctx.user.id,
|
|
|
|
|
input.roundId
|
2026-03-07 16:18:24 +01:00
|
|
|
).catch((err) => {
|
|
|
|
|
console.error('[SpecialAward] processEligibilityJob (round) failed:', err)
|
|
|
|
|
})
|
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
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-04 00:32:14 +01:00
|
|
|
// Get eligible projects that haven't been notified yet
|
2026-03-04 13:29:22 +01:00
|
|
|
// Exclude projects that have been rejected at any stage
|
2026-03-03 19:14:41 +01:00
|
|
|
const eligibilities = await ctx.prisma.awardEligibility.findMany({
|
2026-03-04 13:29:22 +01:00
|
|
|
where: {
|
|
|
|
|
awardId: input.awardId,
|
|
|
|
|
eligible: true,
|
|
|
|
|
notifiedAt: null,
|
|
|
|
|
project: {
|
|
|
|
|
projectRoundStates: {
|
|
|
|
|
none: { state: 'REJECTED' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-03-03 19:14:41 +01:00
|
|
|
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 },
|
2026-03-05 13:06:17 +01:00
|
|
|
data: { inviteToken: token, inviteTokenExpiresAt: expiresAt, status: 'INVITED' },
|
2026-03-03 19:14:41 +01:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:22 +01:00
|
|
|
// Build notification items — track which eligibility each email belongs to
|
|
|
|
|
const items: NotificationItem[] = []
|
|
|
|
|
const eligibilityEmailMap = new Map<string, Set<string>>() // eligibilityId → Set<email>
|
2026-03-03 19:14:41 +01:00
|
|
|
|
|
|
|
|
for (const e of eligibilities) {
|
2026-03-04 13:29:22 +01:00
|
|
|
const recipients: Array<{ id: string; email: string; name: string | null }> = []
|
2026-03-03 19:14:41 +01:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:22 +01:00
|
|
|
const emails = new Set<string>()
|
2026-03-03 19:14:41 +01:00
|
|
|
for (const recipient of recipients) {
|
|
|
|
|
const token = tokenMap.get(recipient.id)
|
|
|
|
|
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
2026-03-04 13:29:22 +01:00
|
|
|
emails.add(recipient.email)
|
|
|
|
|
|
|
|
|
|
items.push({
|
|
|
|
|
email: recipient.email,
|
|
|
|
|
name: recipient.name || '',
|
|
|
|
|
type: 'AWARD_SELECTION_NOTIFICATION',
|
|
|
|
|
context: {
|
|
|
|
|
title: `Under consideration for ${award.name}`,
|
|
|
|
|
message: input.customMessage || '',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectName: e.project.title,
|
|
|
|
|
awardName: award.name,
|
|
|
|
|
customMessage: input.customMessage,
|
|
|
|
|
accountUrl,
|
2026-03-03 19:14:41 +01:00
|
|
|
},
|
2026-03-04 13:29:22 +01:00
|
|
|
},
|
|
|
|
|
projectId: e.projectId,
|
|
|
|
|
userId: recipient.id,
|
|
|
|
|
})
|
2026-03-03 19:14:41 +01:00
|
|
|
}
|
2026-03-04 13:29:22 +01:00
|
|
|
eligibilityEmailMap.set(e.id, emails)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await sendBatchNotifications(items)
|
|
|
|
|
|
|
|
|
|
// Determine which eligibilities had zero failures
|
|
|
|
|
const failedEmails = new Set(result.errors.map((e) => e.email))
|
|
|
|
|
const successfulEligibilityIds: string[] = []
|
|
|
|
|
for (const [eligId, emails] of eligibilityEmailMap) {
|
|
|
|
|
const hasFailure = [...emails].some((email) => failedEmails.has(email))
|
|
|
|
|
if (!hasFailure) successfulEligibilityIds.push(eligId)
|
2026-03-03 19:14:41 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:22 +01:00
|
|
|
if (successfulEligibilityIds.length > 0) {
|
2026-03-04 00:32:14 +01:00
|
|
|
await ctx.prisma.awardEligibility.updateMany({
|
2026-03-04 13:29:22 +01:00
|
|
|
where: { id: { in: successfulEligibilityIds } },
|
2026-03-04 00:32:14 +01:00
|
|
|
data: { notifiedAt: new Date() },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'UPDATE',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: input.awardId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
action: 'NOTIFY_ELIGIBLE_PROJECTS',
|
|
|
|
|
eligibleCount: eligibilities.length,
|
2026-03-04 13:29:22 +01:00
|
|
|
emailsSent: result.sent,
|
|
|
|
|
emailsFailed: result.failed,
|
|
|
|
|
failedRecipients: result.errors.length > 0 ? result.errors.map((e) => e.email) : undefined,
|
2026-03-03 19:14:41 +01:00
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-04 13:29:22 +01:00
|
|
|
return { notified: successfulEligibilityIds.length, emailsSent: result.sent, emailsFailed: result.failed }
|
2026-03-03 19:14:41 +01:00
|
|
|
}),
|
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
|
|
|
})
|