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>
This commit is contained in:
2026-02-02 16:58:29 +01:00
parent 8fda8deded
commit 90e3adfab2
44 changed files with 7268 additions and 2154 deletions

View File

@@ -27,6 +27,8 @@ import { logoRouter } from './logo'
// Applicant system routers
import { applicationRouter } from './application'
import { mentorRouter } from './mentor'
import { filteringRouter } from './filtering'
import { specialAwardRouter } from './specialAward'
/**
* Root tRPC router that combines all domain routers
@@ -60,6 +62,8 @@ export const appRouter = router({
// Applicant system routers
application: applicationRouter,
mentor: mentorRouter,
filtering: filteringRouter,
specialAward: specialAwardRouter,
})
export type AppRouter = typeof appRouter

View File

@@ -25,7 +25,7 @@ export const auditRouter = router({
const where: Record<string, unknown> = {}
if (userId) where.userId = userId
if (action) where.action = { contains: action, mode: 'insensitive' }
if (action) where.action = action
if (entityType) where.entityType = entityType
if (entityId) where.entityId = entityId
if (startDate || endDate) {

View File

@@ -216,9 +216,15 @@ export const evaluationRouter = router({
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'SUBMIT_EVALUATION',
action: 'EVALUATION_SUBMITTED',
entityType: 'Evaluation',
entityId: id,
detailsJson: {
projectId: evaluation.assignment.projectId,
roundId: evaluation.assignment.roundId,
globalScore: data.globalScore,
binaryDecision: data.binaryDecision,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},

View File

@@ -53,6 +53,19 @@ export const fileRouter = router({
}
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min
// Log file access
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'FILE_DOWNLOADED',
entityType: 'ProjectFile',
detailsJson: { bucket: input.bucket, objectKey: input.objectKey },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
}).catch(() => {})
return { url }
}),

View File

@@ -0,0 +1,522 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import { executeFilteringRules } from '../services/ai-filtering'
import { logAudit } from '../utils/audit'
export const filteringRouter = router({
/**
* Get filtering rules for a round
*/
getRules: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.filteringRule.findMany({
where: { roundId: input.roundId },
orderBy: { priority: 'asc' },
})
}),
/**
* Create a filtering rule
*/
createRule: adminProcedure
.input(
z.object({
roundId: z.string(),
name: z.string().min(1),
ruleType: z.enum(['FIELD_BASED', 'DOCUMENT_CHECK', 'AI_SCREENING']),
configJson: z.record(z.unknown()),
priority: z.number().int().default(0),
})
)
.mutation(async ({ ctx, input }) => {
const rule = await ctx.prisma.filteringRule.create({
data: {
roundId: input.roundId,
name: input.name,
ruleType: input.ruleType,
configJson: input.configJson as Prisma.InputJsonValue,
priority: input.priority,
},
})
await logAudit({
userId: ctx.user.id,
action: 'CREATE',
entityType: 'FilteringRule',
entityId: rule.id,
detailsJson: { roundId: input.roundId, name: input.name, ruleType: input.ruleType },
})
return rule
}),
/**
* Update a filtering rule
*/
updateRule: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).optional(),
configJson: z.record(z.unknown()).optional(),
priority: z.number().int().optional(),
isActive: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, configJson, ...rest } = input
const rule = await ctx.prisma.filteringRule.update({
where: { id },
data: {
...rest,
...(configJson !== undefined && { configJson: configJson as Prisma.InputJsonValue }),
},
})
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'FilteringRule',
entityId: id,
})
return rule
}),
/**
* Delete a filtering rule
*/
deleteRule: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.filteringRule.delete({ where: { id: input.id } })
await logAudit({
userId: ctx.user.id,
action: 'DELETE',
entityType: 'FilteringRule',
entityId: input.id,
})
}),
/**
* Reorder rules (batch update priorities)
*/
reorderRules: adminProcedure
.input(
z.object({
rules: z.array(
z.object({
id: z.string(),
priority: z.number().int(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.rules.map((r) =>
ctx.prisma.filteringRule.update({
where: { id: r.id },
data: { priority: r.priority },
})
)
)
}),
/**
* Execute all filtering rules against projects in a round
*/
executeRules: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Get rules
const rules = await ctx.prisma.filteringRule.findMany({
where: { roundId: input.roundId },
orderBy: { priority: 'asc' },
})
if (rules.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No filtering rules configured for this round',
})
}
// Get projects in this round
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
include: {
files: {
select: { id: true, fileName: true, fileType: true },
},
},
})
if (projects.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No projects found in this round',
})
}
// Execute rules
const results = await executeFilteringRules(rules, projects)
// Upsert results
await ctx.prisma.$transaction(
results.map((r) =>
ctx.prisma.filteringResult.upsert({
where: {
roundId_projectId: {
roundId: input.roundId,
projectId: r.projectId,
},
},
create: {
roundId: input.roundId,
projectId: r.projectId,
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
},
update: {
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
// Clear any previous override
overriddenBy: null,
overriddenAt: null,
overrideReason: null,
finalOutcome: null,
},
})
)
)
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
action: 'EXECUTE_FILTERING',
projectCount: projects.length,
passed: results.filter((r) => r.outcome === 'PASSED').length,
filteredOut: results.filter((r) => r.outcome === 'FILTERED_OUT').length,
flagged: results.filter((r) => r.outcome === 'FLAGGED').length,
},
})
return {
total: results.length,
passed: results.filter((r) => r.outcome === 'PASSED').length,
filteredOut: results.filter((r) => r.outcome === 'FILTERED_OUT').length,
flagged: results.filter((r) => r.outcome === 'FLAGGED').length,
}
}),
/**
* Get filtering results for a round (paginated)
*/
getResults: protectedProcedure
.input(
z.object({
roundId: z.string(),
outcome: z.enum(['PASSED', 'FILTERED_OUT', 'FLAGGED']).optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const { roundId, outcome, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = { roundId }
if (outcome) where.outcome = outcome
const [results, total] = await Promise.all([
ctx.prisma.filteringResult.findMany({
where,
skip,
take: perPage,
orderBy: { createdAt: 'desc' },
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
status: true,
competitionCategory: true,
country: true,
},
},
overriddenByUser: {
select: { id: true, name: true, email: true },
},
},
}),
ctx.prisma.filteringResult.count({ where }),
])
return {
results,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
}
}),
/**
* Get aggregate stats for filtering results
*/
getResultStats: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const [passed, filteredOut, flagged, overridden] = await Promise.all([
ctx.prisma.filteringResult.count({
where: { roundId: input.roundId, outcome: 'PASSED' },
}),
ctx.prisma.filteringResult.count({
where: { roundId: input.roundId, outcome: 'FILTERED_OUT' },
}),
ctx.prisma.filteringResult.count({
where: { roundId: input.roundId, outcome: 'FLAGGED' },
}),
ctx.prisma.filteringResult.count({
where: { roundId: input.roundId, overriddenBy: { not: null } },
}),
])
return { passed, filteredOut, flagged, overridden, total: passed + filteredOut + flagged }
}),
/**
* Override a single filtering result
*/
overrideResult: adminProcedure
.input(
z.object({
id: z.string(),
finalOutcome: z.enum(['PASSED', 'FILTERED_OUT', 'FLAGGED']),
reason: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
const result = await ctx.prisma.filteringResult.update({
where: { id: input.id },
data: {
finalOutcome: input.finalOutcome,
overriddenBy: ctx.user.id,
overriddenAt: new Date(),
overrideReason: input.reason,
},
})
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'FilteringResult',
entityId: input.id,
detailsJson: {
action: 'OVERRIDE',
originalOutcome: result.outcome,
finalOutcome: input.finalOutcome,
reason: input.reason,
},
})
return result
}),
/**
* Bulk override multiple results
*/
bulkOverride: adminProcedure
.input(
z.object({
ids: z.array(z.string()),
finalOutcome: z.enum(['PASSED', 'FILTERED_OUT', 'FLAGGED']),
reason: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.filteringResult.updateMany({
where: { id: { in: input.ids } },
data: {
finalOutcome: input.finalOutcome,
overriddenBy: ctx.user.id,
overriddenAt: new Date(),
overrideReason: input.reason,
},
})
await logAudit({
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'FilteringResult',
detailsJson: {
action: 'BULK_OVERRIDE',
count: input.ids.length,
finalOutcome: input.finalOutcome,
},
})
return { updated: input.ids.length }
}),
/**
* Finalize filtering results — apply outcomes to project statuses
* PASSED → keep in pool, FILTERED_OUT → set aside (NOT deleted)
*/
finalizeResults: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const results = await ctx.prisma.filteringResult.findMany({
where: { roundId: input.roundId },
})
// Use finalOutcome if overridden, otherwise use outcome
const filteredOutIds = results
.filter((r) => (r.finalOutcome || r.outcome) === 'FILTERED_OUT')
.map((r) => r.projectId)
const passedIds = results
.filter((r) => (r.finalOutcome || r.outcome) === 'PASSED')
.map((r) => r.projectId)
// Update project statuses
await ctx.prisma.$transaction([
// Filtered out projects get REJECTED status (data preserved)
...(filteredOutIds.length > 0
? [
ctx.prisma.project.updateMany({
where: { id: { in: filteredOutIds } },
data: { status: 'REJECTED' },
}),
]
: []),
// Passed projects get ELIGIBLE status
...(passedIds.length > 0
? [
ctx.prisma.project.updateMany({
where: { id: { in: passedIds } },
data: { status: 'ELIGIBLE' },
}),
]
: []),
])
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
action: 'FINALIZE_FILTERING',
passed: passedIds.length,
filteredOut: filteredOutIds.length,
},
})
return { passed: passedIds.length, filteredOut: filteredOutIds.length }
}),
/**
* Reinstate a filtered-out project back into the active pool
*/
reinstateProject: adminProcedure
.input(
z.object({
roundId: z.string(),
projectId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Update filtering result
await ctx.prisma.filteringResult.update({
where: {
roundId_projectId: {
roundId: input.roundId,
projectId: input.projectId,
},
},
data: {
finalOutcome: 'PASSED',
overriddenBy: ctx.user.id,
overriddenAt: new Date(),
overrideReason: 'Reinstated by admin',
},
})
// Restore project status
await ctx.prisma.project.update({
where: { id: input.projectId },
data: { status: 'ELIGIBLE' },
})
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'FilteringResult',
detailsJson: {
action: 'REINSTATE',
roundId: input.roundId,
projectId: input.projectId,
},
})
}),
/**
* Bulk reinstate filtered-out projects
*/
bulkReinstate: adminProcedure
.input(
z.object({
roundId: z.string(),
projectIds: z.array(z.string()),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction([
...input.projectIds.map((projectId) =>
ctx.prisma.filteringResult.update({
where: {
roundId_projectId: {
roundId: input.roundId,
projectId,
},
},
data: {
finalOutcome: 'PASSED',
overriddenBy: ctx.user.id,
overriddenAt: new Date(),
overrideReason: 'Bulk reinstated by admin',
},
})
),
ctx.prisma.project.updateMany({
where: { id: { in: input.projectIds } },
data: { status: 'ELIGIBLE' },
}),
])
await logAudit({
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'FilteringResult',
detailsJson: {
action: 'BULK_REINSTATE',
roundId: input.roundId,
count: input.projectIds.length,
},
})
return { reinstated: input.projectIds.length }
}),
})

View File

@@ -12,7 +12,7 @@ export const projectRouter = router({
list: protectedProcedure
.input(
z.object({
roundId: z.string(),
roundId: z.string().optional(),
status: z
.enum([
'SUBMITTED',
@@ -23,23 +23,63 @@ export const projectRouter = router({
'REJECTED',
])
.optional(),
statuses: z.array(
z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
])
).optional(),
search: z.string().optional(),
tags: z.array(z.string()).optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
oceanIssue: z.enum([
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
'OCEAN_ACIDIFICATION', 'OTHER',
]).optional(),
country: z.string().optional(),
wantsMentorship: z.boolean().optional(),
hasFiles: z.boolean().optional(),
hasAssignments: z.boolean().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const { roundId, status, search, tags, page, perPage } = input
const {
roundId, status, statuses, search, tags,
competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments,
page, perPage,
} = input
const skip = (page - 1) * perPage
// Build where clause
const where: Record<string, unknown> = { roundId }
const where: Record<string, unknown> = {}
if (status) where.status = status
if (roundId) where.roundId = roundId
if (statuses && statuses.length > 0) {
where.status = { in: statuses }
} else if (status) {
where.status = status
}
if (tags && tags.length > 0) {
where.tags = { hasSome: tags }
}
if (competitionCategory) where.competitionCategory = competitionCategory
if (oceanIssue) where.oceanIssue = oceanIssue
if (country) where.country = country
if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship
if (hasFiles === true) where.files = { some: {} }
if (hasFiles === false) where.files = { none: {} }
if (hasAssignments === true) where.assignments = { some: {} }
if (hasAssignments === false) where.assignments = { none: {} }
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
@@ -50,7 +90,9 @@ export const projectRouter = router({
// Jury members can only see assigned projects
if (ctx.user.role === 'JURY_MEMBER') {
// If hasAssignments filter is already set, combine with jury filter
where.assignments = {
...((where.assignments as Record<string, unknown>) || {}),
some: { userId: ctx.user.id },
}
}
@@ -63,6 +105,9 @@ export const projectRouter = router({
orderBy: { createdAt: 'desc' },
include: {
files: true,
round: {
select: { id: true, name: true, program: { select: { name: true } } },
},
_count: { select: { assignments: true } },
},
}),
@@ -78,6 +123,48 @@ export const projectRouter = router({
}
}),
/**
* Get filter options for the project list (distinct values)
*/
getFilterOptions: protectedProcedure
.query(async ({ ctx }) => {
const [rounds, countries, categories, issues] = await Promise.all([
ctx.prisma.round.findMany({
select: { id: true, name: true, program: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
}),
ctx.prisma.project.findMany({
where: { country: { not: null } },
select: { country: true },
distinct: ['country'],
orderBy: { country: 'asc' },
}),
ctx.prisma.project.groupBy({
by: ['competitionCategory'],
where: { competitionCategory: { not: null } },
_count: true,
}),
ctx.prisma.project.groupBy({
by: ['oceanIssue'],
where: { oceanIssue: { not: null } },
_count: true,
}),
])
return {
rounds,
countries: countries.map((c) => c.country).filter(Boolean) as string[],
categories: categories.map((c) => ({
value: c.competitionCategory!,
count: c._count,
})),
issues: issues.map((i) => ({
value: i.oceanIssue!,
count: i._count,
})),
}
}),
/**
* Get a single project with details
*/

View File

@@ -165,19 +165,33 @@ export const roundRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Get previous status for audit
const previousRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.id },
select: { status: true },
})
const round = await ctx.prisma.round.update({
where: { id: input.id },
data: { status: input.status },
})
// Map status to specific action name
const statusActionMap: Record<string, string> = {
ACTIVE: 'ROUND_ACTIVATED',
CLOSED: 'ROUND_CLOSED',
ARCHIVED: 'ROUND_ARCHIVED',
}
const action = statusActionMap[input.status] || 'UPDATE_STATUS'
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE_STATUS',
action,
entityType: 'Round',
entityId: input.id,
detailsJson: { status: input.status },
detailsJson: { status: input.status, previousStatus: previousRound.status },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},

View File

@@ -0,0 +1,775 @@
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: {
select: { id: true, name: true },
},
},
})
// 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(),
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,
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(),
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 },
select: { status: true },
})
const award = await ctx.prisma.specialAward.update({
where: { id: input.id },
data: { status: input.status },
})
await logAudit({
userId: ctx.user.id,
action: 'UPDATE_STATUS',
entityType: 'SpecialAward',
entityId: input.id,
detailsJson: {
previousStatus: current.status,
newStatus: input.status,
},
})
return award
}),
// ─── Eligibility ────────────────────────────────────────────────────────
/**
* Run auto-tag + AI eligibility
*/
runEligibility: adminProcedure
.input(z.object({ awardId: z.string() }))
.mutation(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
include: { program: true },
})
// Get all projects in the program's rounds
const projects = await ctx.prisma.project.findMany({
where: {
round: { programId: award.programId },
status: { in: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] },
},
select: {
id: true,
title: true,
description: true,
competitionCategory: true,
country: true,
geographicZone: true,
tags: true,
oceanIssue: true,
},
})
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)
}
// Phase 2: AI interpretation (if criteria text exists)
let aiResults: Map<string, { eligible: boolean; confidence: number; reasoning: string }> | undefined
if (award.criteriaText) {
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
}),
})

View File

@@ -170,6 +170,7 @@ export const userRouter = router({
.input(
z.object({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
search: z.string().optional(),
page: z.number().int().min(1).default(1),
@@ -177,12 +178,16 @@ export const userRouter = router({
})
)
.query(async ({ ctx, input }) => {
const { role, status, search, page, perPage } = input
const { role, roles, status, search, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = {}
if (role) where.role = role
if (roles && roles.length > 0) {
where.role = { in: roles }
} else if (role) {
where.role = role
}
if (status) where.status = status
if (search) {
where.OR = [
@@ -210,7 +215,7 @@ export const userRouter = router({
createdAt: true,
lastLoginAt: true,
_count: {
select: { assignments: true },
select: { assignments: true, mentorAssignments: true },
},
},
}),
@@ -238,7 +243,7 @@ export const userRouter = router({
where: { id: input.id },
include: {
_count: {
select: { assignments: true },
select: { assignments: true, mentorAssignments: true },
},
},
})
@@ -356,6 +361,21 @@ export const userRouter = router({
},
})
// Track role change specifically
if (data.role && data.role !== targetUser.role) {
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'ROLE_CHANGED',
entityType: 'User',
entityId: id,
detailsJson: { previousRole: targetUser.role, newRole: data.role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
}).catch(() => {})
}
return user
}),
@@ -816,7 +836,7 @@ export const userRouter = router({
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'SET_PASSWORD',
action: 'PASSWORD_SET',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { timestamp: new Date().toISOString() },
@@ -896,7 +916,7 @@ export const userRouter = router({
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CHANGE_PASSWORD',
action: 'PASSWORD_CHANGED',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { timestamp: new Date().toISOString() },