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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
|
||||
|
||||
522
src/server/routers/filtering.ts
Normal file
522
src/server/routers/filtering.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
775
src/server/routers/specialAward.ts
Normal file
775
src/server/routers/specialAward.ts
Normal 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
|
||||
}),
|
||||
})
|
||||
@@ -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() },
|
||||
|
||||
226
src/server/services/ai-award-eligibility.ts
Normal file
226
src/server/services/ai-award-eligibility.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* AI-Powered Award Eligibility Service
|
||||
*
|
||||
* Determines project eligibility for special awards using:
|
||||
* - Deterministic field matching (tags, country, category)
|
||||
* - AI interpretation of plain-language criteria
|
||||
*/
|
||||
|
||||
import { getOpenAI, getConfiguredModel } from '@/lib/openai'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AutoTagRule = {
|
||||
field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue'
|
||||
operator: 'equals' | 'contains' | 'in'
|
||||
value: string | string[]
|
||||
}
|
||||
|
||||
export interface EligibilityResult {
|
||||
projectId: string
|
||||
eligible: boolean
|
||||
confidence: number
|
||||
reasoning: string
|
||||
method: 'AUTO' | 'AI'
|
||||
}
|
||||
|
||||
interface ProjectForEligibility {
|
||||
id: string
|
||||
title: string
|
||||
description?: string | null
|
||||
competitionCategory?: string | null
|
||||
country?: string | null
|
||||
geographicZone?: string | null
|
||||
tags: string[]
|
||||
oceanIssue?: string | null
|
||||
}
|
||||
|
||||
// ─── Auto Tag Rules ─────────────────────────────────────────────────────────
|
||||
|
||||
export function applyAutoTagRules(
|
||||
rules: AutoTagRule[],
|
||||
projects: ProjectForEligibility[]
|
||||
): Map<string, boolean> {
|
||||
const results = new Map<string, boolean>()
|
||||
|
||||
for (const project of projects) {
|
||||
const matches = rules.every((rule) => {
|
||||
const fieldValue = getFieldValue(project, rule.field)
|
||||
|
||||
switch (rule.operator) {
|
||||
case 'equals':
|
||||
return String(fieldValue).toLowerCase() === String(rule.value).toLowerCase()
|
||||
case 'contains':
|
||||
if (Array.isArray(fieldValue)) {
|
||||
return fieldValue.some((v) =>
|
||||
String(v).toLowerCase().includes(String(rule.value).toLowerCase())
|
||||
)
|
||||
}
|
||||
return String(fieldValue || '').toLowerCase().includes(String(rule.value).toLowerCase())
|
||||
case 'in':
|
||||
if (Array.isArray(rule.value)) {
|
||||
return rule.value.some((v) =>
|
||||
String(v).toLowerCase() === String(fieldValue).toLowerCase()
|
||||
)
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
results.set(project.id, matches)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function getFieldValue(
|
||||
project: ProjectForEligibility,
|
||||
field: AutoTagRule['field']
|
||||
): unknown {
|
||||
switch (field) {
|
||||
case 'competitionCategory':
|
||||
return project.competitionCategory
|
||||
case 'country':
|
||||
return project.country
|
||||
case 'geographicZone':
|
||||
return project.geographicZone
|
||||
case 'tags':
|
||||
return project.tags
|
||||
case 'oceanIssue':
|
||||
return project.oceanIssue
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AI Criteria Interpretation ─────────────────────────────────────────────
|
||||
|
||||
const AI_ELIGIBILITY_SYSTEM_PROMPT = `You are a special award eligibility evaluator. Given a list of projects and award criteria, determine which projects are eligible.
|
||||
|
||||
Return a JSON object with this structure:
|
||||
{
|
||||
"evaluations": [
|
||||
{
|
||||
"project_id": "string",
|
||||
"eligible": boolean,
|
||||
"confidence": number (0-1),
|
||||
"reasoning": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Be fair, objective, and base your evaluation only on the provided information. Do not include personal identifiers in reasoning.`
|
||||
|
||||
export async function aiInterpretCriteria(
|
||||
criteriaText: string,
|
||||
projects: ProjectForEligibility[]
|
||||
): Promise<EligibilityResult[]> {
|
||||
const results: EligibilityResult[] = []
|
||||
|
||||
try {
|
||||
const openai = await getOpenAI()
|
||||
if (!openai) {
|
||||
// No OpenAI — mark all as needing manual review
|
||||
return projects.map((p) => ({
|
||||
projectId: p.id,
|
||||
eligible: false,
|
||||
confidence: 0,
|
||||
reasoning: 'AI unavailable — requires manual eligibility review',
|
||||
method: 'AI' as const,
|
||||
}))
|
||||
}
|
||||
|
||||
const model = await getConfiguredModel()
|
||||
|
||||
// Anonymize and batch
|
||||
const anonymized = projects.map((p, i) => ({
|
||||
project_id: `P${i + 1}`,
|
||||
real_id: p.id,
|
||||
title: p.title,
|
||||
description: p.description?.slice(0, 500) || '',
|
||||
category: p.competitionCategory || 'Unknown',
|
||||
ocean_issue: p.oceanIssue || 'Unknown',
|
||||
country: p.country || 'Unknown',
|
||||
region: p.geographicZone || 'Unknown',
|
||||
tags: p.tags.join(', '),
|
||||
}))
|
||||
|
||||
const batchSize = 20
|
||||
for (let i = 0; i < anonymized.length; i += batchSize) {
|
||||
const batch = anonymized.slice(i, i + batchSize)
|
||||
|
||||
const userPrompt = `Award criteria: ${criteriaText}
|
||||
|
||||
Projects to evaluate:
|
||||
${JSON.stringify(
|
||||
batch.map(({ real_id, ...rest }) => rest),
|
||||
null,
|
||||
2
|
||||
)}
|
||||
|
||||
Evaluate each project against the award criteria.`
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: AI_ELIGIBILITY_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3,
|
||||
max_tokens: 4000,
|
||||
})
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
if (content) {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as {
|
||||
evaluations: Array<{
|
||||
project_id: string
|
||||
eligible: boolean
|
||||
confidence: number
|
||||
reasoning: string
|
||||
}>
|
||||
}
|
||||
|
||||
for (const eval_ of parsed.evaluations) {
|
||||
const anon = batch.find((b) => b.project_id === eval_.project_id)
|
||||
if (anon) {
|
||||
results.push({
|
||||
projectId: anon.real_id,
|
||||
eligible: eval_.eligible,
|
||||
confidence: eval_.confidence,
|
||||
reasoning: eval_.reasoning,
|
||||
method: 'AI',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Parse error — mark batch for manual review
|
||||
for (const item of batch) {
|
||||
results.push({
|
||||
projectId: item.real_id,
|
||||
eligible: false,
|
||||
confidence: 0,
|
||||
reasoning: 'AI response parse error — requires manual review',
|
||||
method: 'AI',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// OpenAI error — mark all for manual review
|
||||
return projects.map((p) => ({
|
||||
projectId: p.id,
|
||||
eligible: false,
|
||||
confidence: 0,
|
||||
reasoning: 'AI error — requires manual eligibility review',
|
||||
method: 'AI' as const,
|
||||
}))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
509
src/server/services/ai-filtering.ts
Normal file
509
src/server/services/ai-filtering.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* AI-Powered Filtering Service
|
||||
*
|
||||
* Runs automated filtering rules against projects:
|
||||
* - Field-based rules (age checks, category, country, etc.)
|
||||
* - Document checks (file existence/types)
|
||||
* - AI screening (GPT interprets criteria text, flags spam)
|
||||
*/
|
||||
|
||||
import { getOpenAI, getConfiguredModel } from '@/lib/openai'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type FieldRuleCondition = {
|
||||
field:
|
||||
| 'competitionCategory'
|
||||
| 'foundedAt'
|
||||
| 'country'
|
||||
| 'geographicZone'
|
||||
| 'tags'
|
||||
| 'oceanIssue'
|
||||
operator:
|
||||
| 'equals'
|
||||
| 'not_equals'
|
||||
| 'greater_than'
|
||||
| 'less_than'
|
||||
| 'contains'
|
||||
| 'in'
|
||||
| 'not_in'
|
||||
| 'older_than_years'
|
||||
| 'newer_than_years'
|
||||
| 'is_empty'
|
||||
value: string | number | string[]
|
||||
}
|
||||
|
||||
export type FieldRuleConfig = {
|
||||
conditions: FieldRuleCondition[]
|
||||
logic: 'AND' | 'OR'
|
||||
action: 'PASS' | 'REJECT' | 'FLAG'
|
||||
}
|
||||
|
||||
export type DocumentCheckConfig = {
|
||||
requiredFileTypes?: string[] // e.g. ['pdf', 'docx']
|
||||
minFileCount?: number
|
||||
action: 'PASS' | 'REJECT' | 'FLAG'
|
||||
}
|
||||
|
||||
export type AIScreeningConfig = {
|
||||
criteriaText: string
|
||||
action: 'FLAG' // AI screening always flags for human review
|
||||
}
|
||||
|
||||
export type RuleConfig = FieldRuleConfig | DocumentCheckConfig | AIScreeningConfig
|
||||
|
||||
export interface RuleResult {
|
||||
ruleId: string
|
||||
ruleName: string
|
||||
ruleType: string
|
||||
passed: boolean
|
||||
action: 'PASS' | 'REJECT' | 'FLAG'
|
||||
reasoning?: string
|
||||
}
|
||||
|
||||
export interface ProjectFilteringResult {
|
||||
projectId: string
|
||||
outcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'
|
||||
ruleResults: RuleResult[]
|
||||
aiScreeningJson?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ProjectForFiltering {
|
||||
id: string
|
||||
title: string
|
||||
description?: string | null
|
||||
competitionCategory?: string | null
|
||||
foundedAt?: Date | null
|
||||
country?: string | null
|
||||
geographicZone?: string | null
|
||||
tags: string[]
|
||||
oceanIssue?: string | null
|
||||
wantsMentorship?: boolean | null
|
||||
files: Array<{ id: string; fileName: string; fileType?: string | null }>
|
||||
}
|
||||
|
||||
interface FilteringRuleInput {
|
||||
id: string
|
||||
name: string
|
||||
ruleType: string
|
||||
configJson: Prisma.JsonValue
|
||||
priority: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
// ─── Field-Based Rule Evaluation ────────────────────────────────────────────
|
||||
|
||||
function evaluateCondition(
|
||||
condition: FieldRuleCondition,
|
||||
project: ProjectForFiltering
|
||||
): boolean {
|
||||
const { field, operator, value } = condition
|
||||
|
||||
// Get field value from project
|
||||
let fieldValue: unknown
|
||||
switch (field) {
|
||||
case 'competitionCategory':
|
||||
fieldValue = project.competitionCategory
|
||||
break
|
||||
case 'foundedAt':
|
||||
fieldValue = project.foundedAt
|
||||
break
|
||||
case 'country':
|
||||
fieldValue = project.country
|
||||
break
|
||||
case 'geographicZone':
|
||||
fieldValue = project.geographicZone
|
||||
break
|
||||
case 'tags':
|
||||
fieldValue = project.tags
|
||||
break
|
||||
case 'oceanIssue':
|
||||
fieldValue = project.oceanIssue
|
||||
break
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case 'equals':
|
||||
return String(fieldValue) === String(value)
|
||||
case 'not_equals':
|
||||
return String(fieldValue) !== String(value)
|
||||
case 'contains':
|
||||
if (Array.isArray(fieldValue)) {
|
||||
return fieldValue.some((v) =>
|
||||
String(v).toLowerCase().includes(String(value).toLowerCase())
|
||||
)
|
||||
}
|
||||
return String(fieldValue || '')
|
||||
.toLowerCase()
|
||||
.includes(String(value).toLowerCase())
|
||||
case 'in':
|
||||
if (Array.isArray(value)) {
|
||||
return value.includes(String(fieldValue))
|
||||
}
|
||||
return false
|
||||
case 'not_in':
|
||||
if (Array.isArray(value)) {
|
||||
return !value.includes(String(fieldValue))
|
||||
}
|
||||
return true
|
||||
case 'is_empty':
|
||||
if (fieldValue === null || fieldValue === undefined) return true
|
||||
if (Array.isArray(fieldValue)) return fieldValue.length === 0
|
||||
return String(fieldValue).trim() === ''
|
||||
case 'older_than_years': {
|
||||
if (!fieldValue || !(fieldValue instanceof Date)) return false
|
||||
const yearsAgo = new Date()
|
||||
yearsAgo.setFullYear(yearsAgo.getFullYear() - Number(value))
|
||||
return fieldValue < yearsAgo
|
||||
}
|
||||
case 'newer_than_years': {
|
||||
if (!fieldValue || !(fieldValue instanceof Date)) return false
|
||||
const yearsAgo = new Date()
|
||||
yearsAgo.setFullYear(yearsAgo.getFullYear() - Number(value))
|
||||
return fieldValue >= yearsAgo
|
||||
}
|
||||
case 'greater_than':
|
||||
return Number(fieldValue) > Number(value)
|
||||
case 'less_than':
|
||||
return Number(fieldValue) < Number(value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateFieldRule(
|
||||
config: FieldRuleConfig,
|
||||
project: ProjectForFiltering
|
||||
): { passed: boolean; action: 'PASS' | 'REJECT' | 'FLAG' } {
|
||||
const results = config.conditions.map((c) => evaluateCondition(c, project))
|
||||
|
||||
const allConditionsMet =
|
||||
config.logic === 'AND'
|
||||
? results.every(Boolean)
|
||||
: results.some(Boolean)
|
||||
|
||||
// If conditions met, the rule's action applies
|
||||
// For PASS action: conditions met = passed, not met = not passed
|
||||
// For REJECT action: conditions met = rejected (not passed)
|
||||
// For FLAG action: conditions met = flagged
|
||||
if (config.action === 'PASS') {
|
||||
return { passed: allConditionsMet, action: config.action }
|
||||
}
|
||||
// For REJECT/FLAG: conditions matching means the project should be rejected/flagged
|
||||
return { passed: !allConditionsMet, action: config.action }
|
||||
}
|
||||
|
||||
// ─── Document Check Evaluation ──────────────────────────────────────────────
|
||||
|
||||
export function evaluateDocumentRule(
|
||||
config: DocumentCheckConfig,
|
||||
project: ProjectForFiltering
|
||||
): { passed: boolean; action: 'PASS' | 'REJECT' | 'FLAG' } {
|
||||
const files = project.files || []
|
||||
|
||||
if (config.minFileCount !== undefined && files.length < config.minFileCount) {
|
||||
return { passed: false, action: config.action }
|
||||
}
|
||||
|
||||
if (config.requiredFileTypes && config.requiredFileTypes.length > 0) {
|
||||
const fileExtensions = files.map((f) => {
|
||||
const ext = f.fileName.split('.').pop()?.toLowerCase()
|
||||
return ext || ''
|
||||
})
|
||||
const hasAllTypes = config.requiredFileTypes.every((type) =>
|
||||
fileExtensions.some((ext) => ext === type.toLowerCase())
|
||||
)
|
||||
if (!hasAllTypes) {
|
||||
return { passed: false, action: config.action }
|
||||
}
|
||||
}
|
||||
|
||||
return { passed: true, action: config.action }
|
||||
}
|
||||
|
||||
// ─── AI Screening ───────────────────────────────────────────────────────────
|
||||
|
||||
const AI_SCREENING_SYSTEM_PROMPT = `You are a project screening assistant. You evaluate projects against specific criteria.
|
||||
You must return a JSON object with this structure:
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"project_id": "string",
|
||||
"meets_criteria": boolean,
|
||||
"confidence": number (0-1),
|
||||
"reasoning": "string",
|
||||
"quality_score": number (1-10),
|
||||
"spam_risk": boolean
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Be fair and objective. Base your evaluation only on the information provided.
|
||||
Never include personal identifiers in your reasoning.`
|
||||
|
||||
export async function executeAIScreening(
|
||||
config: AIScreeningConfig,
|
||||
projects: ProjectForFiltering[]
|
||||
): Promise<
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
meetsCriteria: boolean
|
||||
confidence: number
|
||||
reasoning: string
|
||||
qualityScore: number
|
||||
spamRisk: boolean
|
||||
}
|
||||
>
|
||||
> {
|
||||
const results = new Map<
|
||||
string,
|
||||
{
|
||||
meetsCriteria: boolean
|
||||
confidence: number
|
||||
reasoning: string
|
||||
qualityScore: number
|
||||
spamRisk: boolean
|
||||
}
|
||||
>()
|
||||
|
||||
try {
|
||||
const openai = await getOpenAI()
|
||||
if (!openai) {
|
||||
// No OpenAI configured — flag all for manual review
|
||||
for (const p of projects) {
|
||||
results.set(p.id, {
|
||||
meetsCriteria: false,
|
||||
confidence: 0,
|
||||
reasoning: 'AI screening unavailable — flagged for manual review',
|
||||
qualityScore: 5,
|
||||
spamRisk: false,
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
const model = await getConfiguredModel()
|
||||
|
||||
// Anonymize project data — use numeric IDs
|
||||
const anonymizedProjects = projects.map((p, i) => ({
|
||||
project_id: `P${i + 1}`,
|
||||
real_id: p.id,
|
||||
title: p.title,
|
||||
description: p.description?.slice(0, 500) || '',
|
||||
category: p.competitionCategory || 'Unknown',
|
||||
ocean_issue: p.oceanIssue || 'Unknown',
|
||||
country: p.country || 'Unknown',
|
||||
tags: p.tags.join(', '),
|
||||
has_files: (p.files?.length || 0) > 0,
|
||||
}))
|
||||
|
||||
// Process in batches of 20
|
||||
const batchSize = 20
|
||||
for (let i = 0; i < anonymizedProjects.length; i += batchSize) {
|
||||
const batch = anonymizedProjects.slice(i, i + batchSize)
|
||||
|
||||
const userPrompt = `Evaluate these projects against the following criteria:
|
||||
|
||||
CRITERIA: ${config.criteriaText}
|
||||
|
||||
PROJECTS:
|
||||
${JSON.stringify(
|
||||
batch.map(({ real_id, ...rest }) => rest),
|
||||
null,
|
||||
2
|
||||
)}
|
||||
|
||||
Return your evaluation as JSON.`
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: AI_SCREENING_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3,
|
||||
max_tokens: 4000,
|
||||
})
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
if (content) {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as {
|
||||
projects: Array<{
|
||||
project_id: string
|
||||
meets_criteria: boolean
|
||||
confidence: number
|
||||
reasoning: string
|
||||
quality_score: number
|
||||
spam_risk: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
for (const result of parsed.projects) {
|
||||
const anon = batch.find((b) => b.project_id === result.project_id)
|
||||
if (anon) {
|
||||
results.set(anon.real_id, {
|
||||
meetsCriteria: result.meets_criteria,
|
||||
confidence: result.confidence,
|
||||
reasoning: result.reasoning,
|
||||
qualityScore: result.quality_score,
|
||||
spamRisk: result.spam_risk,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Parse error — flag batch for manual review
|
||||
for (const item of batch) {
|
||||
results.set(item.real_id, {
|
||||
meetsCriteria: false,
|
||||
confidence: 0,
|
||||
reasoning: 'AI response parse error — flagged for manual review',
|
||||
qualityScore: 5,
|
||||
spamRisk: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// OpenAI error — flag all for manual review
|
||||
for (const p of projects) {
|
||||
results.set(p.id, {
|
||||
meetsCriteria: false,
|
||||
confidence: 0,
|
||||
reasoning: 'AI screening error — flagged for manual review',
|
||||
qualityScore: 5,
|
||||
spamRisk: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ─── Main Execution ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function executeFilteringRules(
|
||||
rules: FilteringRuleInput[],
|
||||
projects: ProjectForFiltering[]
|
||||
): Promise<ProjectFilteringResult[]> {
|
||||
const activeRules = rules
|
||||
.filter((r) => r.isActive)
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
|
||||
// Separate AI screening rules (need batch processing)
|
||||
const aiRules = activeRules.filter((r) => r.ruleType === 'AI_SCREENING')
|
||||
const nonAiRules = activeRules.filter((r) => r.ruleType !== 'AI_SCREENING')
|
||||
|
||||
// Pre-compute AI screening results if needed
|
||||
const aiResults = new Map<
|
||||
string,
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
meetsCriteria: boolean
|
||||
confidence: number
|
||||
reasoning: string
|
||||
qualityScore: number
|
||||
spamRisk: boolean
|
||||
}
|
||||
>
|
||||
>()
|
||||
|
||||
for (const aiRule of aiRules) {
|
||||
const config = aiRule.configJson as unknown as AIScreeningConfig
|
||||
const screeningResults = await executeAIScreening(config, projects)
|
||||
aiResults.set(aiRule.id, screeningResults)
|
||||
}
|
||||
|
||||
// Evaluate each project
|
||||
const results: ProjectFilteringResult[] = []
|
||||
|
||||
for (const project of projects) {
|
||||
const ruleResults: RuleResult[] = []
|
||||
let hasFailed = false
|
||||
let hasFlagged = false
|
||||
|
||||
// Evaluate non-AI rules
|
||||
for (const rule of nonAiRules) {
|
||||
let result: { passed: boolean; action: 'PASS' | 'REJECT' | 'FLAG' }
|
||||
|
||||
if (rule.ruleType === 'FIELD_BASED') {
|
||||
const config = rule.configJson as unknown as FieldRuleConfig
|
||||
result = evaluateFieldRule(config, project)
|
||||
} else if (rule.ruleType === 'DOCUMENT_CHECK') {
|
||||
const config = rule.configJson as unknown as DocumentCheckConfig
|
||||
result = evaluateDocumentRule(config, project)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
ruleResults.push({
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
ruleType: rule.ruleType,
|
||||
passed: result.passed,
|
||||
action: result.action,
|
||||
})
|
||||
|
||||
if (!result.passed) {
|
||||
if (result.action === 'REJECT') hasFailed = true
|
||||
if (result.action === 'FLAG') hasFlagged = true
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate AI rules
|
||||
for (const aiRule of aiRules) {
|
||||
const ruleScreening = aiResults.get(aiRule.id)
|
||||
const screening = ruleScreening?.get(project.id)
|
||||
|
||||
if (screening) {
|
||||
const passed = screening.meetsCriteria && !screening.spamRisk
|
||||
ruleResults.push({
|
||||
ruleId: aiRule.id,
|
||||
ruleName: aiRule.name,
|
||||
ruleType: 'AI_SCREENING',
|
||||
passed,
|
||||
action: 'FLAG',
|
||||
reasoning: screening.reasoning,
|
||||
})
|
||||
|
||||
if (!passed) hasFlagged = true
|
||||
}
|
||||
}
|
||||
|
||||
// Determine overall outcome
|
||||
let outcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'
|
||||
if (hasFailed) {
|
||||
outcome = 'FILTERED_OUT'
|
||||
} else if (hasFlagged) {
|
||||
outcome = 'FLAGGED'
|
||||
} else {
|
||||
outcome = 'PASSED'
|
||||
}
|
||||
|
||||
// Collect AI screening data
|
||||
const aiScreeningData: Record<string, unknown> = {}
|
||||
for (const aiRule of aiRules) {
|
||||
const screening = aiResults.get(aiRule.id)?.get(project.id)
|
||||
if (screening) {
|
||||
aiScreeningData[aiRule.id] = screening
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
projectId: project.id,
|
||||
outcome,
|
||||
ruleResults,
|
||||
aiScreeningJson:
|
||||
Object.keys(aiScreeningData).length > 0 ? aiScreeningData : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
33
src/server/utils/audit.ts
Normal file
33
src/server/utils/audit.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Shared utility for creating audit log entries.
|
||||
* Wrapped in try-catch so audit failures never break the calling operation.
|
||||
*/
|
||||
export async function logAudit(input: {
|
||||
userId?: string | null
|
||||
action: string
|
||||
entityType: string
|
||||
entityId?: string
|
||||
detailsJson?: Record<string, unknown>
|
||||
ipAddress?: string
|
||||
userAgent?: string
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: input.userId ?? null,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
detailsJson: input.detailsJson as Prisma.InputJsonValue ?? undefined,
|
||||
ipAddress: input.ipAddress,
|
||||
userAgent: input.userAgent,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// Never break the calling operation on audit failure
|
||||
console.error('[Audit] Failed to create audit log entry:', error)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user