Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +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 {
|
|
|
|
|
applyAutoTagRules,
|
|
|
|
|
aiInterpretCriteria,
|
|
|
|
|
type AutoTagRule,
|
|
|
|
|
} from '../services/ai-award-eligibility'
|
|
|
|
|
|
|
|
|
|
export const specialAwardRouter = router({
|
|
|
|
|
// ─── Admin Queries ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List awards for a program
|
|
|
|
|
*/
|
|
|
|
|
list: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
programId: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
return ctx.prisma.specialAward.findMany({
|
|
|
|
|
where: input.programId ? { programId: input.programId } : {},
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
include: {
|
|
|
|
|
_count: {
|
|
|
|
|
select: {
|
|
|
|
|
eligibilities: true,
|
|
|
|
|
jurors: true,
|
|
|
|
|
votes: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
winnerProject: {
|
|
|
|
|
select: { id: true, title: true, teamName: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get award detail with stats
|
|
|
|
|
*/
|
|
|
|
|
get: protectedProcedure
|
|
|
|
|
.input(z.object({ id: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.id },
|
|
|
|
|
include: {
|
|
|
|
|
_count: {
|
|
|
|
|
select: {
|
|
|
|
|
eligibilities: true,
|
|
|
|
|
jurors: true,
|
|
|
|
|
votes: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
winnerProject: {
|
|
|
|
|
select: { id: true, title: true, teamName: true },
|
|
|
|
|
},
|
|
|
|
|
program: {
|
2026-02-02 20:02:58 +01:00
|
|
|
select: { id: true, name: true, year: true },
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Count eligible projects
|
|
|
|
|
const eligibleCount = await ctx.prisma.awardEligibility.count({
|
|
|
|
|
where: { awardId: input.id, eligible: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { ...award, eligibleCount }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
// ─── 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(),
|
2026-02-02 20:02:58 +01:00
|
|
|
useAiEligibility: z.boolean().optional(),
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
|
|
|
|
|
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
|
|
|
|
autoTagRulesJson: z.record(z.unknown()).optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const maxOrder = await ctx.prisma.specialAward.aggregate({
|
|
|
|
|
where: { programId: input.programId },
|
|
|
|
|
_max: { sortOrder: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const award = await ctx.prisma.specialAward.create({
|
|
|
|
|
data: {
|
|
|
|
|
programId: input.programId,
|
|
|
|
|
name: input.name,
|
|
|
|
|
description: input.description,
|
|
|
|
|
criteriaText: input.criteriaText,
|
2026-02-02 20:02:58 +01:00
|
|
|
useAiEligibility: input.useAiEligibility ?? true,
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
scoringMode: input.scoringMode,
|
|
|
|
|
maxRankedPicks: input.maxRankedPicks,
|
|
|
|
|
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
|
|
|
|
|
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CREATE',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: award.id,
|
|
|
|
|
detailsJson: { name: input.name, scoringMode: input.scoringMode },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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(),
|
2026-02-02 20:02:58 +01:00
|
|
|
useAiEligibility: z.boolean().optional(),
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(),
|
|
|
|
|
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
|
|
|
|
autoTagRulesJson: z.record(z.unknown()).optional(),
|
|
|
|
|
votingStartAt: z.date().optional(),
|
|
|
|
|
votingEndAt: z.date().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const { id, autoTagRulesJson, ...rest } = input
|
|
|
|
|
const award = await ctx.prisma.specialAward.update({
|
|
|
|
|
where: { id },
|
|
|
|
|
data: {
|
|
|
|
|
...rest,
|
|
|
|
|
...(autoTagRulesJson !== undefined && { autoTagRulesJson: autoTagRulesJson as Prisma.InputJsonValue }),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 }) => {
|
|
|
|
|
await ctx.prisma.specialAward.delete({ where: { id: input.id } })
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'DELETE',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: input.id,
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 },
|
2026-02-05 16:29:36 +01:00
|
|
|
select: { status: true, votingStartAt: true, votingEndAt: true },
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
})
|
|
|
|
|
|
2026-02-05 16:29:36 +01:00
|
|
|
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 now
|
|
|
|
|
if (!current.votingStartAt || current.votingStartAt > now) {
|
|
|
|
|
updateData.votingStartAt = now
|
|
|
|
|
votingStartAtUpdated = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
const award = await ctx.prisma.specialAward.update({
|
|
|
|
|
where: { id: input.id },
|
2026-02-05 16:29:36 +01:00
|
|
|
data: updateData,
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'UPDATE_STATUS',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: input.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
previousStatus: current.status,
|
|
|
|
|
newStatus: input.status,
|
2026-02-05 16:29:36 +01:00
|
|
|
...(votingStartAtUpdated && {
|
|
|
|
|
votingStartAtUpdated: true,
|
|
|
|
|
previousVotingStartAt: current.votingStartAt,
|
|
|
|
|
newVotingStartAt: now,
|
|
|
|
|
}),
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return award
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
// ─── Eligibility ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run auto-tag + AI eligibility
|
|
|
|
|
*/
|
|
|
|
|
runEligibility: adminProcedure
|
2026-02-02 20:02:58 +01:00
|
|
|
.input(z.object({
|
|
|
|
|
awardId: z.string(),
|
|
|
|
|
includeSubmitted: z.boolean().optional(),
|
|
|
|
|
}))
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.awardId },
|
|
|
|
|
include: { program: true },
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-02 20:02:58 +01:00
|
|
|
// Get projects in the program's rounds
|
|
|
|
|
const statusFilter = input.includeSubmitted
|
|
|
|
|
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
|
|
|
|
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
2026-02-04 14:15:06 +01:00
|
|
|
const projects = await ctx.prisma.project.findMany({
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
where: {
|
|
|
|
|
round: { programId: award.programId },
|
2026-02-02 20:02:58 +01:00
|
|
|
status: { in: [...statusFilter] },
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
},
|
2026-02-04 14:15:06 +01:00
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
description: true,
|
|
|
|
|
competitionCategory: true,
|
|
|
|
|
country: true,
|
|
|
|
|
geographicZone: true,
|
|
|
|
|
tags: true,
|
|
|
|
|
oceanIssue: true,
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (projects.length === 0) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'No eligible projects found',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Phase 1: Auto-tag rules (deterministic)
|
|
|
|
|
const autoTagRules = award.autoTagRulesJson as unknown as AutoTagRule[] | null
|
|
|
|
|
let autoResults: Map<string, boolean> | undefined
|
|
|
|
|
if (autoTagRules && Array.isArray(autoTagRules) && autoTagRules.length > 0) {
|
|
|
|
|
autoResults = applyAutoTagRules(autoTagRules, projects)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 20:02:58 +01:00
|
|
|
// Phase 2: AI interpretation (if criteria text exists AND AI eligibility is enabled)
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
let aiResults: Map<string, { eligible: boolean; confidence: number; reasoning: string }> | undefined
|
2026-02-02 20:02:58 +01:00
|
|
|
if (award.criteriaText && award.useAiEligibility) {
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
const aiEvals = await aiInterpretCriteria(award.criteriaText, projects)
|
|
|
|
|
aiResults = new Map(
|
|
|
|
|
aiEvals.map((e) => [
|
|
|
|
|
e.projectId,
|
|
|
|
|
{ eligible: e.eligible, confidence: e.confidence, reasoning: e.reasoning },
|
|
|
|
|
])
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Combine results: auto-tag AND AI must agree (or just one if only one configured)
|
|
|
|
|
const eligibilities = projects.map((project) => {
|
|
|
|
|
const autoEligible = autoResults?.get(project.id) ?? true
|
|
|
|
|
const aiEval = aiResults?.get(project.id)
|
|
|
|
|
const aiEligible = aiEval?.eligible ?? true
|
|
|
|
|
|
|
|
|
|
const eligible = autoEligible && aiEligible
|
|
|
|
|
const method = autoResults && aiResults ? 'AUTO' : autoResults ? 'AUTO' : 'MANUAL'
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
eligible,
|
|
|
|
|
method,
|
|
|
|
|
aiReasoningJson: aiEval
|
|
|
|
|
? { confidence: aiEval.confidence, reasoning: aiEval.reasoning }
|
|
|
|
|
: null,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Upsert eligibilities
|
|
|
|
|
await ctx.prisma.$transaction(
|
|
|
|
|
eligibilities.map((e) =>
|
|
|
|
|
ctx.prisma.awardEligibility.upsert({
|
|
|
|
|
where: {
|
|
|
|
|
awardId_projectId: {
|
|
|
|
|
awardId: input.awardId,
|
|
|
|
|
projectId: e.projectId,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
create: {
|
|
|
|
|
awardId: input.awardId,
|
|
|
|
|
projectId: e.projectId,
|
|
|
|
|
eligible: e.eligible,
|
|
|
|
|
method: e.method as 'AUTO' | 'MANUAL',
|
|
|
|
|
aiReasoningJson: e.aiReasoningJson ?? undefined,
|
|
|
|
|
},
|
|
|
|
|
update: {
|
|
|
|
|
eligible: e.eligible,
|
|
|
|
|
method: e.method as 'AUTO' | 'MANUAL',
|
|
|
|
|
aiReasoningJson: e.aiReasoningJson ?? undefined,
|
|
|
|
|
// Clear overrides
|
|
|
|
|
overriddenBy: null,
|
|
|
|
|
overriddenAt: null,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const eligibleCount = eligibilities.filter((e) => e.eligible).length
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'UPDATE',
|
|
|
|
|
entityType: 'SpecialAward',
|
|
|
|
|
entityId: input.awardId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
action: 'RUN_ELIGIBILITY',
|
|
|
|
|
totalProjects: projects.length,
|
|
|
|
|
eligible: eligibleCount,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
total: projects.length,
|
|
|
|
|
eligible: eligibleCount,
|
|
|
|
|
ineligible: projects.length - eligibleCount,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List eligible projects
|
|
|
|
|
*/
|
|
|
|
|
listEligible: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
awardId: z.string(),
|
|
|
|
|
eligibleOnly: z.boolean().default(false),
|
|
|
|
|
page: z.number().int().min(1).default(1),
|
|
|
|
|
perPage: z.number().int().min(1).max(100).default(50),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const { awardId, eligibleOnly, page, perPage } = input
|
|
|
|
|
const skip = (page - 1) * perPage
|
|
|
|
|
|
|
|
|
|
const where: Record<string, unknown> = { awardId }
|
|
|
|
|
if (eligibleOnly) where.eligible = true
|
|
|
|
|
|
|
|
|
|
const [eligibilities, total] = await Promise.all([
|
|
|
|
|
ctx.prisma.awardEligibility.findMany({
|
|
|
|
|
where,
|
|
|
|
|
skip,
|
|
|
|
|
take: perPage,
|
|
|
|
|
include: {
|
|
|
|
|
project: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
teamName: true,
|
|
|
|
|
competitionCategory: true,
|
|
|
|
|
country: true,
|
|
|
|
|
tags: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { project: { title: 'asc' } },
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.awardEligibility.count({ where }),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
return { eligibilities, total, page, perPage, totalPages: Math.ceil(total / perPage) }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Manual eligibility override
|
|
|
|
|
*/
|
|
|
|
|
setEligibility: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
awardId: z.string(),
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
eligible: z.boolean(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
await ctx.prisma.awardEligibility.upsert({
|
|
|
|
|
where: {
|
|
|
|
|
awardId_projectId: {
|
|
|
|
|
awardId: input.awardId,
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
create: {
|
|
|
|
|
awardId: input.awardId,
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
eligible: input.eligible,
|
|
|
|
|
method: 'MANUAL',
|
|
|
|
|
overriddenBy: ctx.user.id,
|
|
|
|
|
overriddenAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
update: {
|
|
|
|
|
eligible: input.eligible,
|
|
|
|
|
overriddenBy: ctx.user.id,
|
|
|
|
|
overriddenAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
// ─── Jurors ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List jurors for an award
|
|
|
|
|
*/
|
|
|
|
|
listJurors: protectedProcedure
|
|
|
|
|
.input(z.object({ awardId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
return ctx.prisma.awardJuror.findMany({
|
|
|
|
|
where: { awardId: input.awardId },
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
role: true,
|
|
|
|
|
profileImageKey: true,
|
|
|
|
|
profileImageProvider: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Add juror
|
|
|
|
|
*/
|
|
|
|
|
addJuror: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
awardId: z.string(),
|
|
|
|
|
userId: z.string(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
return ctx.prisma.awardJuror.create({
|
|
|
|
|
data: {
|
|
|
|
|
awardId: input.awardId,
|
|
|
|
|
userId: input.userId,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove juror
|
|
|
|
|
*/
|
|
|
|
|
removeJuror: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
awardId: z.string(),
|
|
|
|
|
userId: z.string(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
await ctx.prisma.awardJuror.delete({
|
|
|
|
|
where: {
|
|
|
|
|
awardId_userId: {
|
|
|
|
|
awardId: input.awardId,
|
|
|
|
|
userId: input.userId,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Bulk add jurors
|
|
|
|
|
*/
|
|
|
|
|
bulkAddJurors: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
awardId: z.string(),
|
|
|
|
|
userIds: z.array(z.string()),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const data = input.userIds.map((userId) => ({
|
|
|
|
|
awardId: input.awardId,
|
|
|
|
|
userId,
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.awardJuror.createMany({
|
|
|
|
|
data,
|
|
|
|
|
skipDuplicates: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { added: input.userIds.length }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
// ─── Jury Queries ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get awards where current user is a juror
|
|
|
|
|
*/
|
|
|
|
|
getMyAwards: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
const jurorships = await ctx.prisma.awardJuror.findMany({
|
|
|
|
|
where: { userId: ctx.user.id },
|
|
|
|
|
include: {
|
|
|
|
|
award: {
|
|
|
|
|
include: {
|
|
|
|
|
_count: {
|
|
|
|
|
select: { eligibilities: { where: { eligible: true } } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return jurorships.map((j) => j.award)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get award detail for voting (jury view)
|
|
|
|
|
*/
|
|
|
|
|
getMyAwardDetail: protectedProcedure
|
|
|
|
|
.input(z.object({ awardId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
// Verify user is a juror
|
|
|
|
|
const juror = await ctx.prisma.awardJuror.findUnique({
|
|
|
|
|
where: {
|
|
|
|
|
awardId_userId: {
|
|
|
|
|
awardId: input.awardId,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!juror) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'You are not a juror for this award',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.awardId },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get eligible projects
|
|
|
|
|
const eligibleProjects = await 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,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get user's existing votes
|
|
|
|
|
const myVotes = await 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 = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.awardId },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const votes = await 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 },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const jurorCount = await 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 },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return award
|
|
|
|
|
}),
|
|
|
|
|
})
|