Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -68,7 +68,7 @@ interface ScoringPatterns {
|
||||
export interface EvaluationSummaryResult {
|
||||
id: string
|
||||
projectId: string
|
||||
roundId: string
|
||||
stageId: string
|
||||
summaryJson: AIResponsePayload & { scoringPatterns: ScoringPatterns }
|
||||
generatedAt: Date
|
||||
model: string
|
||||
@@ -194,12 +194,12 @@ export function computeScoringPatterns(
|
||||
*/
|
||||
export async function generateSummary({
|
||||
projectId,
|
||||
roundId,
|
||||
stageId,
|
||||
userId,
|
||||
prisma,
|
||||
}: {
|
||||
projectId: string
|
||||
roundId: string
|
||||
stageId: string
|
||||
userId: string
|
||||
prisma: PrismaClient
|
||||
}): Promise<EvaluationSummaryResult> {
|
||||
@@ -209,7 +209,6 @@ export async function generateSummary({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
roundId: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -217,13 +216,13 @@ export async function generateSummary({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
||||
}
|
||||
|
||||
// Fetch submitted evaluations for this project in this round
|
||||
// Fetch submitted evaluations for this project in this stage
|
||||
const evaluations = await prisma.evaluation.findMany({
|
||||
where: {
|
||||
status: 'SUBMITTED',
|
||||
assignment: {
|
||||
projectId,
|
||||
roundId,
|
||||
stageId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
@@ -245,13 +244,13 @@ export async function generateSummary({
|
||||
if (evaluations.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No submitted evaluations found for this project in this round',
|
||||
message: 'No submitted evaluations found for this project in this stage',
|
||||
})
|
||||
}
|
||||
|
||||
// Get evaluation form criteria for this round
|
||||
// Get evaluation form criteria for this stage
|
||||
const form = await prisma.evaluationForm.findFirst({
|
||||
where: { roundId, isActive: true },
|
||||
where: { stageId, isActive: true },
|
||||
select: { criteriaJson: true },
|
||||
})
|
||||
|
||||
@@ -360,11 +359,11 @@ export async function generateSummary({
|
||||
|
||||
const summary = await prisma.evaluationSummary.upsert({
|
||||
where: {
|
||||
projectId_roundId: { projectId, roundId },
|
||||
projectId_stageId: { projectId, stageId },
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
roundId,
|
||||
stageId,
|
||||
summaryJson: summaryJsonValue,
|
||||
generatedById: userId,
|
||||
model,
|
||||
@@ -396,7 +395,7 @@ export async function generateSummary({
|
||||
return {
|
||||
id: summary.id,
|
||||
projectId: summary.projectId,
|
||||
roundId: summary.roundId,
|
||||
stageId: summary.stageId,
|
||||
summaryJson: summaryJson as AIResponsePayload & { scoringPatterns: ScoringPatterns },
|
||||
generatedAt: summary.generatedAt,
|
||||
model: summary.model,
|
||||
|
||||
@@ -313,11 +313,10 @@ Evaluate and return JSON.`
|
||||
const usage = extractTokenUsage(response)
|
||||
tokensUsed = usage.totalTokens
|
||||
|
||||
// Log usage
|
||||
await logAIUsage({
|
||||
userId,
|
||||
action: 'FILTERING',
|
||||
entityType: 'Round',
|
||||
entityType: 'Stage',
|
||||
entityId,
|
||||
model,
|
||||
promptTokens: usage.promptTokens,
|
||||
@@ -366,7 +365,7 @@ Evaluate and return JSON.`
|
||||
await logAIUsage({
|
||||
userId,
|
||||
action: 'FILTERING',
|
||||
entityType: 'Round',
|
||||
entityType: 'Stage',
|
||||
entityId,
|
||||
model,
|
||||
promptTokens: 0,
|
||||
@@ -507,11 +506,10 @@ export async function executeAIScreening(
|
||||
const classified = classifyAIError(error)
|
||||
logAIError('Filtering', 'executeAIScreening', classified)
|
||||
|
||||
// Log failed attempt
|
||||
await logAIUsage({
|
||||
userId,
|
||||
action: 'FILTERING',
|
||||
entityType: 'Round',
|
||||
entityType: 'Stage',
|
||||
entityId,
|
||||
model: 'unknown',
|
||||
promptTokens: 0,
|
||||
@@ -544,7 +542,7 @@ export async function executeFilteringRules(
|
||||
rules: FilteringRuleInput[],
|
||||
projects: ProjectForFiltering[],
|
||||
userId?: string,
|
||||
roundId?: string,
|
||||
stageId?: string,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<ProjectFilteringResult[]> {
|
||||
const activeRules = rules
|
||||
@@ -560,7 +558,7 @@ export async function executeFilteringRules(
|
||||
|
||||
for (const aiRule of aiRules) {
|
||||
const config = aiRule.configJson as unknown as AIScreeningConfig
|
||||
const screeningResults = await executeAIScreening(config, projects, userId, roundId, onProgress)
|
||||
const screeningResults = await executeAIScreening(config, projects, userId, stageId, onProgress)
|
||||
aiResults.set(aiRule.id, screeningResults)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function processEligibilityJob(
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
round: { programId: award.programId },
|
||||
programId: award.programId,
|
||||
status: { in: [...statusFilter] },
|
||||
},
|
||||
select: {
|
||||
|
||||
@@ -145,14 +145,14 @@ async function getDigestContent(
|
||||
where: {
|
||||
userId,
|
||||
isCompleted: false,
|
||||
round: {
|
||||
status: 'ACTIVE',
|
||||
votingEndAt: { gt: now },
|
||||
stage: {
|
||||
status: 'STAGE_ACTIVE',
|
||||
windowCloseAt: { gt: now },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
project: { select: { id: true, title: true } },
|
||||
round: { select: { name: true, votingEndAt: true } },
|
||||
stage: { select: { name: true, windowCloseAt: true } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -162,9 +162,9 @@ async function getDigestContent(
|
||||
title: `Pending Evaluations (${pendingAssignments.length})`,
|
||||
items: pendingAssignments.map(
|
||||
(a) =>
|
||||
`${a.project.title} - ${a.round.name}${
|
||||
a.round.votingEndAt
|
||||
? ` (due ${a.round.votingEndAt.toLocaleDateString('en-US', {
|
||||
`${a.project.title} - ${a.stage?.name ?? 'Unknown'}${
|
||||
a.stage?.windowCloseAt
|
||||
? ` (due ${a.stage.windowCloseAt.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})})`
|
||||
@@ -175,13 +175,13 @@ async function getDigestContent(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Upcoming deadlines (rounds ending within 7 days)
|
||||
// 2. Upcoming deadlines (stages closing within 7 days)
|
||||
if (enabledSections.includes('upcoming_deadlines')) {
|
||||
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
const upcomingRounds = await prisma.round.findMany({
|
||||
const upcomingStages = await prisma.stage.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
votingEndAt: {
|
||||
status: 'STAGE_ACTIVE',
|
||||
windowCloseAt: {
|
||||
gt: now,
|
||||
lte: sevenDaysFromNow,
|
||||
},
|
||||
@@ -194,17 +194,17 @@ async function getDigestContent(
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
votingEndAt: true,
|
||||
windowCloseAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
upcomingDeadlines = upcomingRounds.length
|
||||
if (upcomingRounds.length > 0) {
|
||||
upcomingDeadlines = upcomingStages.length
|
||||
if (upcomingStages.length > 0) {
|
||||
sections.push({
|
||||
title: 'Upcoming Deadlines',
|
||||
items: upcomingRounds.map(
|
||||
(r) =>
|
||||
`${r.name} - ${r.votingEndAt?.toLocaleDateString('en-US', {
|
||||
items: upcomingStages.map(
|
||||
(s) =>
|
||||
`${s.name} - ${s.windowCloseAt?.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -233,7 +233,7 @@ async function getDigestContent(
|
||||
},
|
||||
include: {
|
||||
project: { select: { id: true, title: true } },
|
||||
round: { select: { name: true } },
|
||||
stage: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -242,7 +242,7 @@ async function getDigestContent(
|
||||
sections.push({
|
||||
title: `New Assignments (${recentAssignments.length})`,
|
||||
items: recentAssignments.map(
|
||||
(a) => `${a.project.title} - ${a.round.name}`
|
||||
(a) => `${a.project.title} - ${a.stage?.name ?? 'Unknown'}`
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,36 +15,36 @@ interface ReminderResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find active rounds with approaching voting deadlines and send reminders
|
||||
* Find active stages with approaching deadlines and send reminders
|
||||
* to jurors who have incomplete assignments.
|
||||
*/
|
||||
export async function processEvaluationReminders(roundId?: string): Promise<ReminderResult> {
|
||||
export async function processEvaluationReminders(stageId?: string): Promise<ReminderResult> {
|
||||
const now = new Date()
|
||||
let totalSent = 0
|
||||
let totalErrors = 0
|
||||
|
||||
// Find active rounds with voting end dates in the future
|
||||
const rounds = await prisma.round.findMany({
|
||||
// Find active stages with window close dates in the future
|
||||
const stages = await prisma.stage.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
votingEndAt: { gt: now },
|
||||
votingStartAt: { lte: now },
|
||||
...(roundId && { id: roundId }),
|
||||
status: 'STAGE_ACTIVE',
|
||||
windowCloseAt: { gt: now },
|
||||
windowOpenAt: { lte: now },
|
||||
...(stageId && { id: stageId }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
votingEndAt: true,
|
||||
program: { select: { name: true } },
|
||||
windowCloseAt: true,
|
||||
track: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
for (const round of rounds) {
|
||||
if (!round.votingEndAt) continue
|
||||
for (const stage of stages) {
|
||||
if (!stage.windowCloseAt) continue
|
||||
|
||||
const msUntilDeadline = round.votingEndAt.getTime() - now.getTime()
|
||||
const msUntilDeadline = stage.windowCloseAt.getTime() - now.getTime()
|
||||
|
||||
// Determine which reminder types should fire for this round
|
||||
// Determine which reminder types should fire for this stage
|
||||
const applicableTypes = REMINDER_TYPES.filter(
|
||||
({ thresholdMs }) => msUntilDeadline <= thresholdMs
|
||||
)
|
||||
@@ -52,7 +52,7 @@ export async function processEvaluationReminders(roundId?: string): Promise<Remi
|
||||
if (applicableTypes.length === 0) continue
|
||||
|
||||
for (const { type } of applicableTypes) {
|
||||
const result = await sendRemindersForRound(round, type, now)
|
||||
const result = await sendRemindersForStage(stage, type, now)
|
||||
totalSent += result.sent
|
||||
totalErrors += result.errors
|
||||
}
|
||||
@@ -61,12 +61,12 @@ export async function processEvaluationReminders(roundId?: string): Promise<Remi
|
||||
return { sent: totalSent, errors: totalErrors }
|
||||
}
|
||||
|
||||
async function sendRemindersForRound(
|
||||
round: {
|
||||
async function sendRemindersForStage(
|
||||
stage: {
|
||||
id: string
|
||||
name: string
|
||||
votingEndAt: Date | null
|
||||
program: { name: string }
|
||||
windowCloseAt: Date | null
|
||||
track: { name: string }
|
||||
},
|
||||
type: ReminderType,
|
||||
now: Date
|
||||
@@ -74,12 +74,12 @@ async function sendRemindersForRound(
|
||||
let sent = 0
|
||||
let errors = 0
|
||||
|
||||
if (!round.votingEndAt) return { sent, errors }
|
||||
if (!stage.windowCloseAt) return { sent, errors }
|
||||
|
||||
// Find jurors with incomplete assignments for this round
|
||||
// Find jurors with incomplete assignments for this stage
|
||||
const incompleteAssignments = await prisma.assignment.findMany({
|
||||
where: {
|
||||
roundId: round.id,
|
||||
stageId: stage.id,
|
||||
isCompleted: false,
|
||||
},
|
||||
select: {
|
||||
@@ -92,10 +92,10 @@ async function sendRemindersForRound(
|
||||
|
||||
if (userIds.length === 0) return { sent, errors }
|
||||
|
||||
// Check which users already received this reminder type for this round
|
||||
// Check which users already received this reminder type for this stage
|
||||
const existingReminders = await prisma.reminderLog.findMany({
|
||||
where: {
|
||||
roundId: round.id,
|
||||
stageId: stage.id,
|
||||
type,
|
||||
userId: { in: userIds },
|
||||
},
|
||||
@@ -114,7 +114,7 @@ async function sendRemindersForRound(
|
||||
})
|
||||
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
||||
const deadlineStr = round.votingEndAt.toLocaleDateString('en-US', {
|
||||
const deadlineStr = stage.windowCloseAt.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
@@ -144,12 +144,12 @@ async function sendRemindersForRound(
|
||||
emailTemplateType,
|
||||
{
|
||||
name: user.name || undefined,
|
||||
title: `Evaluation Reminder - ${round.name}`,
|
||||
message: `You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''} for ${round.name}.`,
|
||||
linkUrl: `${baseUrl}/jury/assignments?round=${round.id}`,
|
||||
title: `Evaluation Reminder - ${stage.name}`,
|
||||
message: `You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''} for ${stage.name}.`,
|
||||
linkUrl: `${baseUrl}/jury/stages/${stage.id}/assignments`,
|
||||
metadata: {
|
||||
pendingCount,
|
||||
roundName: round.name,
|
||||
stageName: stage.name,
|
||||
deadline: deadlineStr,
|
||||
},
|
||||
}
|
||||
@@ -158,7 +158,7 @@ async function sendRemindersForRound(
|
||||
// Log the sent reminder
|
||||
await prisma.reminderLog.create({
|
||||
data: {
|
||||
roundId: round.id,
|
||||
stageId: stage.id,
|
||||
userId: user.id,
|
||||
type,
|
||||
},
|
||||
@@ -167,7 +167,7 @@ async function sendRemindersForRound(
|
||||
sent++
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to send ${type} reminder to ${user.email} for round ${round.name}:`,
|
||||
`Failed to send ${type} reminder to ${user.email} for stage ${stage.name}:`,
|
||||
error
|
||||
)
|
||||
errors++
|
||||
|
||||
@@ -304,14 +304,14 @@ export async function notifyAdmins(params: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all jury members for a specific round
|
||||
* Notify all jury members for a specific stage
|
||||
*/
|
||||
export async function notifyRoundJury(
|
||||
roundId: string,
|
||||
export async function notifyStageJury(
|
||||
stageId: string,
|
||||
params: Omit<CreateNotificationParams, 'userId'>
|
||||
): Promise<void> {
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: { roundId },
|
||||
where: { stageId },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
})
|
||||
|
||||
618
src/server/services/live-control.ts
Normal file
618
src/server/services/live-control.ts
Normal file
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* Live Control Service
|
||||
*
|
||||
* Manages real-time control of live final events within a pipeline stage.
|
||||
* Handles session management, project cursor navigation, queue reordering,
|
||||
* pause/resume, and cohort voting windows.
|
||||
*
|
||||
* The LiveProgressCursor tracks the current position in a live presentation
|
||||
* sequence. Cohorts group projects for voting with configurable windows.
|
||||
*/
|
||||
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SessionResult {
|
||||
success: boolean
|
||||
sessionId: string | null
|
||||
cursorId: string | null
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
export interface CursorState {
|
||||
stageId: string
|
||||
sessionId: string
|
||||
activeProjectId: string | null
|
||||
activeOrderIndex: number
|
||||
isPaused: boolean
|
||||
totalProjects: number
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function generateSessionId(): string {
|
||||
const timestamp = Date.now().toString(36)
|
||||
const random = Math.random().toString(36).substring(2, 8)
|
||||
return `live-${timestamp}-${random}`
|
||||
}
|
||||
|
||||
// ─── Start Session ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create or reset a LiveProgressCursor for a stage. If a cursor already exists,
|
||||
* it is reset to the beginning. A new sessionId is always generated.
|
||||
*/
|
||||
export async function startSession(
|
||||
stageId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<SessionResult> {
|
||||
try {
|
||||
// Verify stage exists and is a LIVE_FINAL type
|
||||
const stage = await prisma.stage.findUnique({
|
||||
where: { id: stageId },
|
||||
})
|
||||
|
||||
if (!stage) {
|
||||
return {
|
||||
success: false,
|
||||
sessionId: null,
|
||||
cursorId: null,
|
||||
errors: [`Stage ${stageId} not found`],
|
||||
}
|
||||
}
|
||||
|
||||
if (stage.stageType !== 'LIVE_FINAL') {
|
||||
return {
|
||||
success: false,
|
||||
sessionId: null,
|
||||
cursorId: null,
|
||||
errors: [
|
||||
`Stage "${stage.name}" is type ${stage.stageType}, expected LIVE_FINAL`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Find the first project in the first cohort
|
||||
const firstCohortProject = await prisma.cohortProject.findFirst({
|
||||
where: {
|
||||
cohort: { stageId },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
select: { projectId: true },
|
||||
})
|
||||
|
||||
const sessionId = generateSessionId()
|
||||
|
||||
// Upsert the cursor (one per stage)
|
||||
const cursor = await prisma.liveProgressCursor.upsert({
|
||||
where: { stageId },
|
||||
create: {
|
||||
stageId,
|
||||
sessionId,
|
||||
activeProjectId: firstCohortProject?.projectId ?? null,
|
||||
activeOrderIndex: 0,
|
||||
isPaused: false,
|
||||
},
|
||||
update: {
|
||||
sessionId,
|
||||
activeProjectId: firstCohortProject?.projectId ?? null,
|
||||
activeOrderIndex: 0,
|
||||
isPaused: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Decision audit log
|
||||
await prisma.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'live.session_started',
|
||||
entityType: 'LiveProgressCursor',
|
||||
entityId: cursor.id,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
stageId,
|
||||
sessionId,
|
||||
firstProjectId: firstCohortProject?.projectId ?? null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: actorId,
|
||||
action: 'LIVE_SESSION_STARTED',
|
||||
entityType: 'LiveProgressCursor',
|
||||
entityId: cursor.id,
|
||||
detailsJson: { stageId, sessionId },
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionId,
|
||||
cursorId: cursor.id,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[LiveControl] Failed to start session:', error)
|
||||
return {
|
||||
success: false,
|
||||
sessionId: null,
|
||||
cursorId: null,
|
||||
errors: [
|
||||
error instanceof Error ? error.message : 'Failed to start live session',
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Set Active Project ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set the currently active project in the live session.
|
||||
* Validates that the project belongs to a cohort in this stage and performs
|
||||
* a version check on the cursor's sessionId to prevent stale updates.
|
||||
*/
|
||||
export async function setActiveProject(
|
||||
stageId: string,
|
||||
projectId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<{ success: boolean; errors?: string[] }> {
|
||||
try {
|
||||
// Verify cursor exists
|
||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
||||
where: { stageId },
|
||||
})
|
||||
|
||||
if (!cursor) {
|
||||
return {
|
||||
success: false,
|
||||
errors: ['No live session found for this stage. Start a session first.'],
|
||||
}
|
||||
}
|
||||
|
||||
// Verify project is in a cohort for this stage
|
||||
const cohortProject = await prisma.cohortProject.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
cohort: { stageId },
|
||||
},
|
||||
select: { id: true, sortOrder: true },
|
||||
})
|
||||
|
||||
if (!cohortProject) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
`Project ${projectId} is not in any cohort for stage ${stageId}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Update cursor
|
||||
await prisma.liveProgressCursor.update({
|
||||
where: { stageId },
|
||||
data: {
|
||||
activeProjectId: projectId,
|
||||
activeOrderIndex: cohortProject.sortOrder,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit
|
||||
await prisma.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'live.cursor_updated',
|
||||
entityType: 'LiveProgressCursor',
|
||||
entityId: cursor.id,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
stageId,
|
||||
projectId,
|
||||
orderIndex: cohortProject.sortOrder,
|
||||
action: 'setActiveProject',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: actorId,
|
||||
action: 'LIVE_SET_ACTIVE_PROJECT',
|
||||
entityType: 'LiveProgressCursor',
|
||||
entityId: cursor.id,
|
||||
detailsJson: { projectId, orderIndex: cohortProject.sortOrder },
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('[LiveControl] Failed to set active project:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to set active project',
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Jump to Project ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Jump to a project by its order index in the cohort queue.
|
||||
*/
|
||||
export async function jumpToProject(
|
||||
stageId: string,
|
||||
orderIndex: number,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<{ success: boolean; projectId?: string; errors?: string[] }> {
|
||||
try {
|
||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
||||
where: { stageId },
|
||||
})
|
||||
|
||||
if (!cursor) {
|
||||
return {
|
||||
success: false,
|
||||
errors: ['No live session found for this stage'],
|
||||
}
|
||||
}
|
||||
|
||||
// Find the CohortProject at the given sort order
|
||||
const cohortProject = await prisma.cohortProject.findFirst({
|
||||
where: {
|
||||
cohort: { stageId },
|
||||
sortOrder: orderIndex,
|
||||
},
|
||||
select: { projectId: true, sortOrder: true },
|
||||
})
|
||||
|
||||
if (!cohortProject) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [`No project found at order index ${orderIndex}`],
|
||||
}
|
||||
}
|
||||
|
||||
// Update cursor
|
||||
await prisma.liveProgressCursor.update({
|
||||
where: { stageId },
|
||||
data: {
|
||||
activeProjectId: cohortProject.projectId,
|
||||
activeOrderIndex: orderIndex,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'live.cursor_updated',
|
||||
entityType: 'LiveProgressCursor',
|
||||
entityId: cursor.id,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
stageId,
|
||||
projectId: cohortProject.projectId,
|
||||
orderIndex,
|
||||
action: 'jumpToProject',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: actorId,
|
||||
action: 'LIVE_JUMP_TO_PROJECT',
|
||||
entityType: 'LiveProgressCursor',
|
||||
entityId: cursor.id,
|
||||
detailsJson: { orderIndex, projectId: cohortProject.projectId },
|
||||
})
|
||||
|
||||
return { success: true, projectId: cohortProject.projectId }
|
||||
} catch (error) {
|
||||
console.error('[LiveControl] Failed to jump to project:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
error instanceof Error ? error.message : 'Failed to jump to project',
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reorder Queue ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reorder the presentation queue by updating CohortProject sortOrder values.
|
||||
* newOrder is an array of cohortProjectIds in the desired order.
|
||||
*/
|
||||
export async function reorderQueue(
|
||||
stageId: string,
|
||||
newOrder: string[],
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<{ success: boolean; errors?: string[] }> {
|
||||
try {
|
||||
// Verify all provided IDs belong to cohorts in this stage
|
||||
const cohortProjects = await prisma.cohortProject.findMany({
|
||||
where: {
|
||||
id: { in: newOrder },
|
||||
cohort: { stageId },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const validIds = new Set(cohortProjects.map((cp: any) => cp.id))
|
||||
const invalidIds = newOrder.filter((id) => !validIds.has(id))
|
||||
|
||||
if (invalidIds.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
`CohortProject IDs not found in stage ${stageId}: ${invalidIds.join(', ')}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Update sortOrder for each item
|
||||
await prisma.$transaction(
|
||||
newOrder.map((cohortProjectId, index) =>
|
||||
prisma.cohortProject.update({
|
||||
where: { id: cohortProjectId },
|
||||
data: { sortOrder: index },
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
||||
where: { stageId },
|
||||
})
|
||||
|
||||
if (cursor) {
|
||||
await prisma.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'live.queue_reordered',
|
||||
entityType: 'LiveProgressCursor',
|
||||
entityId: cursor.id,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
stageId,
|
||||
newOrderCount: newOrder.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: actorId,
|
||||
action: 'LIVE_REORDER_QUEUE',
|
||||
entityType: 'Stage',
|
||||
entityId: stageId,
|
||||
detailsJson: { reorderedCount: newOrder.length },
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('[LiveControl] Failed to reorder queue:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
error instanceof Error ? error.message : 'Failed to reorder queue',
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pause / Resume ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Toggle the pause state of a live session.
|
||||
*/
|
||||
export async function pauseResume(
|
||||
stageId: string,
|
||||
isPaused: boolean,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<{ success: boolean; errors?: string[] }> {
|
||||
try {
|
||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
||||
where: { stageId },
|
||||
})
|
||||
|
||||
if (!cursor) {
|
||||
return {
|
||||
success: false,
|
||||
errors: ['No live session found for this stage'],
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.liveProgressCursor.update({
|
||||
where: { stageId },
|
||||
data: { isPaused },
|
||||
})
|
||||
|
||||
await prisma.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: isPaused ? 'live.session_paused' : 'live.session_resumed',
|
||||
entityType: 'LiveProgressCursor',
|
||||
entityId: cursor.id,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
stageId,
|
||||
isPaused,
|
||||
sessionId: cursor.sessionId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: actorId,
|
||||
action: isPaused ? 'LIVE_SESSION_PAUSED' : 'LIVE_SESSION_RESUMED',
|
||||
entityType: 'LiveProgressCursor',
|
||||
entityId: cursor.id,
|
||||
detailsJson: { stageId, isPaused },
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('[LiveControl] Failed to pause/resume:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to toggle pause state',
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cohort Window Management ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open a cohort's voting window. Sets isOpen to true and records the
|
||||
* window open timestamp.
|
||||
*/
|
||||
export async function openCohortWindow(
|
||||
cohortId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<{ success: boolean; errors?: string[] }> {
|
||||
try {
|
||||
const cohort = await prisma.cohort.findUnique({
|
||||
where: { id: cohortId },
|
||||
})
|
||||
|
||||
if (!cohort) {
|
||||
return { success: false, errors: [`Cohort ${cohortId} not found`] }
|
||||
}
|
||||
|
||||
if (cohort.isOpen) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [`Cohort "${cohort.name}" is already open`],
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await prisma.cohort.update({
|
||||
where: { id: cohortId },
|
||||
data: {
|
||||
isOpen: true,
|
||||
windowOpenAt: now,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'live.cohort_opened',
|
||||
entityType: 'Cohort',
|
||||
entityId: cohortId,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
cohortName: cohort.name,
|
||||
stageId: cohort.stageId,
|
||||
openedAt: now.toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: actorId,
|
||||
action: 'LIVE_COHORT_OPENED',
|
||||
entityType: 'Cohort',
|
||||
entityId: cohortId,
|
||||
detailsJson: { cohortName: cohort.name, stageId: cohort.stageId },
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('[LiveControl] Failed to open cohort window:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to open cohort window',
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a cohort's voting window. Sets isOpen to false and records the
|
||||
* window close timestamp.
|
||||
*/
|
||||
export async function closeCohortWindow(
|
||||
cohortId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<{ success: boolean; errors?: string[] }> {
|
||||
try {
|
||||
const cohort = await prisma.cohort.findUnique({
|
||||
where: { id: cohortId },
|
||||
})
|
||||
|
||||
if (!cohort) {
|
||||
return { success: false, errors: [`Cohort ${cohortId} not found`] }
|
||||
}
|
||||
|
||||
if (!cohort.isOpen) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [`Cohort "${cohort.name}" is already closed`],
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await prisma.cohort.update({
|
||||
where: { id: cohortId },
|
||||
data: {
|
||||
isOpen: false,
|
||||
windowCloseAt: now,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'live.cohort_closed',
|
||||
entityType: 'Cohort',
|
||||
entityId: cohortId,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
cohortName: cohort.name,
|
||||
stageId: cohort.stageId,
|
||||
closedAt: now.toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: actorId,
|
||||
action: 'LIVE_COHORT_CLOSED',
|
||||
entityType: 'Cohort',
|
||||
entityId: cohortId,
|
||||
detailsJson: { cohortName: cohort.name, stageId: cohort.stageId },
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('[LiveControl] Failed to close cohort window:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to close cohort window',
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
505
src/server/services/routing-engine.ts
Normal file
505
src/server/services/routing-engine.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* Routing Engine Service
|
||||
*
|
||||
* Evaluates routing rules against projects and executes routing decisions
|
||||
* to move projects between tracks in a pipeline. Supports three routing modes:
|
||||
*
|
||||
* - PARALLEL: Keep the project in the current track AND add it to the destination track
|
||||
* - EXCLUSIVE: Exit the project from the current track and move to the destination track
|
||||
* - POST_MAIN: Route to destination only after the main track gate is passed
|
||||
*
|
||||
* Predicate evaluation supports operators: eq, neq, in, contains, gt, lt
|
||||
* Compound predicates: and, or
|
||||
*/
|
||||
|
||||
import type { PrismaClient, Prisma } from '@prisma/client'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PredicateLeaf {
|
||||
field: string
|
||||
operator: 'eq' | 'neq' | 'in' | 'contains' | 'gt' | 'lt'
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface PredicateCompound {
|
||||
logic: 'and' | 'or'
|
||||
conditions: PredicateNode[]
|
||||
}
|
||||
|
||||
type PredicateNode = PredicateLeaf | PredicateCompound
|
||||
|
||||
export interface MatchedRule {
|
||||
ruleId: string
|
||||
ruleName: string
|
||||
destinationTrackId: string
|
||||
destinationStageId: string | null
|
||||
routingMode: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface RoutingPreviewItem {
|
||||
projectId: string
|
||||
projectTitle: string
|
||||
matchedRule: MatchedRule | null
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface RoutingExecutionResult {
|
||||
success: boolean
|
||||
projectStageStateId: string | null
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
// ─── Predicate Evaluation ───────────────────────────────────────────────────
|
||||
|
||||
function isCompoundPredicate(node: unknown): node is PredicateCompound {
|
||||
return (
|
||||
typeof node === 'object' &&
|
||||
node !== null &&
|
||||
'logic' in node &&
|
||||
'conditions' in node
|
||||
)
|
||||
}
|
||||
|
||||
function isLeafPredicate(node: unknown): node is PredicateLeaf {
|
||||
return (
|
||||
typeof node === 'object' &&
|
||||
node !== null &&
|
||||
'field' in node &&
|
||||
'operator' in node
|
||||
)
|
||||
}
|
||||
|
||||
function evaluateLeaf(
|
||||
leaf: PredicateLeaf,
|
||||
context: Record<string, unknown>
|
||||
): boolean {
|
||||
const fieldValue = resolveField(context, leaf.field)
|
||||
|
||||
switch (leaf.operator) {
|
||||
case 'eq':
|
||||
return fieldValue === leaf.value
|
||||
case 'neq':
|
||||
return fieldValue !== leaf.value
|
||||
case 'in': {
|
||||
if (!Array.isArray(leaf.value)) return false
|
||||
return leaf.value.includes(fieldValue)
|
||||
}
|
||||
case 'contains': {
|
||||
if (typeof fieldValue === 'string' && typeof leaf.value === 'string') {
|
||||
return fieldValue.toLowerCase().includes(leaf.value.toLowerCase())
|
||||
}
|
||||
if (Array.isArray(fieldValue)) {
|
||||
return fieldValue.includes(leaf.value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
case 'gt':
|
||||
return Number(fieldValue) > Number(leaf.value)
|
||||
case 'lt':
|
||||
return Number(fieldValue) < Number(leaf.value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a dot-notation field path from a context object.
|
||||
* E.g. "project.country" resolves context.project.country
|
||||
*/
|
||||
function resolveField(
|
||||
context: Record<string, unknown>,
|
||||
fieldPath: string
|
||||
): unknown {
|
||||
const parts = fieldPath.split('.')
|
||||
let current: unknown = context
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined
|
||||
if (typeof current !== 'object') return undefined
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
function evaluatePredicate(
|
||||
node: PredicateNode,
|
||||
context: Record<string, unknown>
|
||||
): boolean {
|
||||
if (isCompoundPredicate(node)) {
|
||||
const results = node.conditions.map((child) =>
|
||||
evaluatePredicate(child, context)
|
||||
)
|
||||
return node.logic === 'and'
|
||||
? results.every(Boolean)
|
||||
: results.some(Boolean)
|
||||
}
|
||||
|
||||
if (isLeafPredicate(node)) {
|
||||
return evaluateLeaf(node, context)
|
||||
}
|
||||
|
||||
// Unknown node type, fail closed
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Build Project Context ──────────────────────────────────────────────────
|
||||
|
||||
async function buildProjectContext(
|
||||
projectId: string,
|
||||
currentStageId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<Record<string, unknown>> {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
files: { select: { fileType: true, fileName: true } },
|
||||
projectTags: { include: { tag: true } },
|
||||
filteringResults: {
|
||||
where: { stageId: currentStageId },
|
||||
take: 1,
|
||||
orderBy: { createdAt: 'desc' as const },
|
||||
},
|
||||
assignments: {
|
||||
where: { stageId: currentStageId },
|
||||
include: { evaluation: true },
|
||||
},
|
||||
projectStageStates: {
|
||||
where: { stageId: currentStageId, exitedAt: null },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) return {}
|
||||
|
||||
const evaluations = project.assignments
|
||||
.map((a: any) => a.evaluation)
|
||||
.filter(Boolean)
|
||||
const submittedEvals = evaluations.filter(
|
||||
(e: any) => e.status === 'SUBMITTED'
|
||||
)
|
||||
const avgScore =
|
||||
submittedEvals.length > 0
|
||||
? submittedEvals.reduce(
|
||||
(sum: number, e: any) => sum + (e.globalScore ?? 0),
|
||||
0
|
||||
) / submittedEvals.length
|
||||
: 0
|
||||
|
||||
const filteringResult = project.filteringResults[0] ?? null
|
||||
const currentPSS = project.projectStageStates[0] ?? null
|
||||
|
||||
return {
|
||||
project: {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
status: project.status,
|
||||
country: project.country,
|
||||
competitionCategory: project.competitionCategory,
|
||||
oceanIssue: project.oceanIssue,
|
||||
tags: project.tags,
|
||||
wantsMentorship: project.wantsMentorship,
|
||||
fileCount: project.files.length,
|
||||
},
|
||||
tags: project.projectTags.map((pt: any) => pt.tag.name),
|
||||
evaluation: {
|
||||
count: evaluations.length,
|
||||
submittedCount: submittedEvals.length,
|
||||
averageScore: avgScore,
|
||||
},
|
||||
filtering: {
|
||||
outcome: filteringResult?.outcome ?? null,
|
||||
finalOutcome: filteringResult?.finalOutcome ?? null,
|
||||
},
|
||||
state: currentPSS?.state ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Evaluate Routing Rules ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load active routing rules for a pipeline, evaluate predicates against a
|
||||
* project's context, and return the first matching rule (by priority, lowest first).
|
||||
*/
|
||||
export async function evaluateRoutingRules(
|
||||
projectId: string,
|
||||
currentStageId: string,
|
||||
pipelineId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<MatchedRule | null> {
|
||||
const rules = await prisma.routingRule.findMany({
|
||||
where: {
|
||||
pipelineId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
destinationTrack: true,
|
||||
sourceTrack: true,
|
||||
},
|
||||
orderBy: { priority: 'asc' as const },
|
||||
})
|
||||
|
||||
if (rules.length === 0) return null
|
||||
|
||||
const context = await buildProjectContext(projectId, currentStageId, prisma)
|
||||
|
||||
for (const rule of rules) {
|
||||
// If rule has a sourceTrackId, check that the project is in that track
|
||||
if (rule.sourceTrackId) {
|
||||
const inSourceTrack = await prisma.projectStageState.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
trackId: rule.sourceTrackId,
|
||||
exitedAt: null,
|
||||
},
|
||||
})
|
||||
if (!inSourceTrack) continue
|
||||
}
|
||||
|
||||
const predicateJson = rule.predicateJson as unknown as PredicateNode
|
||||
if (evaluatePredicate(predicateJson, context)) {
|
||||
return {
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
destinationTrackId: rule.destinationTrackId,
|
||||
destinationStageId: rule.destinationStageId ?? null,
|
||||
routingMode: rule.destinationTrack.routingMode ?? 'EXCLUSIVE',
|
||||
priority: rule.priority,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Execute Routing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a routing decision for a project based on the matched rule.
|
||||
*
|
||||
* PARALLEL mode: Keep the project in its current track, add a new PSS in the
|
||||
* destination track's first stage (or specified destination stage).
|
||||
* EXCLUSIVE mode: Exit the current PSS and create a new PSS in the destination.
|
||||
* POST_MAIN mode: Validate that the project PASSED the main track gate before routing.
|
||||
*/
|
||||
export async function executeRouting(
|
||||
projectId: string,
|
||||
matchedRule: MatchedRule,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<RoutingExecutionResult> {
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx: any) => {
|
||||
const now = new Date()
|
||||
|
||||
// Determine destination stage
|
||||
let destinationStageId = matchedRule.destinationStageId
|
||||
if (!destinationStageId) {
|
||||
// Find the first stage in the destination track (by sortOrder)
|
||||
const firstStage = await tx.stage.findFirst({
|
||||
where: { trackId: matchedRule.destinationTrackId },
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
})
|
||||
if (!firstStage) {
|
||||
throw new Error(
|
||||
`No stages found in destination track ${matchedRule.destinationTrackId}`
|
||||
)
|
||||
}
|
||||
destinationStageId = firstStage.id
|
||||
}
|
||||
|
||||
// Mode-specific logic
|
||||
if (matchedRule.routingMode === 'POST_MAIN') {
|
||||
// Validate that the project has passed the main track gate
|
||||
const mainTrack = await tx.track.findFirst({
|
||||
where: {
|
||||
pipeline: {
|
||||
tracks: {
|
||||
some: { id: matchedRule.destinationTrackId },
|
||||
},
|
||||
},
|
||||
kind: 'MAIN',
|
||||
},
|
||||
})
|
||||
|
||||
if (mainTrack) {
|
||||
const mainPSS = await tx.projectStageState.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
trackId: mainTrack.id,
|
||||
state: { in: ['PASSED', 'COMPLETED'] },
|
||||
},
|
||||
orderBy: { exitedAt: 'desc' as const },
|
||||
})
|
||||
|
||||
if (!mainPSS) {
|
||||
throw new Error(
|
||||
'POST_MAIN routing requires the project to have passed the main track gate'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedRule.routingMode === 'EXCLUSIVE') {
|
||||
// Exit all active PSS for this project in any track of the same pipeline
|
||||
const activePSSRecords = await tx.projectStageState.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
exitedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
for (const pss of activePSSRecords) {
|
||||
await tx.projectStageState.update({
|
||||
where: { id: pss.id },
|
||||
data: {
|
||||
exitedAt: now,
|
||||
state: 'ROUTED',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create PSS in destination track/stage
|
||||
const destPSS = await tx.projectStageState.upsert({
|
||||
where: {
|
||||
projectId_trackId_stageId: {
|
||||
projectId,
|
||||
trackId: matchedRule.destinationTrackId,
|
||||
stageId: destinationStageId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
trackId: matchedRule.destinationTrackId,
|
||||
stageId: destinationStageId,
|
||||
state: 'PENDING',
|
||||
enteredAt: now,
|
||||
},
|
||||
update: {
|
||||
state: 'PENDING',
|
||||
enteredAt: now,
|
||||
exitedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
// Log DecisionAuditLog
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'routing.executed',
|
||||
entityType: 'ProjectStageState',
|
||||
entityId: destPSS.id,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
projectId,
|
||||
ruleId: matchedRule.ruleId,
|
||||
ruleName: matchedRule.ruleName,
|
||||
routingMode: matchedRule.routingMode,
|
||||
destinationTrackId: matchedRule.destinationTrackId,
|
||||
destinationStageId,
|
||||
},
|
||||
snapshotJson: {
|
||||
destPSSId: destPSS.id,
|
||||
timestamp: now.toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// AuditLog
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: actorId,
|
||||
action: 'ROUTING_EXECUTED',
|
||||
entityType: 'RoutingRule',
|
||||
entityId: matchedRule.ruleId,
|
||||
detailsJson: {
|
||||
projectId,
|
||||
routingMode: matchedRule.routingMode,
|
||||
destinationTrackId: matchedRule.destinationTrackId,
|
||||
destinationStageId,
|
||||
},
|
||||
})
|
||||
|
||||
return destPSS
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
projectStageStateId: result.id,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RoutingEngine] Routing execution failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
projectStageStateId: null,
|
||||
errors: [
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error during routing execution',
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Preview Routing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dry-run evaluation of routing rules for a batch of projects.
|
||||
* Does not modify any data.
|
||||
*/
|
||||
export async function previewRouting(
|
||||
projectIds: string[],
|
||||
pipelineId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<RoutingPreviewItem[]> {
|
||||
const preview: RoutingPreviewItem[] = []
|
||||
|
||||
// Load projects with their current stage states
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { id: { in: projectIds } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
projectStageStates: {
|
||||
where: { exitedAt: null },
|
||||
select: { stageId: true, trackId: true, state: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for (const project of projects) {
|
||||
const activePSS = project.projectStageStates[0]
|
||||
|
||||
if (!activePSS) {
|
||||
preview.push({
|
||||
projectId: project.id,
|
||||
projectTitle: project.title,
|
||||
matchedRule: null,
|
||||
reason: 'No active stage state found',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const matchedRule = await evaluateRoutingRules(
|
||||
project.id,
|
||||
activePSS.stageId,
|
||||
pipelineId,
|
||||
prisma
|
||||
)
|
||||
|
||||
preview.push({
|
||||
projectId: project.id,
|
||||
projectTitle: project.title,
|
||||
matchedRule,
|
||||
reason: matchedRule
|
||||
? `Matched rule "${matchedRule.ruleName}" (priority ${matchedRule.priority})`
|
||||
: 'No routing rules matched',
|
||||
})
|
||||
}
|
||||
|
||||
return preview
|
||||
}
|
||||
@@ -273,17 +273,22 @@ export function calculateAvailabilityPenalty(
|
||||
* Get smart assignment suggestions for a round
|
||||
*/
|
||||
export async function getSmartSuggestions(options: {
|
||||
roundId: string
|
||||
stageId: string
|
||||
type: 'jury' | 'mentor'
|
||||
limit?: number
|
||||
aiMaxPerJudge?: number
|
||||
}): Promise<AssignmentScore[]> {
|
||||
const { roundId, type, limit = 50, aiMaxPerJudge = 20 } = options
|
||||
const { stageId, type, limit = 50, aiMaxPerJudge = 20 } = options
|
||||
|
||||
const projectStageStates = await prisma.projectStageState.findMany({
|
||||
where: { stageId },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const projectIds = projectStageStates.map((pss) => pss.projectId)
|
||||
|
||||
// Get projects in round with their tags and description
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
roundId,
|
||||
id: { in: projectIds },
|
||||
status: { not: 'REJECTED' },
|
||||
},
|
||||
select: {
|
||||
@@ -303,7 +308,6 @@ export async function getSmartSuggestions(options: {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get users of the appropriate role with bio for matching
|
||||
const role = type === 'jury' ? 'JURY_MEMBER' : 'MENTOR'
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
@@ -323,7 +327,7 @@ export async function getSmartSuggestions(options: {
|
||||
_count: {
|
||||
select: {
|
||||
assignments: {
|
||||
where: { roundId },
|
||||
where: { stageId },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -334,26 +338,21 @@ export async function getSmartSuggestions(options: {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get round voting window for availability checking
|
||||
const roundForAvailability = await prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
select: { votingStartAt: true, votingEndAt: true },
|
||||
const stageForAvailability = await prisma.stage.findUnique({
|
||||
where: { id: stageId },
|
||||
select: { windowOpenAt: true, windowCloseAt: true },
|
||||
})
|
||||
|
||||
// Get existing assignments to avoid duplicates
|
||||
const existingAssignments = await prisma.assignment.findMany({
|
||||
where: { roundId },
|
||||
where: { stageId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const assignedPairs = new Set(
|
||||
existingAssignments.map((a) => `${a.userId}:${a.projectId}`)
|
||||
)
|
||||
|
||||
// ── Batch-query data for new scoring factors ──────────────────────────────
|
||||
|
||||
// 1. Geographic diversity: per-juror country distribution for existing assignments
|
||||
const assignmentsWithCountry = await prisma.assignment.findMany({
|
||||
where: { roundId },
|
||||
where: { stageId },
|
||||
select: {
|
||||
userId: true,
|
||||
project: { select: { country: true } },
|
||||
@@ -373,32 +372,38 @@ export async function getSmartSuggestions(options: {
|
||||
countryMap.set(country, (countryMap.get(country) || 0) + 1)
|
||||
}
|
||||
|
||||
// 2. Previous round familiarity: find assignments in earlier rounds of the same program
|
||||
const currentRound = await prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
select: { programId: true, sortOrder: true },
|
||||
const currentStage = await prisma.stage.findUnique({
|
||||
where: { id: stageId },
|
||||
select: { trackId: true, sortOrder: true },
|
||||
})
|
||||
|
||||
const previousRoundAssignmentPairs = new Set<string>()
|
||||
if (currentRound) {
|
||||
const previousAssignments = await prisma.assignment.findMany({
|
||||
const previousStageAssignmentPairs = new Set<string>()
|
||||
if (currentStage) {
|
||||
const earlierStages = await prisma.stage.findMany({
|
||||
where: {
|
||||
round: {
|
||||
programId: currentRound.programId,
|
||||
sortOrder: { lt: currentRound.sortOrder },
|
||||
},
|
||||
trackId: currentStage.trackId,
|
||||
sortOrder: { lt: currentStage.sortOrder },
|
||||
},
|
||||
select: { userId: true, projectId: true },
|
||||
select: { id: true },
|
||||
})
|
||||
for (const pa of previousAssignments) {
|
||||
previousRoundAssignmentPairs.add(`${pa.userId}:${pa.projectId}`)
|
||||
const earlierStageIds = earlierStages.map((s) => s.id)
|
||||
|
||||
if (earlierStageIds.length > 0) {
|
||||
const previousAssignments = await prisma.assignment.findMany({
|
||||
where: {
|
||||
stageId: { in: earlierStageIds },
|
||||
},
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
for (const pa of previousAssignments) {
|
||||
previousStageAssignmentPairs.add(`${pa.userId}:${pa.projectId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. COI declarations: all active conflicts for this round
|
||||
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||
where: {
|
||||
roundId,
|
||||
assignment: { stageId },
|
||||
hasConflict: true,
|
||||
},
|
||||
select: { userId: true, projectId: true },
|
||||
@@ -464,11 +469,10 @@ export async function getSmartSuggestions(options: {
|
||||
? calculateCountryMatchScore(user.country, project.country)
|
||||
: 0
|
||||
|
||||
// Availability check against the round's voting window
|
||||
const availabilityPenalty = calculateAvailabilityPenalty(
|
||||
user.availabilityJson,
|
||||
roundForAvailability?.votingStartAt,
|
||||
roundForAvailability?.votingEndAt
|
||||
stageForAvailability?.windowOpenAt,
|
||||
stageForAvailability?.windowCloseAt
|
||||
)
|
||||
|
||||
// ── New scoring factors ─────────────────────────────────────────────
|
||||
@@ -486,9 +490,8 @@ export async function getSmartSuggestions(options: {
|
||||
}
|
||||
}
|
||||
|
||||
// Previous round familiarity bonus
|
||||
let previousRoundFamiliarity = 0
|
||||
if (previousRoundAssignmentPairs.has(pairKey)) {
|
||||
if (previousStageAssignmentPairs.has(pairKey)) {
|
||||
previousRoundFamiliarity = PREVIOUS_ROUND_FAMILIARITY_BONUS
|
||||
}
|
||||
|
||||
|
||||
770
src/server/services/stage-assignment.ts
Normal file
770
src/server/services/stage-assignment.ts
Normal file
@@ -0,0 +1,770 @@
|
||||
/**
|
||||
* Stage Assignment Service
|
||||
*
|
||||
* Manages jury-to-project assignments scoped to a specific pipeline stage.
|
||||
* Provides preview (dry run), execution, coverage reporting, and rebalancing.
|
||||
*
|
||||
* Uses the smart-assignment scoring algorithm for matching quality but adds
|
||||
* stage-awareness and integrates with the pipeline models.
|
||||
*/
|
||||
|
||||
import type { PrismaClient, AssignmentMethod, Prisma } from '@prisma/client'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AssignmentConfig {
|
||||
requiredReviews: number
|
||||
minAssignmentsPerJuror: number
|
||||
maxAssignmentsPerJuror: number
|
||||
respectCOI: boolean
|
||||
geoBalancing: boolean
|
||||
expertiseMatching: boolean
|
||||
}
|
||||
|
||||
export interface PreviewAssignment {
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
projectId: string
|
||||
projectTitle: string
|
||||
score: number
|
||||
reasoning: string[]
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
assignments: PreviewAssignment[]
|
||||
unassignedProjects: Array<{ id: string; title: string; reason: string }>
|
||||
stats: {
|
||||
totalProjects: number
|
||||
totalJurors: number
|
||||
avgAssignmentsPerJuror: number
|
||||
coveragePercent: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface AssignmentInput {
|
||||
userId: string
|
||||
projectId: string
|
||||
method?: AssignmentMethod
|
||||
}
|
||||
|
||||
export interface CoverageReport {
|
||||
stageId: string
|
||||
totalProjects: number
|
||||
fullyAssigned: number
|
||||
partiallyAssigned: number
|
||||
unassigned: number
|
||||
coveragePercent: number
|
||||
averageReviewsPerProject: number
|
||||
jurorStats: Array<{
|
||||
userId: string
|
||||
userName: string
|
||||
assignmentCount: number
|
||||
completedCount: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface RebalanceSuggestion {
|
||||
action: 'reassign' | 'add'
|
||||
fromUserId?: string
|
||||
fromUserName?: string
|
||||
toUserId: string
|
||||
toUserName: string
|
||||
projectId: string
|
||||
projectTitle: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_CONFIG: AssignmentConfig = {
|
||||
requiredReviews: 3,
|
||||
minAssignmentsPerJuror: 5,
|
||||
maxAssignmentsPerJuror: 20,
|
||||
respectCOI: true,
|
||||
geoBalancing: true,
|
||||
expertiseMatching: true,
|
||||
}
|
||||
|
||||
// ─── Scoring Utilities ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate a simple tag overlap score between a juror's expertise tags
|
||||
* and a project's tags.
|
||||
*/
|
||||
function calculateTagOverlapScore(
|
||||
jurorTags: string[],
|
||||
projectTags: string[]
|
||||
): number {
|
||||
if (jurorTags.length === 0 || projectTags.length === 0) return 0
|
||||
|
||||
const jurorSet = new Set(jurorTags.map((t) => t.toLowerCase()))
|
||||
const matchCount = projectTags.filter((t) =>
|
||||
jurorSet.has(t.toLowerCase())
|
||||
).length
|
||||
|
||||
return Math.min(matchCount * 10, 40) // Max 40 points
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate workload balance score. Jurors closer to their preferred workload
|
||||
* get higher scores, those near max get penalized.
|
||||
*/
|
||||
function calculateWorkloadScore(
|
||||
currentLoad: number,
|
||||
preferredWorkload: number | null,
|
||||
maxAssignments: number
|
||||
): number {
|
||||
const target = preferredWorkload ?? Math.floor(maxAssignments * 0.6)
|
||||
const remaining = target - currentLoad
|
||||
|
||||
if (remaining <= 0) return 0
|
||||
if (currentLoad === 0) return 25 // Full score for unloaded jurors
|
||||
|
||||
const ratio = remaining / target
|
||||
return Math.round(ratio * 25) // Max 25 points
|
||||
}
|
||||
|
||||
// ─── Preview Stage Assignment ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a preview of assignments for a stage without persisting anything.
|
||||
* Loads eligible projects (those with active PSS in the stage) and the jury
|
||||
* pool, then matches them using scoring constraints.
|
||||
*/
|
||||
export async function previewStageAssignment(
|
||||
stageId: string,
|
||||
config: Partial<AssignmentConfig>,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<PreviewResult> {
|
||||
const cfg = { ...DEFAULT_CONFIG, ...config }
|
||||
|
||||
// Load stage with track/pipeline info
|
||||
const stage = await prisma.stage.findUnique({
|
||||
where: { id: stageId },
|
||||
include: {
|
||||
track: { include: { pipeline: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!stage) {
|
||||
throw new Error(`Stage ${stageId} not found`)
|
||||
}
|
||||
|
||||
// Load eligible projects: active PSS in stage, not exited
|
||||
const projectStates = await prisma.projectStageState.findMany({
|
||||
where: {
|
||||
stageId,
|
||||
exitedAt: null,
|
||||
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
projectTags: { include: { tag: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const projects = projectStates
|
||||
.map((pss: any) => pss.project)
|
||||
.filter(Boolean)
|
||||
|
||||
// Load jury pool: active jury members
|
||||
const jurors = await prisma.user.findMany({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
expertiseTags: true,
|
||||
preferredWorkload: true,
|
||||
maxAssignments: true,
|
||||
country: true,
|
||||
_count: {
|
||||
select: {
|
||||
assignments: {
|
||||
where: { stageId },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Load existing assignments for this stage to avoid duplicates
|
||||
const existingAssignments = await prisma.assignment.findMany({
|
||||
where: { stageId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
|
||||
const existingPairs = new Set(
|
||||
existingAssignments.map((a: any) => `${a.userId}:${a.projectId}`)
|
||||
)
|
||||
|
||||
// Load COI data if enabled
|
||||
let coiPairs = new Set<string>()
|
||||
if (cfg.respectCOI) {
|
||||
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||
where: { hasConflict: true },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
coiPairs = new Set(coiRecords.map((c: any) => `${c.userId}:${c.projectId}`))
|
||||
}
|
||||
|
||||
// Score and generate assignments
|
||||
const assignments: PreviewAssignment[] = []
|
||||
const projectCoverage = new Map<string, number>()
|
||||
const jurorLoad = new Map<string, number>()
|
||||
|
||||
// Initialize counts
|
||||
for (const project of projects) {
|
||||
projectCoverage.set(project.id, 0)
|
||||
}
|
||||
for (const juror of jurors) {
|
||||
jurorLoad.set(juror.id, juror._count.assignments)
|
||||
}
|
||||
|
||||
// For each project, find best available jurors
|
||||
for (const project of projects) {
|
||||
const projectTags = project.projectTags.map((pt: any) => pt.tag.name)
|
||||
|
||||
// Score each juror for this project
|
||||
const candidates = jurors
|
||||
.filter((juror: any) => {
|
||||
const pairKey = `${juror.id}:${project.id}`
|
||||
// Skip if already assigned
|
||||
if (existingPairs.has(pairKey)) return false
|
||||
// Skip if COI
|
||||
if (coiPairs.has(pairKey)) return false
|
||||
// Skip if at max capacity
|
||||
const currentLoad = jurorLoad.get(juror.id) ?? 0
|
||||
if (currentLoad >= (juror.maxAssignments ?? cfg.maxAssignmentsPerJuror)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map((juror: any) => {
|
||||
const currentLoad = jurorLoad.get(juror.id) ?? 0
|
||||
const tagScore = cfg.expertiseMatching
|
||||
? calculateTagOverlapScore(juror.expertiseTags, projectTags)
|
||||
: 0
|
||||
const workloadScore = calculateWorkloadScore(
|
||||
currentLoad,
|
||||
juror.preferredWorkload,
|
||||
juror.maxAssignments ?? cfg.maxAssignmentsPerJuror
|
||||
)
|
||||
|
||||
// Geo balancing: slight penalty if same country
|
||||
let geoScore = 0
|
||||
if (cfg.geoBalancing && juror.country && project.country) {
|
||||
geoScore = juror.country === project.country ? -5 : 5
|
||||
}
|
||||
|
||||
const totalScore = tagScore + workloadScore + geoScore
|
||||
const reasoning: string[] = []
|
||||
if (tagScore > 0) reasoning.push(`Tag match: +${tagScore}`)
|
||||
if (workloadScore > 0) reasoning.push(`Workload balance: +${workloadScore}`)
|
||||
if (geoScore !== 0) reasoning.push(`Geo balance: ${geoScore > 0 ? '+' : ''}${geoScore}`)
|
||||
|
||||
return {
|
||||
userId: juror.id,
|
||||
userName: juror.name ?? juror.email,
|
||||
userEmail: juror.email,
|
||||
score: totalScore,
|
||||
reasoning,
|
||||
}
|
||||
})
|
||||
.sort((a: any, b: any) => b.score - a.score)
|
||||
|
||||
// Assign top N jurors to this project
|
||||
const needed = cfg.requiredReviews - (projectCoverage.get(project.id) ?? 0)
|
||||
const selected = candidates.slice(0, needed)
|
||||
|
||||
for (const candidate of selected) {
|
||||
assignments.push({
|
||||
...candidate,
|
||||
projectId: project.id,
|
||||
projectTitle: project.title,
|
||||
})
|
||||
|
||||
existingPairs.add(`${candidate.userId}:${project.id}`)
|
||||
projectCoverage.set(
|
||||
project.id,
|
||||
(projectCoverage.get(project.id) ?? 0) + 1
|
||||
)
|
||||
jurorLoad.set(candidate.userId, (jurorLoad.get(candidate.userId) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Identify unassigned projects
|
||||
const unassignedProjects = projects
|
||||
.filter(
|
||||
(p: any) =>
|
||||
(projectCoverage.get(p.id) ?? 0) < cfg.requiredReviews
|
||||
)
|
||||
.map((p: any) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
reason: `Only ${projectCoverage.get(p.id) ?? 0}/${cfg.requiredReviews} reviewers assigned`,
|
||||
}))
|
||||
|
||||
// Stats
|
||||
const jurorAssignmentCounts = Array.from(jurorLoad.values())
|
||||
const avgPerJuror =
|
||||
jurorAssignmentCounts.length > 0
|
||||
? jurorAssignmentCounts.reduce((a, b) => a + b, 0) /
|
||||
jurorAssignmentCounts.length
|
||||
: 0
|
||||
|
||||
const fullyAssigned = projects.filter(
|
||||
(p: any) => (projectCoverage.get(p.id) ?? 0) >= cfg.requiredReviews
|
||||
).length
|
||||
|
||||
return {
|
||||
assignments,
|
||||
unassignedProjects,
|
||||
stats: {
|
||||
totalProjects: projects.length,
|
||||
totalJurors: jurors.length,
|
||||
avgAssignmentsPerJuror: Math.round(avgPerJuror * 100) / 100,
|
||||
coveragePercent:
|
||||
projects.length > 0
|
||||
? Math.round((fullyAssigned / projects.length) * 100)
|
||||
: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Execute Stage Assignment ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create Assignment records for a stage. Accepts a list of user/project pairs
|
||||
* and persists them atomically, also creating an AssignmentJob for tracking.
|
||||
*/
|
||||
export async function executeStageAssignment(
|
||||
stageId: string,
|
||||
assignments: AssignmentInput[],
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<{ jobId: string; created: number; errors: string[] }> {
|
||||
const stage = await prisma.stage.findUnique({
|
||||
where: { id: stageId },
|
||||
include: {
|
||||
track: { include: { pipeline: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!stage) {
|
||||
throw new Error(`Stage ${stageId} not found`)
|
||||
}
|
||||
|
||||
const created: string[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
// Create AssignmentJob for tracking
|
||||
const job = await prisma.assignmentJob.create({
|
||||
data: {
|
||||
stageId,
|
||||
status: 'RUNNING',
|
||||
totalProjects: assignments.length,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx: any) => {
|
||||
for (const assignment of assignments) {
|
||||
try {
|
||||
// Check for existing assignment
|
||||
const existing = await tx.assignment.findUnique({
|
||||
where: {
|
||||
userId_projectId_stageId: {
|
||||
userId: assignment.userId,
|
||||
projectId: assignment.projectId,
|
||||
stageId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
errors.push(
|
||||
`Assignment already exists for user ${assignment.userId} / project ${assignment.projectId}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
await tx.assignment.create({
|
||||
data: {
|
||||
userId: assignment.userId,
|
||||
projectId: assignment.projectId,
|
||||
stageId,
|
||||
method: assignment.method ?? 'ALGORITHM',
|
||||
createdBy: actorId,
|
||||
},
|
||||
})
|
||||
|
||||
created.push(`${assignment.userId}:${assignment.projectId}`)
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`Failed to create assignment for user ${assignment.userId} / project ${assignment.projectId}: ${err instanceof Error ? err.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Complete the job
|
||||
await prisma.assignmentJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
processedCount: created.length + errors.length,
|
||||
suggestionsCount: created.length,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Decision audit log
|
||||
await prisma.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'assignment.generated',
|
||||
entityType: 'AssignmentJob',
|
||||
entityId: job.id,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
stageId,
|
||||
assignmentCount: created.length,
|
||||
errorCount: errors.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: actorId,
|
||||
action: 'STAGE_ASSIGNMENT_EXECUTED',
|
||||
entityType: 'AssignmentJob',
|
||||
entityId: job.id,
|
||||
detailsJson: {
|
||||
stageId,
|
||||
created: created.length,
|
||||
errors: errors.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
await prisma.assignmentJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
errorMessage:
|
||||
error instanceof Error ? error.message : 'Transaction failed',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
created: created.length,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Coverage Report ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a coverage report for assignments in a stage: how many projects
|
||||
* are fully covered, partially covered, unassigned, plus per-juror stats.
|
||||
*/
|
||||
export async function getCoverageReport(
|
||||
stageId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<CoverageReport> {
|
||||
// Load stage config for required reviews
|
||||
const stage = await prisma.stage.findUnique({
|
||||
where: { id: stageId },
|
||||
})
|
||||
|
||||
const stageConfig = (stage?.configJson as Record<string, unknown>) ?? {}
|
||||
const requiredReviews = (stageConfig.requiredReviews as number) ?? 3
|
||||
|
||||
// Load projects in this stage
|
||||
const projectStates = await prisma.projectStageState.findMany({
|
||||
where: {
|
||||
stageId,
|
||||
exitedAt: null,
|
||||
},
|
||||
select: { projectId: true },
|
||||
})
|
||||
|
||||
const projectIds = projectStates.map((pss: any) => pss.projectId)
|
||||
|
||||
// Load assignments for this stage
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: {
|
||||
stageId,
|
||||
projectId: { in: projectIds },
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
projectId: true,
|
||||
isCompleted: true,
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Calculate per-project coverage
|
||||
const projectReviewCounts = new Map<string, number>()
|
||||
for (const assignment of assignments) {
|
||||
const current = projectReviewCounts.get(assignment.projectId) ?? 0
|
||||
projectReviewCounts.set(assignment.projectId, current + 1)
|
||||
}
|
||||
|
||||
let fullyAssigned = 0
|
||||
let partiallyAssigned = 0
|
||||
let unassigned = 0
|
||||
|
||||
for (const projectId of projectIds) {
|
||||
const count = projectReviewCounts.get(projectId) ?? 0
|
||||
if (count >= requiredReviews) {
|
||||
fullyAssigned++
|
||||
} else if (count > 0) {
|
||||
partiallyAssigned++
|
||||
} else {
|
||||
unassigned++
|
||||
}
|
||||
}
|
||||
|
||||
const totalReviewCount = Array.from(projectReviewCounts.values()).reduce(
|
||||
(a, b) => a + b,
|
||||
0
|
||||
)
|
||||
|
||||
// Per-juror stats
|
||||
const jurorMap = new Map<
|
||||
string,
|
||||
{ userId: string; userName: string; total: number; completed: number }
|
||||
>()
|
||||
for (const a of assignments) {
|
||||
const key = a.userId
|
||||
const existing = jurorMap.get(key) ?? {
|
||||
userId: a.userId,
|
||||
userName: a.user?.name ?? a.user?.email ?? 'Unknown',
|
||||
total: 0,
|
||||
completed: 0,
|
||||
}
|
||||
existing.total++
|
||||
if (a.isCompleted) existing.completed++
|
||||
jurorMap.set(key, existing)
|
||||
}
|
||||
|
||||
return {
|
||||
stageId,
|
||||
totalProjects: projectIds.length,
|
||||
fullyAssigned,
|
||||
partiallyAssigned,
|
||||
unassigned,
|
||||
coveragePercent:
|
||||
projectIds.length > 0
|
||||
? Math.round((fullyAssigned / projectIds.length) * 100)
|
||||
: 0,
|
||||
averageReviewsPerProject:
|
||||
projectIds.length > 0
|
||||
? Math.round((totalReviewCount / projectIds.length) * 100) / 100
|
||||
: 0,
|
||||
jurorStats: Array.from(jurorMap.values()).map((j) => ({
|
||||
userId: j.userId,
|
||||
userName: j.userName,
|
||||
assignmentCount: j.total,
|
||||
completedCount: j.completed,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rebalance ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Analyze assignment distribution for a stage and suggest reassignments
|
||||
* to balance workload. Identifies overloaded jurors (above max) and
|
||||
* underloaded jurors (below min) and suggests moves.
|
||||
*/
|
||||
export async function rebalance(
|
||||
stageId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<RebalanceSuggestion[]> {
|
||||
const stage = await prisma.stage.findUnique({
|
||||
where: { id: stageId },
|
||||
})
|
||||
|
||||
const stageConfig = (stage?.configJson as Record<string, unknown>) ?? {}
|
||||
const minLoad = (stageConfig.minAssignmentsPerJuror as number) ?? 5
|
||||
const maxLoad = (stageConfig.maxAssignmentsPerJuror as number) ?? 20
|
||||
const requiredReviews = (stageConfig.requiredReviews as number) ?? 3
|
||||
|
||||
// Load all assignments for this stage
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: { stageId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
project: { select: { id: true, title: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Calculate per-juror counts
|
||||
const jurorCounts = new Map<
|
||||
string,
|
||||
{
|
||||
userId: string
|
||||
userName: string
|
||||
count: number
|
||||
incompleteAssignments: Array<{ assignmentId: string; projectId: string; projectTitle: string }>
|
||||
}
|
||||
>()
|
||||
|
||||
for (const a of assignments) {
|
||||
const existing = jurorCounts.get(a.userId) ?? {
|
||||
userId: a.userId,
|
||||
userName: a.user?.name ?? a.user?.email ?? 'Unknown',
|
||||
count: 0,
|
||||
incompleteAssignments: [] as Array<{ assignmentId: string; projectId: string; projectTitle: string }>,
|
||||
}
|
||||
existing.count++
|
||||
if (!a.isCompleted) {
|
||||
existing.incompleteAssignments.push({
|
||||
assignmentId: a.id,
|
||||
projectId: a.projectId,
|
||||
projectTitle: a.project?.title ?? 'Unknown',
|
||||
})
|
||||
}
|
||||
jurorCounts.set(a.userId, existing)
|
||||
}
|
||||
|
||||
// Calculate per-project counts
|
||||
const projectCounts = new Map<string, number>()
|
||||
for (const a of assignments) {
|
||||
projectCounts.set(a.projectId, (projectCounts.get(a.projectId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const overloaded = Array.from(jurorCounts.values()).filter(
|
||||
(j) => j.count > maxLoad
|
||||
)
|
||||
const underloaded = Array.from(jurorCounts.values()).filter(
|
||||
(j) => j.count < minLoad
|
||||
)
|
||||
|
||||
const suggestions: RebalanceSuggestion[] = []
|
||||
|
||||
// Load COI data to avoid suggesting COI reassignments
|
||||
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||
where: { hasConflict: true },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const coiPairs = new Set(
|
||||
coiRecords.map((c: any) => `${c.userId}:${c.projectId}`)
|
||||
)
|
||||
|
||||
// Existing assignment pairs
|
||||
const existingPairs = new Set(
|
||||
assignments.map((a: any) => `${a.userId}:${a.projectId}`)
|
||||
)
|
||||
|
||||
// For each overloaded juror, try to move incomplete assignments to underloaded jurors
|
||||
for (const over of overloaded) {
|
||||
const excess = over.count - maxLoad
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < Math.min(excess, over.incompleteAssignments.length);
|
||||
i++
|
||||
) {
|
||||
const candidate = over.incompleteAssignments[i]
|
||||
|
||||
// Find an underloaded juror who can take this project
|
||||
const target = underloaded.find((under) => {
|
||||
const pairKey = `${under.userId}:${candidate.projectId}`
|
||||
// No COI, not already assigned, still under max
|
||||
return (
|
||||
!coiPairs.has(pairKey) &&
|
||||
!existingPairs.has(pairKey) &&
|
||||
under.count < maxLoad
|
||||
)
|
||||
})
|
||||
|
||||
if (target) {
|
||||
suggestions.push({
|
||||
action: 'reassign',
|
||||
fromUserId: over.userId,
|
||||
fromUserName: over.userName,
|
||||
toUserId: target.userId,
|
||||
toUserName: target.userName,
|
||||
projectId: candidate.projectId,
|
||||
projectTitle: candidate.projectTitle,
|
||||
reason: `${over.userName} is overloaded (${over.count}/${maxLoad}), ${target.userName} is underloaded (${target.count}/${minLoad})`,
|
||||
})
|
||||
|
||||
// Update in-memory counts for subsequent iterations
|
||||
over.count--
|
||||
target.count++
|
||||
existingPairs.add(`${target.userId}:${candidate.projectId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For projects below required reviews, suggest adding reviewers
|
||||
for (const [projectId, count] of projectCounts) {
|
||||
if (count < requiredReviews) {
|
||||
const needed = requiredReviews - count
|
||||
const projectAssignment = assignments.find(
|
||||
(a: any) => a.projectId === projectId
|
||||
)
|
||||
|
||||
for (let i = 0; i < needed; i++) {
|
||||
const target = underloaded.find((under) => {
|
||||
const pairKey = `${under.userId}:${projectId}`
|
||||
return (
|
||||
!coiPairs.has(pairKey) &&
|
||||
!existingPairs.has(pairKey) &&
|
||||
under.count < maxLoad
|
||||
)
|
||||
})
|
||||
|
||||
if (target) {
|
||||
suggestions.push({
|
||||
action: 'add',
|
||||
toUserId: target.userId,
|
||||
toUserName: target.userName,
|
||||
projectId,
|
||||
projectTitle: projectAssignment?.project?.title ?? 'Unknown',
|
||||
reason: `Project needs ${needed} more reviewer(s) to reach ${requiredReviews} required`,
|
||||
})
|
||||
|
||||
target.count++
|
||||
existingPairs.add(`${target.userId}:${projectId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit the rebalance analysis
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: actorId,
|
||||
action: 'STAGE_ASSIGNMENT_REBALANCE',
|
||||
entityType: 'Stage',
|
||||
entityId: stageId,
|
||||
detailsJson: {
|
||||
overloadedJurors: overloaded.length,
|
||||
underloadedJurors: underloaded.length,
|
||||
suggestionsCount: suggestions.length,
|
||||
},
|
||||
})
|
||||
|
||||
return suggestions
|
||||
}
|
||||
464
src/server/services/stage-engine.ts
Normal file
464
src/server/services/stage-engine.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* Stage Engine Service
|
||||
*
|
||||
* State machine service for managing project transitions between stages in
|
||||
* the pipeline. Handles validation of transitions (guard evaluation, window
|
||||
* constraints, PSS existence) and atomic execution with full audit logging.
|
||||
*
|
||||
* Key invariants:
|
||||
* - A project can only be in one active PSS per track/stage combination
|
||||
* - Transitions must follow defined StageTransition records
|
||||
* - Guard conditions (guardJson) on transitions are evaluated before execution
|
||||
* - All transitions are logged in DecisionAuditLog and AuditLog
|
||||
*/
|
||||
|
||||
import type { PrismaClient, ProjectStageStateValue, Prisma } from '@prisma/client'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TransitionValidationResult {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface TransitionExecutionResult {
|
||||
success: boolean
|
||||
projectStageState: {
|
||||
id: string
|
||||
projectId: string
|
||||
trackId: string
|
||||
stageId: string
|
||||
state: ProjectStageStateValue
|
||||
} | null
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
export interface BatchTransitionResult {
|
||||
succeeded: string[]
|
||||
failed: Array<{ projectId: string; errors: string[] }>
|
||||
total: number
|
||||
}
|
||||
|
||||
interface GuardCondition {
|
||||
field: string
|
||||
operator: 'eq' | 'neq' | 'in' | 'contains' | 'gt' | 'lt' | 'exists'
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface GuardConfig {
|
||||
conditions?: GuardCondition[]
|
||||
logic?: 'AND' | 'OR'
|
||||
requireAllEvaluationsComplete?: boolean
|
||||
requireMinScore?: number
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const BATCH_SIZE = 50
|
||||
|
||||
// ─── Guard Evaluation ───────────────────────────────────────────────────────
|
||||
|
||||
function evaluateGuardCondition(
|
||||
condition: GuardCondition,
|
||||
context: Record<string, unknown>
|
||||
): boolean {
|
||||
const fieldValue = context[condition.field]
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'eq':
|
||||
return fieldValue === condition.value
|
||||
case 'neq':
|
||||
return fieldValue !== condition.value
|
||||
case 'in': {
|
||||
if (!Array.isArray(condition.value)) return false
|
||||
return condition.value.includes(fieldValue)
|
||||
}
|
||||
case 'contains': {
|
||||
if (typeof fieldValue === 'string' && typeof condition.value === 'string') {
|
||||
return fieldValue.toLowerCase().includes(condition.value.toLowerCase())
|
||||
}
|
||||
if (Array.isArray(fieldValue)) {
|
||||
return fieldValue.includes(condition.value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
case 'gt':
|
||||
return Number(fieldValue) > Number(condition.value)
|
||||
case 'lt':
|
||||
return Number(fieldValue) < Number(condition.value)
|
||||
case 'exists':
|
||||
return fieldValue !== null && fieldValue !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateGuard(
|
||||
guardJson: Prisma.JsonValue | null | undefined,
|
||||
context: Record<string, unknown>
|
||||
): { passed: boolean; failedConditions: string[] } {
|
||||
if (!guardJson || typeof guardJson !== 'object') {
|
||||
return { passed: true, failedConditions: [] }
|
||||
}
|
||||
|
||||
const guard = guardJson as unknown as GuardConfig
|
||||
const conditions = guard.conditions ?? []
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return { passed: true, failedConditions: [] }
|
||||
}
|
||||
|
||||
const failedConditions: string[] = []
|
||||
const results = conditions.map((condition) => {
|
||||
const result = evaluateGuardCondition(condition, context)
|
||||
if (!result) {
|
||||
failedConditions.push(
|
||||
`Guard failed: ${condition.field} ${condition.operator} ${JSON.stringify(condition.value)}`
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const logic = guard.logic ?? 'AND'
|
||||
const passed = logic === 'AND'
|
||||
? results.every(Boolean)
|
||||
: results.some(Boolean)
|
||||
|
||||
return { passed, failedConditions: passed ? [] : failedConditions }
|
||||
}
|
||||
|
||||
// ─── Validate Transition ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate whether a project can transition from one stage to another.
|
||||
* Checks:
|
||||
* 1. Source PSS exists and is not already exited
|
||||
* 2. A StageTransition record exists for fromStage -> toStage
|
||||
* 3. Destination stage is active (not DRAFT or ARCHIVED)
|
||||
* 4. Voting/evaluation window constraints on the destination stage
|
||||
* 5. Guard conditions on the transition
|
||||
*/
|
||||
export async function validateTransition(
|
||||
projectId: string,
|
||||
fromStageId: string,
|
||||
toStageId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<TransitionValidationResult> {
|
||||
const errors: string[] = []
|
||||
|
||||
// 1. Check source PSS exists and is active (no exitedAt)
|
||||
const sourcePSS = await prisma.projectStageState.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
stageId: fromStageId,
|
||||
exitedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
if (!sourcePSS) {
|
||||
errors.push(
|
||||
`Project ${projectId} has no active state in stage ${fromStageId}`
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Check StageTransition record exists
|
||||
const transition = await prisma.stageTransition.findUnique({
|
||||
where: {
|
||||
fromStageId_toStageId: {
|
||||
fromStageId,
|
||||
toStageId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!transition) {
|
||||
errors.push(
|
||||
`No transition defined from stage ${fromStageId} to stage ${toStageId}`
|
||||
)
|
||||
return { valid: false, errors }
|
||||
}
|
||||
|
||||
// 3. Check destination stage is active
|
||||
const destStage = await prisma.stage.findUnique({
|
||||
where: { id: toStageId },
|
||||
})
|
||||
|
||||
if (!destStage) {
|
||||
errors.push(`Destination stage ${toStageId} not found`)
|
||||
return { valid: false, errors }
|
||||
}
|
||||
|
||||
if (destStage.status === 'STAGE_ARCHIVED') {
|
||||
errors.push(`Destination stage "${destStage.name}" is archived`)
|
||||
}
|
||||
|
||||
// 4. Check window constraints on destination stage
|
||||
const now = new Date()
|
||||
if (destStage.windowOpenAt && now < destStage.windowOpenAt) {
|
||||
errors.push(
|
||||
`Destination stage "${destStage.name}" window has not opened yet (opens ${destStage.windowOpenAt.toISOString()})`
|
||||
)
|
||||
}
|
||||
if (destStage.windowCloseAt && now > destStage.windowCloseAt) {
|
||||
errors.push(
|
||||
`Destination stage "${destStage.name}" window has already closed (closed ${destStage.windowCloseAt.toISOString()})`
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Evaluate guard conditions
|
||||
if (transition.guardJson && sourcePSS) {
|
||||
// Build context from the project and its current state for guard evaluation
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
assignments: {
|
||||
where: { stageId: fromStageId },
|
||||
include: { evaluation: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const evaluations = project?.assignments
|
||||
?.map((a: any) => a.evaluation)
|
||||
.filter(Boolean) ?? []
|
||||
const submittedEvaluations = evaluations.filter(
|
||||
(e: any) => e.status === 'SUBMITTED'
|
||||
)
|
||||
const avgScore =
|
||||
submittedEvaluations.length > 0
|
||||
? submittedEvaluations.reduce(
|
||||
(sum: number, e: any) => sum + (e.globalScore ?? 0),
|
||||
0
|
||||
) / submittedEvaluations.length
|
||||
: 0
|
||||
|
||||
const guardContext: Record<string, unknown> = {
|
||||
state: sourcePSS?.state,
|
||||
evaluationCount: evaluations.length,
|
||||
submittedEvaluationCount: submittedEvaluations.length,
|
||||
averageScore: avgScore,
|
||||
status: project?.status,
|
||||
country: project?.country,
|
||||
competitionCategory: project?.competitionCategory,
|
||||
tags: project?.tags ?? [],
|
||||
}
|
||||
|
||||
const guardResult = evaluateGuard(transition.guardJson, guardContext)
|
||||
if (!guardResult.passed) {
|
||||
errors.push(...guardResult.failedConditions)
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
// ─── Execute Transition ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a stage transition for a single project atomically.
|
||||
* Within a transaction:
|
||||
* 1. Sets exitedAt on the source PSS
|
||||
* 2. Creates or updates the destination PSS with the new state
|
||||
* 3. Logs the transition in DecisionAuditLog
|
||||
* 4. Logs the transition in AuditLog
|
||||
*/
|
||||
export async function executeTransition(
|
||||
projectId: string,
|
||||
trackId: string,
|
||||
fromStageId: string,
|
||||
toStageId: string,
|
||||
newState: ProjectStageStateValue,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<TransitionExecutionResult> {
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx: any) => {
|
||||
const now = new Date()
|
||||
|
||||
// 1. Exit the source PSS
|
||||
const sourcePSS = await tx.projectStageState.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
stageId: fromStageId,
|
||||
exitedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
if (sourcePSS) {
|
||||
await tx.projectStageState.update({
|
||||
where: { id: sourcePSS.id },
|
||||
data: {
|
||||
exitedAt: now,
|
||||
state: sourcePSS.state === 'PENDING' || sourcePSS.state === 'IN_PROGRESS'
|
||||
? 'COMPLETED'
|
||||
: sourcePSS.state,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Create or update destination PSS
|
||||
const existingDestPSS = await tx.projectStageState.findUnique({
|
||||
where: {
|
||||
projectId_trackId_stageId: {
|
||||
projectId,
|
||||
trackId,
|
||||
stageId: toStageId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let destPSS
|
||||
if (existingDestPSS) {
|
||||
destPSS = await tx.projectStageState.update({
|
||||
where: { id: existingDestPSS.id },
|
||||
data: {
|
||||
state: newState,
|
||||
enteredAt: now,
|
||||
exitedAt: null,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
destPSS = await tx.projectStageState.create({
|
||||
data: {
|
||||
projectId,
|
||||
trackId,
|
||||
stageId: toStageId,
|
||||
state: newState,
|
||||
enteredAt: now,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Log in DecisionAuditLog
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'stage.transitioned',
|
||||
entityType: 'ProjectStageState',
|
||||
entityId: destPSS.id,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
projectId,
|
||||
trackId,
|
||||
fromStageId,
|
||||
toStageId,
|
||||
previousState: sourcePSS?.state ?? null,
|
||||
newState,
|
||||
},
|
||||
snapshotJson: {
|
||||
sourcePSSId: sourcePSS?.id ?? null,
|
||||
destPSSId: destPSS.id,
|
||||
timestamp: now.toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 4. Audit log (never throws)
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: actorId,
|
||||
action: 'STAGE_TRANSITION',
|
||||
entityType: 'ProjectStageState',
|
||||
entityId: destPSS.id,
|
||||
detailsJson: {
|
||||
projectId,
|
||||
fromStageId,
|
||||
toStageId,
|
||||
newState,
|
||||
},
|
||||
})
|
||||
|
||||
return destPSS
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
projectStageState: {
|
||||
id: result.id,
|
||||
projectId: result.projectId,
|
||||
trackId: result.trackId,
|
||||
stageId: result.stageId,
|
||||
state: result.state,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[StageEngine] Transition execution failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
projectStageState: null,
|
||||
errors: [
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error during transition execution',
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Batch Transition ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute transitions for multiple projects in batches of 50.
|
||||
* Each project is processed independently so a failure in one does not
|
||||
* block others.
|
||||
*/
|
||||
export async function executeBatchTransition(
|
||||
projectIds: string[],
|
||||
trackId: string,
|
||||
fromStageId: string,
|
||||
toStageId: string,
|
||||
newState: ProjectStageStateValue,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<BatchTransitionResult> {
|
||||
const succeeded: string[] = []
|
||||
const failed: Array<{ projectId: string; errors: string[] }> = []
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < projectIds.length; i += BATCH_SIZE) {
|
||||
const batch = projectIds.slice(i, i + BATCH_SIZE)
|
||||
|
||||
const batchPromises = batch.map(async (projectId) => {
|
||||
// Validate first
|
||||
const validation = await validateTransition(
|
||||
projectId,
|
||||
fromStageId,
|
||||
toStageId,
|
||||
prisma
|
||||
)
|
||||
|
||||
if (!validation.valid) {
|
||||
failed.push({ projectId, errors: validation.errors })
|
||||
return
|
||||
}
|
||||
|
||||
// Execute transition
|
||||
const result = await executeTransition(
|
||||
projectId,
|
||||
trackId,
|
||||
fromStageId,
|
||||
toStageId,
|
||||
newState,
|
||||
actorId,
|
||||
prisma
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
succeeded.push(projectId)
|
||||
} else {
|
||||
failed.push({
|
||||
projectId,
|
||||
errors: result.errors ?? ['Transition execution failed'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(batchPromises)
|
||||
}
|
||||
|
||||
return {
|
||||
succeeded,
|
||||
failed,
|
||||
total: projectIds.length,
|
||||
}
|
||||
}
|
||||
584
src/server/services/stage-filtering.ts
Normal file
584
src/server/services/stage-filtering.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Stage Filtering Service
|
||||
*
|
||||
* Runs filtering logic scoped to a specific pipeline stage. Executes deterministic
|
||||
* (field-based, document-check) rules first; if those pass, proceeds to AI screening.
|
||||
* Results are banded by confidence and flagged items go to a manual review queue.
|
||||
*
|
||||
* This service delegates to the existing `ai-filtering.ts` utilities for rule
|
||||
* evaluation but adds stage-awareness, FilteringJob tracking, and manual
|
||||
* decision resolution.
|
||||
*/
|
||||
|
||||
import type { PrismaClient, FilteringOutcome, Prisma } from '@prisma/client'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StageFilteringResult {
|
||||
jobId: string
|
||||
passed: number
|
||||
rejected: number
|
||||
manualQueue: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ManualQueueItem {
|
||||
filteringResultId: string
|
||||
projectId: string
|
||||
projectTitle: string
|
||||
outcome: string
|
||||
ruleResults: Prisma.JsonValue | null
|
||||
aiScreeningJson: Prisma.JsonValue | null
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
interface RuleConfig {
|
||||
conditions?: Array<{
|
||||
field: string
|
||||
operator: string
|
||||
value: unknown
|
||||
}>
|
||||
logic?: 'AND' | 'OR'
|
||||
action?: string
|
||||
requiredFileTypes?: string[]
|
||||
minFileCount?: number
|
||||
criteriaText?: string
|
||||
batchSize?: number
|
||||
}
|
||||
|
||||
interface RuleResult {
|
||||
ruleId: string
|
||||
ruleName: string
|
||||
ruleType: string
|
||||
passed: boolean
|
||||
action: string
|
||||
reasoning?: string
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const AI_CONFIDENCE_THRESHOLD_PASS = 0.75
|
||||
const AI_CONFIDENCE_THRESHOLD_REJECT = 0.25
|
||||
|
||||
// ─── Field-Based Rule Evaluation ────────────────────────────────────────────
|
||||
|
||||
function evaluateFieldCondition(
|
||||
condition: { field: string; operator: string; value: unknown },
|
||||
project: Record<string, unknown>
|
||||
): boolean {
|
||||
const fieldValue = project[condition.field]
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'equals':
|
||||
return String(fieldValue) === String(condition.value)
|
||||
case 'not_equals':
|
||||
return String(fieldValue) !== String(condition.value)
|
||||
case 'contains': {
|
||||
if (Array.isArray(fieldValue)) {
|
||||
return fieldValue.some((v) =>
|
||||
String(v).toLowerCase().includes(String(condition.value).toLowerCase())
|
||||
)
|
||||
}
|
||||
return String(fieldValue ?? '')
|
||||
.toLowerCase()
|
||||
.includes(String(condition.value).toLowerCase())
|
||||
}
|
||||
case 'in': {
|
||||
if (Array.isArray(condition.value)) {
|
||||
return (condition.value as unknown[]).includes(fieldValue)
|
||||
}
|
||||
return false
|
||||
}
|
||||
case 'not_in': {
|
||||
if (Array.isArray(condition.value)) {
|
||||
return !(condition.value as unknown[]).includes(fieldValue)
|
||||
}
|
||||
return true
|
||||
}
|
||||
case 'is_empty':
|
||||
return (
|
||||
fieldValue === null ||
|
||||
fieldValue === undefined ||
|
||||
(Array.isArray(fieldValue) && fieldValue.length === 0) ||
|
||||
String(fieldValue).trim() === ''
|
||||
)
|
||||
case 'greater_than':
|
||||
return Number(fieldValue) > Number(condition.value)
|
||||
case 'less_than':
|
||||
return Number(fieldValue) < Number(condition.value)
|
||||
case 'older_than_years': {
|
||||
if (!(fieldValue instanceof Date)) return false
|
||||
const cutoff = new Date()
|
||||
cutoff.setFullYear(cutoff.getFullYear() - Number(condition.value))
|
||||
return fieldValue < cutoff
|
||||
}
|
||||
case 'newer_than_years': {
|
||||
if (!(fieldValue instanceof Date)) return false
|
||||
const cutoff = new Date()
|
||||
cutoff.setFullYear(cutoff.getFullYear() - Number(condition.value))
|
||||
return fieldValue >= cutoff
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateFieldRule(
|
||||
config: RuleConfig,
|
||||
project: Record<string, unknown>
|
||||
): { passed: boolean; action: string } {
|
||||
const conditions = config.conditions ?? []
|
||||
if (conditions.length === 0) return { passed: true, action: config.action ?? 'PASS' }
|
||||
|
||||
const results = conditions.map((c) => evaluateFieldCondition(c, project))
|
||||
const logic = config.logic ?? 'AND'
|
||||
const allMet = logic === 'AND' ? results.every(Boolean) : results.some(Boolean)
|
||||
|
||||
if (config.action === 'PASS') {
|
||||
return { passed: allMet, action: config.action }
|
||||
}
|
||||
// For REJECT/FLAG rules, if conditions are met the project fails the rule
|
||||
return { passed: !allMet, action: config.action ?? 'REJECT' }
|
||||
}
|
||||
|
||||
function evaluateDocumentCheck(
|
||||
config: RuleConfig,
|
||||
projectFiles: Array<{ fileType: string; fileName: string }>
|
||||
): { passed: boolean; action: string } {
|
||||
const action = config.action ?? 'FLAG'
|
||||
|
||||
if (config.requiredFileTypes && config.requiredFileTypes.length > 0) {
|
||||
const fileTypes = projectFiles.map((f) => f.fileType)
|
||||
const hasAllRequired = config.requiredFileTypes.every((ft) =>
|
||||
fileTypes.includes(ft)
|
||||
)
|
||||
if (!hasAllRequired) return { passed: false, action }
|
||||
}
|
||||
|
||||
if (config.minFileCount !== undefined) {
|
||||
if (projectFiles.length < config.minFileCount) {
|
||||
return { passed: false, action }
|
||||
}
|
||||
}
|
||||
|
||||
return { passed: true, action }
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple AI-screening placeholder that uses confidence banding.
|
||||
* In production, this calls the OpenAI API (see ai-filtering.ts).
|
||||
* Here we evaluate based on project metadata heuristics.
|
||||
*/
|
||||
function bandByConfidence(
|
||||
aiScreeningData: { confidence?: number; meetsAllCriteria?: boolean } | null
|
||||
): { outcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'; confidence: number } {
|
||||
if (!aiScreeningData || aiScreeningData.confidence === undefined) {
|
||||
return { outcome: 'FLAGGED', confidence: 0 }
|
||||
}
|
||||
|
||||
const confidence = aiScreeningData.confidence
|
||||
|
||||
if (confidence >= AI_CONFIDENCE_THRESHOLD_PASS && aiScreeningData.meetsAllCriteria) {
|
||||
return { outcome: 'PASSED', confidence }
|
||||
}
|
||||
|
||||
if (confidence <= AI_CONFIDENCE_THRESHOLD_REJECT && !aiScreeningData.meetsAllCriteria) {
|
||||
return { outcome: 'FILTERED_OUT', confidence }
|
||||
}
|
||||
|
||||
return { outcome: 'FLAGGED', confidence }
|
||||
}
|
||||
|
||||
// ─── Run Stage Filtering ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute the full filtering pipeline for a stage:
|
||||
* 1. Create a FilteringJob for progress tracking
|
||||
* 2. Load all projects with active PSS in the stage
|
||||
* 3. Load filtering rules scoped to this stage (ordered by priority)
|
||||
* 4. Run deterministic rules first (FIELD_BASED, DOCUMENT_CHECK)
|
||||
* 5. For projects that pass deterministic rules, run AI screening
|
||||
* 6. Band AI results by confidence
|
||||
* 7. Save FilteringResult for every project
|
||||
*/
|
||||
export async function runStageFiltering(
|
||||
stageId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<StageFilteringResult> {
|
||||
const stage = await prisma.stage.findUnique({
|
||||
where: { id: stageId },
|
||||
include: {
|
||||
track: { include: { pipeline: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!stage) {
|
||||
throw new Error(`Stage ${stageId} not found`)
|
||||
}
|
||||
|
||||
// Load projects in this stage (active PSS, not exited)
|
||||
const projectStates = await prisma.projectStageState.findMany({
|
||||
where: {
|
||||
stageId,
|
||||
exitedAt: null,
|
||||
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
files: { select: { fileType: true, fileName: true } },
|
||||
projectTags: { include: { tag: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const projects = projectStates.map((pss: any) => pss.project).filter(Boolean)
|
||||
|
||||
const job = await prisma.filteringJob.create({
|
||||
data: {
|
||||
stageId,
|
||||
status: 'RUNNING',
|
||||
totalProjects: projects.length,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Load filtering rules scoped to this stage
|
||||
const rules = await prisma.filteringRule.findMany({
|
||||
where: {
|
||||
stageId,
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: { priority: 'asc' as const },
|
||||
})
|
||||
|
||||
// Separate deterministic rules from AI rules
|
||||
const deterministicRules = rules.filter(
|
||||
(r: any) => r.ruleType === 'FIELD_BASED' || r.ruleType === 'DOCUMENT_CHECK'
|
||||
)
|
||||
const aiRules = rules.filter((r: any) => r.ruleType === 'AI_SCREENING')
|
||||
|
||||
let passed = 0
|
||||
let rejected = 0
|
||||
let manualQueue = 0
|
||||
let processedCount = 0
|
||||
|
||||
for (const project of projects) {
|
||||
const ruleResults: RuleResult[] = []
|
||||
let deterministicPassed = true
|
||||
let deterministicOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' = 'PASSED'
|
||||
|
||||
// 1. Run deterministic rules
|
||||
for (const rule of deterministicRules) {
|
||||
const config = rule.configJson as unknown as RuleConfig
|
||||
|
||||
let result: { passed: boolean; action: string }
|
||||
|
||||
if (rule.ruleType === 'FIELD_BASED') {
|
||||
result = evaluateFieldRule(config, {
|
||||
competitionCategory: project.competitionCategory,
|
||||
foundedAt: project.foundedAt,
|
||||
country: project.country,
|
||||
geographicZone: project.geographicZone,
|
||||
tags: project.tags,
|
||||
oceanIssue: project.oceanIssue,
|
||||
wantsMentorship: project.wantsMentorship,
|
||||
institution: project.institution,
|
||||
})
|
||||
} else {
|
||||
// DOCUMENT_CHECK
|
||||
result = evaluateDocumentCheck(config, project.files)
|
||||
}
|
||||
|
||||
ruleResults.push({
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
ruleType: rule.ruleType,
|
||||
passed: result.passed,
|
||||
action: result.action,
|
||||
})
|
||||
|
||||
if (!result.passed) {
|
||||
deterministicPassed = false
|
||||
if (result.action === 'REJECT') {
|
||||
deterministicOutcome = 'FILTERED_OUT'
|
||||
break // Hard reject, skip remaining rules
|
||||
} else if (result.action === 'FLAG') {
|
||||
deterministicOutcome = 'FLAGGED'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. AI screening (only if deterministic passed)
|
||||
let aiScreeningJson: Record<string, unknown> | null = null
|
||||
let finalOutcome: 'PASSED' | 'FILTERED_OUT' | 'FLAGGED' = deterministicOutcome
|
||||
|
||||
if (deterministicPassed && aiRules.length > 0) {
|
||||
// Build a simplified AI screening result using the existing AI criteria
|
||||
// In production this would call OpenAI via the ai-filtering service
|
||||
const aiRule = aiRules[0]
|
||||
const aiConfig = aiRule.configJson as unknown as RuleConfig
|
||||
|
||||
// For now, flag projects that have AI rules but need manual review
|
||||
// The actual AI call would be: await runAIScreening(project, aiConfig)
|
||||
const hasMinimalData = Boolean(project.description && project.title)
|
||||
const confidence = hasMinimalData ? 0.5 : 0.2
|
||||
|
||||
aiScreeningJson = {
|
||||
ruleId: aiRule.id,
|
||||
criteriaText: aiConfig.criteriaText,
|
||||
confidence,
|
||||
meetsAllCriteria: hasMinimalData,
|
||||
reasoning: hasMinimalData
|
||||
? 'Project has required data, needs manual review'
|
||||
: 'Insufficient project data for AI screening',
|
||||
}
|
||||
|
||||
const banded = bandByConfidence({
|
||||
confidence,
|
||||
meetsAllCriteria: hasMinimalData,
|
||||
})
|
||||
|
||||
finalOutcome = banded.outcome
|
||||
|
||||
ruleResults.push({
|
||||
ruleId: aiRule.id,
|
||||
ruleName: aiRule.name,
|
||||
ruleType: 'AI_SCREENING',
|
||||
passed: banded.outcome === 'PASSED',
|
||||
action: banded.outcome === 'PASSED' ? 'PASS' : banded.outcome === 'FLAGGED' ? 'FLAG' : 'REJECT',
|
||||
reasoning: `Confidence: ${banded.confidence.toFixed(2)}`,
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.filteringResult.upsert({
|
||||
where: {
|
||||
stageId_projectId: {
|
||||
stageId,
|
||||
projectId: project.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
stageId,
|
||||
projectId: project.id,
|
||||
outcome: finalOutcome as FilteringOutcome,
|
||||
ruleResultsJson: ruleResults as unknown as Prisma.InputJsonValue,
|
||||
aiScreeningJson: aiScreeningJson as Prisma.InputJsonValue ?? undefined,
|
||||
},
|
||||
update: {
|
||||
outcome: finalOutcome as FilteringOutcome,
|
||||
ruleResultsJson: ruleResults as unknown as Prisma.InputJsonValue,
|
||||
aiScreeningJson: aiScreeningJson as Prisma.InputJsonValue ?? undefined,
|
||||
finalOutcome: null,
|
||||
overriddenBy: null,
|
||||
overriddenAt: null,
|
||||
overrideReason: null,
|
||||
},
|
||||
})
|
||||
|
||||
// Track counts
|
||||
switch (finalOutcome) {
|
||||
case 'PASSED':
|
||||
passed++
|
||||
break
|
||||
case 'FILTERED_OUT':
|
||||
rejected++
|
||||
break
|
||||
case 'FLAGGED':
|
||||
manualQueue++
|
||||
break
|
||||
}
|
||||
|
||||
processedCount++
|
||||
|
||||
// Update job progress periodically
|
||||
if (processedCount % 10 === 0 || processedCount === projects.length) {
|
||||
await prisma.filteringJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
processedCount,
|
||||
passedCount: passed,
|
||||
filteredCount: rejected,
|
||||
flaggedCount: manualQueue,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Complete the job
|
||||
await prisma.filteringJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
processedCount,
|
||||
passedCount: passed,
|
||||
filteredCount: rejected,
|
||||
flaggedCount: manualQueue,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Decision audit log
|
||||
await prisma.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'filtering.completed',
|
||||
entityType: 'FilteringJob',
|
||||
entityId: job.id,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
stageId,
|
||||
total: projects.length,
|
||||
passed,
|
||||
rejected,
|
||||
manualQueue,
|
||||
ruleCount: rules.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: actorId,
|
||||
action: 'STAGE_FILTERING_RUN',
|
||||
entityType: 'FilteringJob',
|
||||
entityId: job.id,
|
||||
detailsJson: {
|
||||
stageId,
|
||||
total: projects.length,
|
||||
passed,
|
||||
rejected,
|
||||
manualQueue,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
passed,
|
||||
rejected,
|
||||
manualQueue,
|
||||
total: projects.length,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resolve Manual Decision ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a flagged filtering result with a manual decision.
|
||||
* Updates the finalOutcome on the FilteringResult and logs the override.
|
||||
*/
|
||||
export async function resolveManualDecision(
|
||||
filteringResultId: string,
|
||||
outcome: 'PASSED' | 'FILTERED_OUT',
|
||||
reason: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<void> {
|
||||
const existing = await prisma.filteringResult.findUnique({
|
||||
where: { id: filteringResultId },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(`FilteringResult ${filteringResultId} not found`)
|
||||
}
|
||||
|
||||
if (existing.outcome !== 'FLAGGED') {
|
||||
throw new Error(
|
||||
`FilteringResult ${filteringResultId} is not FLAGGED (current: ${existing.outcome})`
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx: any) => {
|
||||
// Update the filtering result
|
||||
await tx.filteringResult.update({
|
||||
where: { id: filteringResultId },
|
||||
data: {
|
||||
finalOutcome: outcome as FilteringOutcome,
|
||||
overriddenBy: actorId,
|
||||
overriddenAt: new Date(),
|
||||
overrideReason: reason,
|
||||
},
|
||||
})
|
||||
|
||||
// Create override action record
|
||||
await tx.overrideAction.create({
|
||||
data: {
|
||||
entityType: 'FilteringResult',
|
||||
entityId: filteringResultId,
|
||||
previousValue: { outcome: existing.outcome },
|
||||
newValueJson: { finalOutcome: outcome },
|
||||
reasonCode: 'ADMIN_DISCRETION',
|
||||
reasonText: reason,
|
||||
actorId,
|
||||
},
|
||||
})
|
||||
|
||||
// Decision audit log
|
||||
await tx.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType: 'filtering.manual_decision',
|
||||
entityType: 'FilteringResult',
|
||||
entityId: filteringResultId,
|
||||
actorId,
|
||||
detailsJson: {
|
||||
projectId: existing.projectId,
|
||||
previousOutcome: existing.outcome,
|
||||
newOutcome: outcome,
|
||||
reason,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: actorId,
|
||||
action: 'FILTERING_MANUAL_DECISION',
|
||||
entityType: 'FilteringResult',
|
||||
entityId: filteringResultId,
|
||||
detailsJson: {
|
||||
projectId: existing.projectId,
|
||||
outcome,
|
||||
reason,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Get Manual Queue ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retrieve all flagged filtering results for a stage that have not yet
|
||||
* been manually resolved.
|
||||
*/
|
||||
export async function getManualQueue(
|
||||
stageId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<ManualQueueItem[]> {
|
||||
const results = await prisma.filteringResult.findMany({
|
||||
where: {
|
||||
stageId,
|
||||
outcome: 'FLAGGED',
|
||||
finalOutcome: null,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: { id: true, title: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' as const },
|
||||
})
|
||||
|
||||
return results.map((r: any) => ({
|
||||
filteringResultId: r.id,
|
||||
projectId: r.projectId,
|
||||
projectTitle: r.project?.title ?? 'Unknown',
|
||||
outcome: r.outcome,
|
||||
ruleResults: r.ruleResultsJson,
|
||||
aiScreeningJson: r.aiScreeningJson,
|
||||
createdAt: r.createdAt,
|
||||
}))
|
||||
}
|
||||
502
src/server/services/stage-notifications.ts
Normal file
502
src/server/services/stage-notifications.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* Stage Notifications Service
|
||||
*
|
||||
* Event producers called from other pipeline services. Each function creates
|
||||
* a DecisionAuditLog entry, checks NotificationPolicy configuration, and
|
||||
* creates in-app notifications (optionally sending email). Producers never
|
||||
* throw - all errors are caught and logged.
|
||||
*
|
||||
* Event types follow a dotted convention:
|
||||
* stage.transitioned, filtering.completed, assignment.generated,
|
||||
* routing.executed, live.cursor_updated, decision.overridden
|
||||
*/
|
||||
|
||||
import type { PrismaClient, Prisma } from '@prisma/client'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StageEventDetails {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface NotificationTarget {
|
||||
userId: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const EVENT_TYPES = {
|
||||
STAGE_TRANSITIONED: 'stage.transitioned',
|
||||
FILTERING_COMPLETED: 'filtering.completed',
|
||||
ASSIGNMENT_GENERATED: 'assignment.generated',
|
||||
ROUTING_EXECUTED: 'routing.executed',
|
||||
CURSOR_UPDATED: 'live.cursor_updated',
|
||||
DECISION_OVERRIDDEN: 'decision.overridden',
|
||||
} as const
|
||||
|
||||
const EVENT_TITLES: Record<string, string> = {
|
||||
[EVENT_TYPES.STAGE_TRANSITIONED]: 'Stage Transition',
|
||||
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filtering Complete',
|
||||
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'Assignments Generated',
|
||||
[EVENT_TYPES.ROUTING_EXECUTED]: 'Routing Executed',
|
||||
[EVENT_TYPES.CURSOR_UPDATED]: 'Live Cursor Updated',
|
||||
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'Decision Overridden',
|
||||
}
|
||||
|
||||
const EVENT_ICONS: Record<string, string> = {
|
||||
[EVENT_TYPES.STAGE_TRANSITIONED]: 'ArrowRight',
|
||||
[EVENT_TYPES.FILTERING_COMPLETED]: 'Filter',
|
||||
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'ClipboardList',
|
||||
[EVENT_TYPES.ROUTING_EXECUTED]: 'GitBranch',
|
||||
[EVENT_TYPES.CURSOR_UPDATED]: 'Play',
|
||||
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'ShieldAlert',
|
||||
}
|
||||
|
||||
const EVENT_PRIORITIES: Record<string, string> = {
|
||||
[EVENT_TYPES.STAGE_TRANSITIONED]: 'normal',
|
||||
[EVENT_TYPES.FILTERING_COMPLETED]: 'high',
|
||||
[EVENT_TYPES.ASSIGNMENT_GENERATED]: 'high',
|
||||
[EVENT_TYPES.ROUTING_EXECUTED]: 'normal',
|
||||
[EVENT_TYPES.CURSOR_UPDATED]: 'low',
|
||||
[EVENT_TYPES.DECISION_OVERRIDDEN]: 'high',
|
||||
}
|
||||
|
||||
// ─── Core Event Emitter ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Core event emission function. Creates a DecisionAuditLog entry, checks
|
||||
* the NotificationPolicy for the event type, and creates in-app notifications
|
||||
* for the appropriate recipients.
|
||||
*
|
||||
* This function never throws. All errors are caught and logged.
|
||||
*/
|
||||
export async function emitStageEvent(
|
||||
eventType: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
actorId: string,
|
||||
details: StageEventDetails,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 1. Create DecisionAuditLog entry
|
||||
await prisma.decisionAuditLog.create({
|
||||
data: {
|
||||
eventType,
|
||||
entityType,
|
||||
entityId,
|
||||
actorId,
|
||||
detailsJson: details as Prisma.InputJsonValue,
|
||||
snapshotJson: {
|
||||
timestamp: new Date().toISOString(),
|
||||
emittedBy: 'stage-notifications',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 2. Check NotificationPolicy
|
||||
const policy = await prisma.notificationPolicy.findUnique({
|
||||
where: { eventType },
|
||||
})
|
||||
|
||||
// If no policy or policy inactive, just log and return
|
||||
if (!policy || !policy.isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Determine recipients based on event type
|
||||
const recipients = await resolveRecipients(
|
||||
eventType,
|
||||
details,
|
||||
prisma
|
||||
)
|
||||
|
||||
if (recipients.length === 0) return
|
||||
|
||||
// 4. Determine notification content
|
||||
const title = EVENT_TITLES[eventType] ?? 'Pipeline Event'
|
||||
const icon = EVENT_ICONS[eventType] ?? 'Bell'
|
||||
const priority = EVENT_PRIORITIES[eventType] ?? 'normal'
|
||||
const message = buildNotificationMessage(eventType, details)
|
||||
|
||||
// 5. Create in-app notifications
|
||||
const channel = policy.channel ?? 'IN_APP'
|
||||
const shouldCreateInApp = channel === 'IN_APP' || channel === 'BOTH'
|
||||
const shouldSendEmail = channel === 'EMAIL' || channel === 'BOTH'
|
||||
|
||||
if (shouldCreateInApp) {
|
||||
const notificationData = recipients.map((recipient) => ({
|
||||
userId: recipient.userId,
|
||||
type: eventType,
|
||||
title,
|
||||
message,
|
||||
icon,
|
||||
priority,
|
||||
metadata: {
|
||||
entityType,
|
||||
entityId,
|
||||
actorId,
|
||||
...details,
|
||||
} as object,
|
||||
groupKey: `${eventType}:${entityId}`,
|
||||
}))
|
||||
|
||||
await prisma.inAppNotification.createMany({
|
||||
data: notificationData,
|
||||
})
|
||||
}
|
||||
|
||||
// 6. Optionally send email notifications
|
||||
if (shouldSendEmail) {
|
||||
// Email sending is best-effort; we import lazily to avoid circular deps
|
||||
try {
|
||||
const { sendStyledNotificationEmail } = await import('@/lib/email')
|
||||
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
await sendStyledNotificationEmail(
|
||||
recipient.email,
|
||||
recipient.name,
|
||||
eventType,
|
||||
{
|
||||
title,
|
||||
message,
|
||||
metadata: details as Record<string, unknown>,
|
||||
}
|
||||
)
|
||||
} catch (emailError) {
|
||||
console.error(
|
||||
`[StageNotifications] Failed to send email to ${recipient.email}:`,
|
||||
emailError
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (importError) {
|
||||
console.error(
|
||||
'[StageNotifications] Failed to import email module:',
|
||||
importError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Audit log (never throws)
|
||||
await logAudit({
|
||||
prisma,
|
||||
userId: actorId,
|
||||
action: 'STAGE_EVENT_EMITTED',
|
||||
entityType,
|
||||
entityId,
|
||||
detailsJson: {
|
||||
eventType,
|
||||
recipientCount: recipients.length,
|
||||
channel,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// Never throw from event producers
|
||||
console.error(
|
||||
`[StageNotifications] Failed to emit event ${eventType}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Recipient Resolution ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Determine who should receive notifications for a given event type.
|
||||
* Different events notify different audiences (admins, jury, etc.).
|
||||
*/
|
||||
async function resolveRecipients(
|
||||
eventType: string,
|
||||
details: StageEventDetails,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<NotificationTarget[]> {
|
||||
try {
|
||||
switch (eventType) {
|
||||
case EVENT_TYPES.STAGE_TRANSITIONED:
|
||||
case EVENT_TYPES.FILTERING_COMPLETED:
|
||||
case EVENT_TYPES.ASSIGNMENT_GENERATED:
|
||||
case EVENT_TYPES.ROUTING_EXECUTED:
|
||||
case EVENT_TYPES.DECISION_OVERRIDDEN: {
|
||||
// Notify admins
|
||||
const admins = await prisma.user.findMany({
|
||||
where: {
|
||||
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
return admins.map((a: any) => ({
|
||||
userId: a.id,
|
||||
name: a.name ?? 'Admin',
|
||||
email: a.email,
|
||||
}))
|
||||
}
|
||||
|
||||
case EVENT_TYPES.CURSOR_UPDATED: {
|
||||
// Notify jury members assigned to the stage
|
||||
const stageId = details.stageId as string | undefined
|
||||
if (!stageId) return []
|
||||
|
||||
const jurors = await prisma.assignment.findMany({
|
||||
where: { stageId },
|
||||
select: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
distinct: ['userId'],
|
||||
})
|
||||
|
||||
return jurors.map((a: any) => ({
|
||||
userId: a.user.id,
|
||||
name: a.user.name ?? 'Jury Member',
|
||||
email: a.user.email,
|
||||
}))
|
||||
}
|
||||
|
||||
default:
|
||||
// Default: notify admins
|
||||
const admins = await prisma.user.findMany({
|
||||
where: {
|
||||
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
return admins.map((a: any) => ({
|
||||
userId: a.id,
|
||||
name: a.name ?? 'Admin',
|
||||
email: a.email,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[StageNotifications] Failed to resolve recipients:',
|
||||
error
|
||||
)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Message Builder ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a human-readable notification message from event details.
|
||||
*/
|
||||
function buildNotificationMessage(
|
||||
eventType: string,
|
||||
details: StageEventDetails
|
||||
): string {
|
||||
switch (eventType) {
|
||||
case EVENT_TYPES.STAGE_TRANSITIONED: {
|
||||
const projectId = details.projectId as string | undefined
|
||||
const toStageId = details.toStageId as string | undefined
|
||||
const newState = details.newState as string | undefined
|
||||
return `Project ${projectId ?? 'unknown'} transitioned to stage ${toStageId ?? 'unknown'} with state ${newState ?? 'unknown'}.`
|
||||
}
|
||||
|
||||
case EVENT_TYPES.FILTERING_COMPLETED: {
|
||||
const total = details.total as number | undefined
|
||||
const passed = details.passed as number | undefined
|
||||
const rejected = details.rejected as number | undefined
|
||||
const manualQueue = details.manualQueue as number | undefined
|
||||
return `Filtering completed: ${passed ?? 0} passed, ${rejected ?? 0} rejected, ${manualQueue ?? 0} flagged for review out of ${total ?? 0} projects.`
|
||||
}
|
||||
|
||||
case EVENT_TYPES.ASSIGNMENT_GENERATED: {
|
||||
const count = details.assignmentCount as number | undefined
|
||||
return `${count ?? 0} assignments were generated for the stage.`
|
||||
}
|
||||
|
||||
case EVENT_TYPES.ROUTING_EXECUTED: {
|
||||
const ruleName = details.ruleName as string | undefined
|
||||
const routingMode = details.routingMode as string | undefined
|
||||
return `Routing rule "${ruleName ?? 'unknown'}" executed in ${routingMode ?? 'unknown'} mode.`
|
||||
}
|
||||
|
||||
case EVENT_TYPES.CURSOR_UPDATED: {
|
||||
const projectId = details.projectId as string | undefined
|
||||
const action = details.action as string | undefined
|
||||
return `Live cursor updated: ${action ?? 'navigation'} to project ${projectId ?? 'unknown'}.`
|
||||
}
|
||||
|
||||
case EVENT_TYPES.DECISION_OVERRIDDEN: {
|
||||
const overrideEntityType = details.entityType as string | undefined
|
||||
const reason = details.reason as string | undefined
|
||||
return `Decision overridden on ${overrideEntityType ?? 'entity'}: ${reason ?? 'No reason provided'}.`
|
||||
}
|
||||
|
||||
default:
|
||||
return `Pipeline event: ${eventType}`
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Convenience Producers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emit a stage.transitioned event when a project moves between stages.
|
||||
* Called from stage-engine.ts after executeTransition.
|
||||
*/
|
||||
export async function onStageTransitioned(
|
||||
projectId: string,
|
||||
trackId: string,
|
||||
fromStageId: string,
|
||||
toStageId: string,
|
||||
newState: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<void> {
|
||||
await emitStageEvent(
|
||||
EVENT_TYPES.STAGE_TRANSITIONED,
|
||||
'ProjectStageState',
|
||||
projectId,
|
||||
actorId,
|
||||
{
|
||||
projectId,
|
||||
trackId,
|
||||
fromStageId,
|
||||
toStageId,
|
||||
newState,
|
||||
},
|
||||
prisma
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a filtering.completed event when a stage filtering job finishes.
|
||||
* Called from stage-filtering.ts after runStageFiltering.
|
||||
*/
|
||||
export async function onFilteringCompleted(
|
||||
jobId: string,
|
||||
stageId: string,
|
||||
total: number,
|
||||
passed: number,
|
||||
rejected: number,
|
||||
manualQueue: number,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<void> {
|
||||
await emitStageEvent(
|
||||
EVENT_TYPES.FILTERING_COMPLETED,
|
||||
'FilteringJob',
|
||||
jobId,
|
||||
actorId,
|
||||
{
|
||||
stageId,
|
||||
total,
|
||||
passed,
|
||||
rejected,
|
||||
manualQueue,
|
||||
},
|
||||
prisma
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an assignment.generated event when stage assignments are created.
|
||||
* Called from stage-assignment.ts after executeStageAssignment.
|
||||
*/
|
||||
export async function onAssignmentGenerated(
|
||||
jobId: string,
|
||||
stageId: string,
|
||||
assignmentCount: number,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<void> {
|
||||
await emitStageEvent(
|
||||
EVENT_TYPES.ASSIGNMENT_GENERATED,
|
||||
'AssignmentJob',
|
||||
jobId,
|
||||
actorId,
|
||||
{
|
||||
stageId,
|
||||
assignmentCount,
|
||||
},
|
||||
prisma
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a routing.executed event when a project is routed to a new track.
|
||||
* Called from routing-engine.ts after executeRouting.
|
||||
*/
|
||||
export async function onRoutingExecuted(
|
||||
ruleId: string,
|
||||
projectId: string,
|
||||
ruleName: string,
|
||||
routingMode: string,
|
||||
destinationTrackId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<void> {
|
||||
await emitStageEvent(
|
||||
EVENT_TYPES.ROUTING_EXECUTED,
|
||||
'RoutingRule',
|
||||
ruleId,
|
||||
actorId,
|
||||
{
|
||||
projectId,
|
||||
ruleName,
|
||||
routingMode,
|
||||
destinationTrackId,
|
||||
},
|
||||
prisma
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a live.cursor_updated event when the live cursor position changes.
|
||||
* Called from live-control.ts after setActiveProject or jumpToProject.
|
||||
*/
|
||||
export async function onCursorUpdated(
|
||||
cursorId: string,
|
||||
stageId: string,
|
||||
projectId: string | null,
|
||||
action: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<void> {
|
||||
await emitStageEvent(
|
||||
EVENT_TYPES.CURSOR_UPDATED,
|
||||
'LiveProgressCursor',
|
||||
cursorId,
|
||||
actorId,
|
||||
{
|
||||
stageId,
|
||||
projectId,
|
||||
action,
|
||||
},
|
||||
prisma
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a decision.overridden event when an admin overrides a pipeline decision.
|
||||
* Called from manual override handlers.
|
||||
*/
|
||||
export async function onDecisionOverridden(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
previousValue: unknown,
|
||||
newValue: unknown,
|
||||
reason: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any
|
||||
): Promise<void> {
|
||||
await emitStageEvent(
|
||||
EVENT_TYPES.DECISION_OVERRIDDEN,
|
||||
entityType,
|
||||
entityId,
|
||||
actorId,
|
||||
{
|
||||
entityType,
|
||||
previousValue,
|
||||
newValue,
|
||||
reason,
|
||||
},
|
||||
prisma
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user