Comprehensive platform audit: security, UX, performance, and visual polish
Phase 1: Security - status transition validation, Zod tightening, DB indexes, transactions Phase 2: Admin UX - search/filter for awards, learning, partners pages Phase 3: Dashboard - Recent Activity feed, Pending Actions card, quick actions Phase 4: Jury - assignments progress/urgency, autosave indicator, divergence highlighting Phase 5: Portals - observer charts, mentor search, login/onboarding polish Phase 6: Messages preview dialog, CsvExportDialog with column selection Phase 7: Performance - query optimizations, loading skeletons, useDebounce hook Phase 8: Visual - AnimatedCard, hover effects, StatusBadge, empty state CTAs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,16 @@ import {
|
||||
import { normalizeCountryToCode } from '@/lib/countries'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
// Valid project status transitions
|
||||
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
||||
SUBMITTED: ['ELIGIBLE', 'REJECTED'], // New submissions get screened
|
||||
ELIGIBLE: ['ASSIGNED', 'REJECTED'], // Eligible projects get assigned to jurors
|
||||
ASSIGNED: ['SEMIFINALIST', 'FINALIST', 'REJECTED'], // After evaluation
|
||||
SEMIFINALIST: ['FINALIST', 'REJECTED'], // Semi-finalists advance or get cut
|
||||
FINALIST: ['REJECTED'], // Finalists can only be rejected (rare)
|
||||
REJECTED: ['SUBMITTED'], // Rejected can be re-submitted (admin override)
|
||||
}
|
||||
|
||||
export const projectRouter = router({
|
||||
/**
|
||||
* List projects with filtering and pagination
|
||||
@@ -288,29 +298,73 @@ export const projectRouter = router({
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
programId: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
title: z.string().min(1).max(500),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
country: 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(),
|
||||
institution: z.string().optional(),
|
||||
contactPhone: z.string().optional(),
|
||||
contactEmail: z.string().email('Invalid email address').optional(),
|
||||
contactName: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
metadataJson: z.record(z.unknown()).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { metadataJson, ...rest } = input
|
||||
const {
|
||||
metadataJson,
|
||||
contactPhone, contactEmail, contactName, city,
|
||||
...rest
|
||||
} = input
|
||||
|
||||
// Get round to fetch programId
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { programId: true },
|
||||
})
|
||||
// If roundId provided, derive programId from round for validation
|
||||
let resolvedProgramId = input.programId
|
||||
if (input.roundId) {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { programId: true },
|
||||
})
|
||||
resolvedProgramId = round.programId
|
||||
}
|
||||
|
||||
// Build metadata from contact fields + any additional metadata
|
||||
const fullMetadata: Record<string, unknown> = { ...metadataJson }
|
||||
if (contactPhone) fullMetadata.contactPhone = contactPhone
|
||||
if (contactEmail) fullMetadata.contactEmail = contactEmail
|
||||
if (contactName) fullMetadata.contactName = contactName
|
||||
if (city) fullMetadata.city = city
|
||||
|
||||
// Normalize country to ISO code if provided
|
||||
const normalizedCountry = input.country
|
||||
? normalizeCountryToCode(input.country)
|
||||
: undefined
|
||||
|
||||
const project = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.project.create({
|
||||
data: {
|
||||
...rest,
|
||||
programId: round.programId,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
programId: resolvedProgramId,
|
||||
roundId: input.roundId || null,
|
||||
title: input.title,
|
||||
teamName: input.teamName,
|
||||
description: input.description,
|
||||
tags: input.tags || [],
|
||||
country: normalizedCountry,
|
||||
competitionCategory: input.competitionCategory,
|
||||
oceanIssue: input.oceanIssue,
|
||||
institution: input.institution,
|
||||
metadataJson: Object.keys(fullMetadata).length > 0
|
||||
? (fullMetadata as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
@@ -321,7 +375,7 @@ export const projectRouter = router({
|
||||
action: 'CREATE',
|
||||
entityType: 'Project',
|
||||
entityId: created.id,
|
||||
detailsJson: { title: input.title, roundId: input.roundId },
|
||||
detailsJson: { title: input.title, roundId: input.roundId, programId: resolvedProgramId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
@@ -368,26 +422,45 @@ export const projectRouter = router({
|
||||
? (country === null ? null : normalizeCountryToCode(country))
|
||||
: undefined
|
||||
|
||||
const project = await ctx.prisma.project.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(status && { status }),
|
||||
...(normalizedCountry !== undefined && { country: normalizedCountry }),
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// Record status change in history
|
||||
// Validate status transition if status is being changed
|
||||
if (status) {
|
||||
await ctx.prisma.projectStatusHistory.create({
|
||||
const currentProject = await ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: { status: true },
|
||||
})
|
||||
const allowedTransitions = VALID_PROJECT_TRANSITIONS[currentProject.status] || []
|
||||
if (!allowedTransitions.includes(status)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Invalid status transition: cannot change from ${currentProject.status} to ${status}. Allowed: ${allowedTransitions.join(', ') || 'none'}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.project.update({
|
||||
where: { id },
|
||||
data: {
|
||||
projectId: id,
|
||||
status,
|
||||
changedBy: ctx.user.id,
|
||||
...data,
|
||||
...(status && { status }),
|
||||
...(normalizedCountry !== undefined && { country: normalizedCountry }),
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Record status change in history
|
||||
if (status) {
|
||||
await tx.projectStatusHistory.create({
|
||||
data: {
|
||||
projectId: id,
|
||||
status,
|
||||
changedBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
// Send notifications if status changed
|
||||
if (status) {
|
||||
@@ -660,34 +733,52 @@ export const projectRouter = router({
|
||||
|
||||
const matchingIds = projects.map((p) => p.id)
|
||||
|
||||
const updated = await ctx.prisma.project.updateMany({
|
||||
where: {
|
||||
id: { in: matchingIds },
|
||||
roundId: input.roundId,
|
||||
},
|
||||
data: { status: input.status },
|
||||
// Validate status transitions for all projects
|
||||
const projectsWithStatus = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: matchingIds }, roundId: input.roundId },
|
||||
select: { id: true, title: true, status: true },
|
||||
})
|
||||
|
||||
// Record status change in history for each project
|
||||
if (matchingIds.length > 0) {
|
||||
await ctx.prisma.projectStatusHistory.createMany({
|
||||
data: matchingIds.map((projectId) => ({
|
||||
projectId,
|
||||
status: input.status,
|
||||
changedBy: ctx.user.id,
|
||||
})),
|
||||
const invalidTransitions: string[] = []
|
||||
for (const p of projectsWithStatus) {
|
||||
const allowed = VALID_PROJECT_TRANSITIONS[p.status] || []
|
||||
if (!allowed.includes(input.status)) {
|
||||
invalidTransitions.push(`"${p.title}" (${p.status} → ${input.status})`)
|
||||
}
|
||||
}
|
||||
if (invalidTransitions.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Invalid transitions for ${invalidTransitions.length} project(s): ${invalidTransitions.slice(0, 3).join('; ')}${invalidTransitions.length > 3 ? ` and ${invalidTransitions.length - 3} more` : ''}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_UPDATE_STATUS',
|
||||
entityType: 'Project',
|
||||
detailsJson: { ids: matchingIds, roundId: input.roundId, status: input.status, count: updated.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.project.updateMany({
|
||||
where: { id: { in: matchingIds }, roundId: input.roundId },
|
||||
data: { status: input.status },
|
||||
})
|
||||
|
||||
if (matchingIds.length > 0) {
|
||||
await tx.projectStatusHistory.createMany({
|
||||
data: matchingIds.map((projectId) => ({
|
||||
projectId,
|
||||
status: input.status,
|
||||
changedBy: ctx.user.id,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_UPDATE_STATUS',
|
||||
entityType: 'Project',
|
||||
detailsJson: { ids: matchingIds, roundId: input.roundId, status: input.status, count: result.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// Helper to get notification title based on type
|
||||
|
||||
@@ -8,6 +8,14 @@ import {
|
||||
} from '../services/in-app-notification'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
// Valid round status transitions (state machine)
|
||||
const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
|
||||
DRAFT: ['ACTIVE', 'ARCHIVED'], // Draft can be activated or archived
|
||||
ACTIVE: ['CLOSED'], // Active rounds can only be closed
|
||||
CLOSED: ['ARCHIVED'], // Closed rounds can be archived
|
||||
ARCHIVED: [], // Archived is terminal — no transitions out
|
||||
}
|
||||
|
||||
export const roundRouter = router({
|
||||
/**
|
||||
* List rounds for a program
|
||||
@@ -296,6 +304,15 @@ export const roundRouter = router({
|
||||
select: { status: true, votingStartAt: true, votingEndAt: true },
|
||||
})
|
||||
|
||||
// Validate status transition
|
||||
const allowedTransitions = VALID_ROUND_TRANSITIONS[previousRound.status] || []
|
||||
if (!allowedTransitions.includes(input.status)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Invalid status transition: cannot change from ${previousRound.status} to ${input.status}. Allowed transitions: ${allowedTransitions.join(', ') || 'none (terminal state)'}`,
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// When activating a round, if votingStartAt is in the future, update it to now
|
||||
|
||||
@@ -3,11 +3,7 @@ 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'
|
||||
import { processEligibilityJob } from '../services/award-eligibility-job'
|
||||
|
||||
export const specialAwardRouter = router({
|
||||
// ─── Admin Queries ──────────────────────────────────────────────────────
|
||||
@@ -267,125 +263,53 @@ export const specialAwardRouter = router({
|
||||
includeSubmitted: z.boolean().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
// Set job status to PENDING immediately
|
||||
await ctx.prisma.specialAward.update({
|
||||
where: { id: input.awardId },
|
||||
include: { program: true },
|
||||
})
|
||||
|
||||
// Get projects in the program's rounds
|
||||
const statusFilter = input.includeSubmitted
|
||||
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
||||
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
round: { programId: award.programId },
|
||||
status: { in: [...statusFilter] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
geographicZone: true,
|
||||
tags: true,
|
||||
oceanIssue: true,
|
||||
data: {
|
||||
eligibilityJobStatus: 'PENDING',
|
||||
eligibilityJobTotal: null,
|
||||
eligibilityJobDone: null,
|
||||
eligibilityJobError: null,
|
||||
eligibilityJobStarted: null,
|
||||
},
|
||||
})
|
||||
|
||||
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 AND AI eligibility is enabled)
|
||||
let aiResults: Map<string, { eligible: boolean; confidence: number; reasoning: string }> | undefined
|
||||
if (award.criteriaText && award.useAiEligibility) {
|
||||
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,
|
||||
},
|
||||
detailsJson: { action: 'RUN_ELIGIBILITY_STARTED' },
|
||||
})
|
||||
|
||||
return {
|
||||
total: projects.length,
|
||||
eligible: eligibleCount,
|
||||
ineligible: projects.length - eligibleCount,
|
||||
}
|
||||
// Fire and forget - process in background
|
||||
void processEligibilityJob(
|
||||
input.awardId,
|
||||
input.includeSubmitted ?? false,
|
||||
ctx.user.id
|
||||
)
|
||||
|
||||
return { started: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get eligibility job status for polling
|
||||
*/
|
||||
getEligibilityJobStatus: protectedProcedure
|
||||
.input(z.object({ awardId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
select: {
|
||||
eligibilityJobStatus: true,
|
||||
eligibilityJobTotal: true,
|
||||
eligibilityJobDone: true,
|
||||
eligibilityJobError: true,
|
||||
eligibilityJobStarted: true,
|
||||
},
|
||||
})
|
||||
return award
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
184
src/server/services/award-eligibility-job.ts
Normal file
184
src/server/services/award-eligibility-job.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
applyAutoTagRules,
|
||||
aiInterpretCriteria,
|
||||
type AutoTagRule,
|
||||
} from './ai-award-eligibility'
|
||||
|
||||
const BATCH_SIZE = 20
|
||||
|
||||
/**
|
||||
* Process eligibility for an award in the background.
|
||||
* Updates progress in the database as it goes so the frontend can poll.
|
||||
*/
|
||||
export async function processEligibilityJob(
|
||||
awardId: string,
|
||||
includeSubmitted: boolean,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Mark job as PROCESSING
|
||||
const award = await prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: awardId },
|
||||
include: { program: true },
|
||||
})
|
||||
|
||||
// Get projects
|
||||
const statusFilter = includeSubmitted
|
||||
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
||||
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
round: { programId: award.programId },
|
||||
status: { in: [...statusFilter] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
geographicZone: true,
|
||||
tags: true,
|
||||
oceanIssue: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (projects.length === 0) {
|
||||
await prisma.specialAward.update({
|
||||
where: { id: awardId },
|
||||
data: {
|
||||
eligibilityJobStatus: 'COMPLETED',
|
||||
eligibilityJobTotal: 0,
|
||||
eligibilityJobDone: 0,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await prisma.specialAward.update({
|
||||
where: { id: awardId },
|
||||
data: {
|
||||
eligibilityJobStatus: 'PROCESSING',
|
||||
eligibilityJobTotal: projects.length,
|
||||
eligibilityJobDone: 0,
|
||||
eligibilityJobError: null,
|
||||
eligibilityJobStarted: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Phase 1: Auto-tag rules (deterministic, fast)
|
||||
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 AND AI eligibility is enabled)
|
||||
// Process in batches to avoid timeouts
|
||||
let aiResults: Map<string, { eligible: boolean; confidence: number; reasoning: string }> | undefined
|
||||
|
||||
if (award.criteriaText && award.useAiEligibility) {
|
||||
aiResults = new Map()
|
||||
|
||||
for (let i = 0; i < projects.length; i += BATCH_SIZE) {
|
||||
const batch = projects.slice(i, i + BATCH_SIZE)
|
||||
const aiEvals = await aiInterpretCriteria(award.criteriaText, batch)
|
||||
|
||||
for (const e of aiEvals) {
|
||||
aiResults.set(e.projectId, {
|
||||
eligible: e.eligible,
|
||||
confidence: e.confidence,
|
||||
reasoning: e.reasoning,
|
||||
})
|
||||
}
|
||||
|
||||
// Update progress
|
||||
await prisma.specialAward.update({
|
||||
where: { id: awardId },
|
||||
data: {
|
||||
eligibilityJobDone: Math.min(i + BATCH_SIZE, projects.length),
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// No AI needed, mark all as done
|
||||
await prisma.specialAward.update({
|
||||
where: { id: awardId },
|
||||
data: { eligibilityJobDone: projects.length },
|
||||
})
|
||||
}
|
||||
|
||||
// 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 prisma.$transaction(
|
||||
eligibilities.map((e) =>
|
||||
prisma.awardEligibility.upsert({
|
||||
where: {
|
||||
awardId_projectId: {
|
||||
awardId,
|
||||
projectId: e.projectId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
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,
|
||||
overriddenBy: null,
|
||||
overriddenAt: null,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Mark as completed
|
||||
await prisma.specialAward.update({
|
||||
where: { id: awardId },
|
||||
data: {
|
||||
eligibilityJobStatus: 'COMPLETED',
|
||||
eligibilityJobDone: projects.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// Mark as failed
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
try {
|
||||
await prisma.specialAward.update({
|
||||
where: { id: awardId },
|
||||
data: {
|
||||
eligibilityJobStatus: 'FAILED',
|
||||
eligibilityJobError: errorMessage,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// If we can't even update the status, log and give up
|
||||
console.error('Failed to update eligibility job status:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user