MOPC-App/docs/claude-architecture-redesign/20-service-layer-changes.md

64 KiB

Service Layer Changes

Overview

This document details ALL service layer modifications required for the MOPC architecture redesign. The service layer is the orchestration tier between tRPC routers (API layer) and Prisma models (data layer). Most services are renamed (Stage → Round) and enhanced with new functionality, while a few new services are introduced.

Service Inventory

Current Service Action New Name Complexity
stage-engine.ts Rename + Simplify round-engine.ts High
stage-assignment.ts Rename + Enhance round-assignment.ts High
stage-filtering.ts Rename (minimal changes) round-filtering.ts Low
stage-notifications.ts Rename + Enhance round-notifications.ts Medium
live-control.ts Preserve + Enhance live-control.ts Medium
(new) Create submission-round-manager.ts High
(new) Create mentor-workspace.ts High
(new) Create winner-confirmation.ts High
ai-assignment.ts Enhance (jury groups) ai-assignment.ts Medium
ai-filtering.ts Rename only ai-filtering.ts Low
ai-evaluation-summary.ts Minimal ai-evaluation-summary.ts Low
ai-tagging.ts No change ai-tagging.ts None
ai-award-eligibility.ts Enhance (new modes) ai-award-eligibility.ts Medium
(new) Create ai-mentoring-insights.ts Medium
anonymization.ts No change anonymization.ts None
smart-assignment.ts Deprecated (removed) -
mentor-matching.ts Preserve mentor-matching.ts Low
in-app-notification.ts Preserve in-app-notification.ts None
email-digest.ts Preserve email-digest.ts None
evaluation-reminders.ts Preserve evaluation-reminders.ts None
webhook-dispatcher.ts Preserve webhook-dispatcher.ts None
award-eligibility-job.ts Preserve award-eligibility-job.ts None

1. Stage Engine → Round Engine

Current Architecture

The stage-engine.ts service is a state machine that manages project transitions between pipeline stages. Key components:

  1. Guard evaluation — Validates guardJson conditions on StageTransition records before allowing transitions
  2. Transition lookup — Loads StageTransition records to find valid paths from fromStageId to toStageId
  3. Track references — All operations include trackId for track-level isolation
  4. PSS lifecycle — Creates/updates ProjectStageState records with enteredAt, exitedAt, and state changes

Current function signatures:

// stage-engine.ts (CURRENT)

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
}

export async function validateTransition(
  projectId: string,
  fromStageId: string,
  toStageId: string,
  prisma: PrismaClient | any
): Promise<TransitionValidationResult>

export async function executeTransition(
  projectId: string,
  trackId: string,        // ← trackId required
  fromStageId: string,
  toStageId: string,
  newState: ProjectStageStateValue,
  actorId: string,
  prisma: PrismaClient | any
): Promise<TransitionExecutionResult>

export async function executeBatchTransition(
  projectIds: string[],
  trackId: string,        // ← trackId required
  fromStageId: string,
  toStageId: string,
  newState: ProjectStageStateValue,
  actorId: string,
  prisma: PrismaClient | any
): Promise<BatchTransitionResult>

New Architecture: Round Engine

The redesigned round-engine.ts simplifies the state machine:

  1. Linear ordering — Rounds have a sortOrder field. No guard evaluation needed — advancement is rule-based (see AdvancementRule)
  2. No StageTransition — Replaced by AdvancementRule which specifies WHERE projects advance to and WHEN
  3. No trackId — Projects belong to rounds, not track+stage combinations
  4. Simplified validation — Only checks: round existence, window constraints, and round status

New function signatures:

// round-engine.ts (NEW)

export interface RoundTransitionValidation {
  valid: boolean
  errors: string[]
  warnings?: string[]  // Non-blocking issues (e.g., "window closing soon")
}

export interface RoundTransitionResult {
  success: boolean
  projectRoundState: {
    id: string
    projectId: string
    roundId: string
    state: ProjectRoundStateValue
    enteredAt: Date
  } | null
  errors?: string[]
}

export interface BatchRoundTransitionResult {
  succeeded: string[]
  failed: Array<{ projectId: string; errors: string[] }>
  warnings: Array<{ projectId: string; warnings: string[] }>
  total: number
  advancementRuleApplied?: string  // Which AdvancementRule triggered the move
}

/**
 * Validate if a project can transition to a target round.
 * Checks:
 * 1. Source PRS exists and is not already exited
 * 2. Destination round exists and is active (not ROUND_DRAFT or ROUND_ARCHIVED)
 * 3. Window constraints on the destination round
 * 4. Project meets advancement criteria (if advancing from current round)
 *
 * No guard evaluation — advancement rules are checked separately.
 */
export async function validateRoundTransition(
  projectId: string,
  fromRoundId: string,
  toRoundId: string,
  prisma: PrismaClient | any
): Promise<RoundTransitionValidation>

/**
 * Execute a round transition for a single project atomically.
 * Within a transaction:
 * 1. Sets exitedAt on the source PRS
 * 2. Creates or updates the destination PRS with the new state
 * 3. Logs the transition in DecisionAuditLog
 * 4. Logs the transition in AuditLog
 *
 * No trackId parameter — projects are identified by projectId + roundId only.
 */
export async function executeRoundTransition(
  projectId: string,
  fromRoundId: string,
  toRoundId: string,
  newState: ProjectRoundStateValue,
  actorId: string,
  metadata?: Record<string, unknown>,  // Optional metadata (e.g., advancement rule, score threshold)
  prisma: PrismaClient | any
): Promise<RoundTransitionResult>

/**
 * Execute transitions for multiple projects in batches of 50.
 * Each project is processed independently so a failure in one does not
 * block others.
 *
 * Returns aggregated results with succeeded/failed counts and detailed errors.
 */
export async function executeBatchRoundTransition(
  projectIds: string[],
  fromRoundId: string,
  toRoundId: string,
  newState: ProjectRoundStateValue,
  actorId: string,
  metadata?: Record<string, unknown>,
  prisma: PrismaClient | any
): Promise<BatchRoundTransitionResult>

/**
 * Determine which round projects should advance to based on AdvancementRule config.
 * Replaces the old guard-based transition lookup.
 *
 * Returns the target round ID and the rule that was applied.
 */
export async function resolveAdvancementTarget(
  currentRoundId: string,
  projectId: string,
  prisma: PrismaClient | any
): Promise<{
  targetRoundId: string | null
  rule: {
    id: string
    ruleType: AdvancementRuleType
    config: Record<string, unknown>
  } | null
  eligible: boolean
  reasoning: string
}>

/**
 * Apply an advancement rule to eligible projects in a round.
 * Used for auto-advancement scenarios (e.g., "All PASSED projects advance to next round").
 */
export async function applyAdvancementRule(
  ruleId: string,
  actorId: string,
  dryRun?: boolean,  // Preview mode
  prisma: PrismaClient | any
): Promise<{
  eligible: number
  advanced: number
  errors: Array<{ projectId: string; reason: string }>
}>

State Machine Diagram

ProjectRoundState Lifecycle:

PENDING ────────────────────────────────────┐
   │                                         │
   │ (round opens, project enters)           │
   │                                         │
   ▼                                         │
IN_PROGRESS ────────────────────────────────┤
   │                                         │
   │ (evaluation/filtering/submission)       │ (admin marks
   │                                         │  as withdrawn)
   ▼                                         │
PASSED ─────────────────────────────────────┤──► WITHDRAWN
   │                                         │
   │ (meets advancement criteria)            │
   │                                         │
   ▼                                         │
COMPLETED ◄─────────────────────────────────┘
   │
   │ (advances to next round)
   │
   ▼
[Next Round: PENDING]

Alternative paths:
IN_PROGRESS ──► REJECTED (did not pass round criteria)
PENDING ──► COMPLETED (skipped round, auto-advanced)

Migration Path

  1. Create round-engine.ts with new signatures
  2. Update all imports from stage-engine to round-engine in:
    • src/server/routers/pipeline.tsround.ts
    • Admin UI components that trigger transitions
  3. Remove validateTransition calls that check for StageTransition records
  4. Replace guard logic with AdvancementRule checks (use resolveAdvancementTarget and applyAdvancementRule)
  5. Delete stage-engine.ts after verification

Code impact: ~30 files (routers, services, UI components) reference stage-engine.


2. Stage Assignment → Round Assignment

Current Architecture

The stage-assignment.ts service generates jury-to-project assignments for evaluation rounds. It uses a scoring algorithm with:

  1. Tag overlap score (max 40 points) — Juror expertise tags vs. project tags
  2. Workload balance score (max 25 points) — Favors jurors below their target workload
  3. Geo-diversity score (±5 points) — Slight penalty for same-country matches
  4. COI filtering — Skips juror-project pairs with declared conflicts
  5. Assignment coverage — Ensures each project gets requiredReviews jurors

Current function signatures:

// stage-assignment.ts (CURRENT)

export interface AssignmentConfig {
  requiredReviews: number                // Projects reviewed per juror
  minAssignmentsPerJuror: number
  maxAssignmentsPerJuror: number
  respectCOI: boolean
  geoBalancing: boolean
  expertiseMatching: boolean
}

export async function previewStageAssignment(
  stageId: string,
  config: Partial<AssignmentConfig>,
  prisma: PrismaClient | any
): Promise<PreviewResult>

export async function executeStageAssignment(
  stageId: string,
  assignments: AssignmentInput[],
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ jobId: string; created: number; errors: string[] }>

export async function getCoverageReport(
  stageId: string,
  prisma: PrismaClient | any
): Promise<CoverageReport>

export async function rebalance(
  stageId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<RebalanceSuggestion[]>

New Architecture: Round Assignment

The redesigned round-assignment.ts enhances the algorithm with:

  1. JuryGroup awareness — Assignments are scoped to a jury group, not just a round
  2. Per-juror capsJuryGroupMember.maxAssignmentsOverride and JuryGroupMember.capModeOverride (HARD, SOFT, NONE)
  3. Category ratio enforcement — New scoring component (max 10 points) for matching juror's preferred category ratio
  4. Enhanced workload scoring — Considers juror's preferredStartupRatio when calculating balance
  5. Jury group defaultsJuryGroup.defaultMaxAssignments, defaultCapMode, defaultCategoryQuotas

New scoring breakdown:

Component Max Points Description
Tag overlap 40 Expertise tag match (juror tags ∩ project tags)
Workload balance 25 Distance from target load (favors jurors below cap)
Category ratio alignment 10 NEW — How well assignment fits juror's preferred STARTUP:CONCEPT ratio
Geo-diversity ±5 Same country = -5, different country = +5
Total 80 Sum determines assignment priority

New function signatures:

// round-assignment.ts (NEW)

export interface RoundAssignmentConfig {
  requiredReviewsPerProject: number      // How many jurors per project (default: 3)

  // Cap enforcement (can be overridden per-juror)
  defaultCapMode: CapMode                // HARD | SOFT | NONE
  defaultMaxAssignments: number          // Default cap (e.g., 20)
  softCapBuffer: number                  // Extra assignments for SOFT mode (default: 2)

  // Category quotas (per juror)
  enforceCategoryQuotas: boolean         // Whether to respect per-juror min/max per category
  defaultCategoryQuotas?: {
    STARTUP: { min: number; max: number }
    BUSINESS_CONCEPT: { min: number; max: number }
  }

  // Scoring weights
  tagOverlapWeight: number               // Default: 40
  workloadWeight: number                 // Default: 25
  categoryRatioWeight: number            // Default: 10 (NEW)
  geoWeight: number                      // Default: 5

  // Filters
  respectCOI: boolean                    // Default: true
  applyGeoBalancing: boolean             // Default: true
  applyExpertiseMatching: boolean        // Default: true
}

export interface JuryGroupAssignmentContext {
  juryGroupId: string
  roundId: string
  members: Array<{
    userId: string
    name: string
    email: string
    expertiseTags: string[]
    country?: string

    // Effective limits (group default OR member override)
    maxAssignments: number
    capMode: CapMode
    categoryQuotas?: {
      STARTUP: { min: number; max: number }
      BUSINESS_CONCEPT: { min: number; max: number }
    }

    // Preferences
    preferredStartupRatio?: number       // 0.0 to 1.0 (e.g., 0.6 = 60% startups)

    // Current load
    currentAssignments: number
    currentStartupCount: number
    currentConceptCount: number
  }>
}

/**
 * Get effective assignment limits for a juror in a jury group.
 * Considers member-level overrides and group defaults.
 */
export async function getEffectiveLimits(
  juryGroupId: string,
  userId: string,
  prisma: PrismaClient | any
): Promise<{
  maxAssignments: number
  capMode: CapMode
  categoryQuotas: {
    STARTUP: { min: number; max: number }
    BUSINESS_CONCEPT: { min: number; max: number }
  } | null
}>

/**
 * Check if a juror can be assigned more projects (respects caps and quotas).
 */
export function canAssignMore(
  juror: JuryGroupAssignmentContext['members'][number],
  projectCategory: CompetitionCategory,
  config: RoundAssignmentConfig
): {
  canAssign: boolean
  reason?: string  // If false, why? (e.g., "Hard cap reached", "Category max reached")
}

/**
 * Calculate category ratio alignment score (0-10 points).
 * Rewards assignments that move juror closer to their preferred ratio.
 *
 * Example: Juror prefers 60% startups. Currently has 10 startups, 5 concepts (66.7%).
 * Assigning a concept (→ 10/6 = 62.5%) is better than assigning startup (→ 11/5 = 68.8%).
 */
export function calculateCategoryRatioScore(
  currentStartupCount: number,
  currentConceptCount: number,
  preferredStartupRatio: number | null,
  newProjectCategory: CompetitionCategory
): number

/**
 * Generate a preview of assignments for a jury group within a round.
 * Loads eligible projects (those with active PRS in the round) and the jury
 * pool, then matches them using enhanced scoring with category ratio awareness.
 */
export async function previewRoundAssignment(
  roundId: string,
  juryGroupId: string,
  config: Partial<RoundAssignmentConfig>,
  prisma: PrismaClient | any
): Promise<PreviewResult>

/**
 * Execute assignments for a round. Creates Assignment records linked to both
 * the round and the jury group, enabling cross-round jury membership tracking.
 */
export async function executeRoundAssignment(
  roundId: string,
  juryGroupId: string,
  assignments: AssignmentInput[],
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ jobId: string; created: number; errors: string[] }>

/**
 * Generate a coverage report for assignments in a round: how many projects
 * are fully covered, partially covered, unassigned, plus per-juror stats.
 *
 * Enhanced with per-juror category breakdown and quota adherence metrics.
 */
export async function getCoverageReport(
  roundId: string,
  juryGroupId: string,
  prisma: PrismaClient | any
): Promise<CoverageReport & {
  jurorCategoryStats: Array<{
    userId: string
    userName: string
    startupCount: number
    conceptCount: number
    preferredRatio: number | null
    actualRatio: number
    quotaAdherence: 'WITHIN' | 'BELOW_MIN' | 'ABOVE_MAX'
  }>
}>

/**
 * Analyze assignment distribution and suggest reassignments to balance
 * workload and enforce category quotas.
 *
 * Enhanced: Now considers category quotas and preferred ratios.
 */
export async function rebalanceAssignments(
  roundId: string,
  juryGroupId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<RebalanceSuggestion[]>

Enhanced Algorithm Pseudocode

// Enhanced assignment algorithm with jury group + category ratio awareness

for each project in round {
  const projectCategory = project.competitionCategory  // STARTUP or BUSINESS_CONCEPT

  // Load jury group members with effective limits
  const jurors = await loadJuryGroupMembers(juryGroupId)

  // Score each juror for this project
  const candidates = jurors
    .filter(juror => {
      // Existing filters
      if (hasConflictOfInterest(juror, project)) return false
      if (alreadyAssigned(juror, project)) return false

      // NEW: Check caps and quotas
      const { canAssign } = canAssignMore(juror, projectCategory, config)
      if (!canAssign) return false

      return true
    })
    .map(juror => {
      // Existing scoring
      const tagScore = calculateTagOverlapScore(juror.expertiseTags, project.tags)  // max 40
      const workloadScore = calculateWorkloadScore(
        juror.currentAssignments,
        juror.maxAssignments
      )  // max 25
      const geoScore = calculateGeoScore(juror.country, project.country)  // ±5

      // NEW: Category ratio score
      const ratioScore = calculateCategoryRatioScore(
        juror.currentStartupCount,
        juror.currentConceptCount,
        juror.preferredStartupRatio,
        projectCategory
      )  // max 10

      const totalScore = tagScore + workloadScore + ratioScore + geoScore

      return { juror, score: totalScore }
    })
    .sort((a, b) => b.score - a.score)  // Descending

  // Assign top N jurors to this project
  const needed = config.requiredReviewsPerProject
  const selected = candidates.slice(0, needed)

  for (const { juror } of selected) {
    await createAssignment(project.id, juror.userId, roundId, juryGroupId)

    // Update in-memory counts for next iteration
    juror.currentAssignments++
    if (projectCategory === 'STARTUP') {
      juror.currentStartupCount++
    } else {
      juror.currentConceptCount++
    }
  }
}

Migration Path

  1. Create round-assignment.ts with new signatures
  2. Update Assignment model references to include juryGroupId
  3. Create getEffectiveLimits utility to load per-juror settings
  4. Implement canAssignMore guard for cap/quota checks
  5. Implement calculateCategoryRatioScore scoring function
  6. Update previewRoundAssignment to use jury group context
  7. Update all routers that call assignment functions
  8. Test with varied jury group configurations (HARD caps, SOFT caps, category quotas)

Code impact: ~15 files (routers, admin UI, jury onboarding)


3. Stage Filtering → Round Filtering

Changes: Minimal (Rename Only)

The stage-filtering.ts service logic is preserved almost entirely. Only naming changes:

Current New
stageId roundId
ProjectStageState ProjectRoundState
Function names: runStageFiltering runRoundFiltering
Function names: getManualQueue (param stageId) getManualQueue (param roundId)

All filtering logic remains the same:

  • Deterministic rules (FIELD_BASED, DOCUMENT_CHECK)
  • AI screening with confidence banding
  • Duplicate submission detection
  • Manual review queue
  • Override resolution

New function signatures (only naming changes):

// round-filtering.ts (NEW — minimal changes from stage-filtering.ts)

export async function runRoundFiltering(
  roundId: string,        // ← renamed from stageId
  actorId: string,
  prisma: PrismaClient | any
): Promise<RoundFilteringResult>  // ← renamed type

export async function resolveManualDecision(
  filteringResultId: string,
  outcome: 'PASSED' | 'FILTERED_OUT',
  reason: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<void>

export async function getManualQueue(
  roundId: string,        // ← renamed from stageId
  prisma: PrismaClient | any
): Promise<ManualQueueItem[]>

Migration Path

  1. Copy stage-filtering.ts to round-filtering.ts
  2. Find/replace: stageIdroundId, StageRound, PSSPRS
  3. Update imports in routers and UI
  4. Verify FilteringResult model uses roundId foreign key
  5. Delete stage-filtering.ts

Code impact: ~8 files (1 router, 7 UI components)


4. Stage Notifications → Round Notifications

Current Architecture

The stage-notifications.ts service emits events from pipeline actions and creates in-app/email notifications. Key events:

  • stage.transitioned
  • filtering.completed
  • assignment.generated
  • live.cursor_updated
  • decision.overridden

All events:

  1. Create a DecisionAuditLog entry
  2. Check NotificationPolicy for the event type
  3. Resolve recipients (admins, jury, etc.)
  4. Create InAppNotification records
  5. Optionally send email

New Architecture: Round Notifications

Enhancements:

  1. New event types for new round types and features:

    • submission_window.opened
    • submission_window.closing_soon (deadline reminder)
    • mentoring.workspace_activated
    • mentoring.file_uploaded
    • mentoring.file_promoted
    • confirmation.proposal_created
    • confirmation.approval_requested
    • confirmation.winners_frozen
  2. Deadline reminder scheduling — New utility scheduleDeadlineReminders() that creates future notifications for:

    • 7 days before window close
    • 3 days before window close
    • 1 day before window close
    • 1 hour before window close
  3. Template system integration — Email templates for each event type (links to MinIO, jury dashboards, etc.)

New function signatures:

// round-notifications.ts (NEW)

// ─── Core Event Emitter (preserved) ───────────────────────────────────

export async function emitRoundEvent(
  eventType: string,
  entityType: string,
  entityId: string,
  actorId: string,
  details: Record<string, unknown>,
  prisma: PrismaClient | any
): Promise<void>

// ─── New Convenience Producers ────────────────────────────────────────

/**
 * Emit when a submission window opens.
 * Notifies eligible teams (those with PASSED status in previous round).
 */
export async function onSubmissionWindowOpened(
  submissionWindowId: string,
  roundId: string,
  eligibleProjectIds: string[],
  actorId: string,
  prisma: PrismaClient | any
): Promise<void>

/**
 * Emit deadline reminder for a submission window.
 * Called by a scheduled job (cron or delayed task).
 */
export async function onSubmissionDeadlineApproaching(
  submissionWindowId: string,
  roundId: string,
  daysRemaining: number,
  actorId: string,
  prisma: PrismaClient | any
): Promise<void>

/**
 * Emit when a mentoring workspace is activated for a project.
 * Notifies mentor + team members.
 */
export async function onMentoringWorkspaceActivated(
  mentorAssignmentId: string,
  projectId: string,
  mentorId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<void>

/**
 * Emit when a file is uploaded to the mentoring workspace.
 * Notifies the other party (mentor → team or team → mentor).
 */
export async function onMentoringFileUploaded(
  mentorFileId: string,
  projectId: string,
  uploadedByUserId: string,
  fileName: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<void>

/**
 * Emit when a mentor file is promoted to official submission.
 * Notifies team + admin.
 */
export async function onMentoringFilePromoted(
  mentorFileId: string,
  projectFileId: string,
  projectId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<void>

/**
 * Emit when a winner proposal is created.
 * Notifies jury members for approval.
 */
export async function onWinnerProposalCreated(
  proposalId: string,
  competitionId: string,
  category: CompetitionCategory,
  rankedProjectIds: string[],
  actorId: string,
  prisma: PrismaClient | any
): Promise<void>

/**
 * Emit when an approval is requested from a jury member.
 */
export async function onWinnerApprovalRequested(
  approvalId: string,
  proposalId: string,
  userId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<void>

/**
 * Emit when winners are frozen (results locked).
 * Notifies all admins and jury members.
 */
export async function onWinnersFrozen(
  proposalId: string,
  competitionId: string,
  category: CompetitionCategory,
  actorId: string,
  prisma: PrismaClient | any
): Promise<void>

// ─── Deadline Reminder Scheduling ────────────────────────────────────

/**
 * Schedule deadline reminders for a submission window.
 * Creates delayed notifications (or cron jobs) for 7d, 3d, 1d, 1h before close.
 *
 * Implementation: Can use Vercel Cron, BullMQ, or simple setTimeout for short windows.
 */
export async function scheduleDeadlineReminders(
  submissionWindowId: string,
  windowCloseAt: Date,
  reminderDays: number[],  // e.g., [7, 3, 1]
  prisma: PrismaClient | any
): Promise<void>

/**
 * Cancel scheduled reminders (if window is extended or closed early).
 */
export async function cancelDeadlineReminders(
  submissionWindowId: string,
  prisma: PrismaClient | any
): Promise<void>

Recipient Resolution Enhancements

New recipient resolution logic for new event types:

// Enhanced recipient resolution (internal helper)

async function resolveRecipients(
  eventType: string,
  details: Record<string, unknown>,
  prisma: PrismaClient | any
): Promise<NotificationTarget[]> {
  switch (eventType) {
    case 'submission_window.opened':
    case 'submission_window.closing_soon': {
      // Notify eligible project team members
      const projectIds = details.eligibleProjectIds as string[]
      const teamMembers = await prisma.teamMember.findMany({
        where: { projectId: { in: projectIds } },
        include: { user: { select: { id: true, name: true, email: true } } }
      })
      return teamMembers.map(tm => ({
        userId: tm.user.id,
        name: tm.user.name ?? 'Team Member',
        email: tm.user.email
      }))
    }

    case 'mentoring.workspace_activated':
    case 'mentoring.file_uploaded':
    case 'mentoring.file_promoted': {
      // Notify mentor + team members
      const projectId = details.projectId as string
      const mentorId = details.mentorId as string

      const [mentor, teamMembers] = await Promise.all([
        prisma.user.findUnique({
          where: { id: mentorId },
          select: { id: true, name: true, email: true }
        }),
        prisma.teamMember.findMany({
          where: { projectId },
          include: { user: { select: { id: true, name: true, email: true } } }
        })
      ])

      return [
        { userId: mentor.id, name: mentor.name ?? 'Mentor', email: mentor.email },
        ...teamMembers.map(tm => ({
          userId: tm.user.id,
          name: tm.user.name ?? 'Team Member',
          email: tm.user.email
        }))
      ]
    }

    case 'confirmation.proposal_created':
    case 'confirmation.approval_requested': {
      // Notify jury members who need to approve
      const proposalId = details.proposalId as string
      const approvals = await prisma.winnerApproval.findMany({
        where: { winnerProposalId: proposalId },
        include: { user: { select: { id: true, name: true, email: true } } }
      })
      return approvals.map(a => ({
        userId: a.user.id,
        name: a.user.name ?? 'Jury Member',
        email: a.user.email
      }))
    }

    case 'confirmation.winners_frozen': {
      // Notify all admins + jury group members
      const competitionId = details.competitionId as string
      const juryGroups = await prisma.juryGroup.findMany({
        where: { competitionId },
        include: {
          members: {
            include: { user: { select: { id: true, name: true, email: true } } }
          }
        }
      })

      const admins = await prisma.user.findMany({
        where: { role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] } },
        select: { id: true, name: true, email: true }
      })

      const juryMembers = juryGroups.flatMap(jg =>
        jg.members.map(m => ({
          userId: m.user.id,
          name: m.user.name ?? 'Jury Member',
          email: m.user.email
        }))
      )

      return [
        ...admins.map(a => ({ userId: a.id, name: a.name ?? 'Admin', email: a.email })),
        ...juryMembers
      ]
    }

    // Existing cases preserved (stage.transitioned, filtering.completed, etc.)
    default:
      return resolveDefaultRecipients(eventType, details, prisma)
  }
}

Migration Path

  1. Copy stage-notifications.ts to round-notifications.ts
  2. Rename references: stageIdroundId, StageRound
  3. Add new event producers for submission windows, mentoring, confirmation
  4. Implement scheduleDeadlineReminders (use Vercel Cron or BullMQ)
  5. Update recipient resolution with new cases
  6. Create email templates for new event types
  7. Delete stage-notifications.ts

Code impact: ~20 files (routers, services that emit events)


5. Live Control — Enhanced

Current Architecture

The live-control.ts service manages real-time control of live final events:

  • Session management (start/pause/resume)
  • Cursor navigation (setActiveProject, jumpToProject)
  • Queue reordering
  • Cohort window management (open/close voting windows)

Enhancements

  1. Category-based cohorts — Enhanced cohort management to handle per-category voting windows:

    • openCohortWindowForCategory(cohortId, category, actorId) — Open voting only for STARTUP or BUSINESS_CONCEPT projects
    • closeCohortWindowForCategory(cohortId, category, actorId) — Close voting for a category
  2. Stage manager enhancements — New UI utilities:

    • getCurrentCohortStatus(roundId) — Returns active cohort, remaining projects, voting status per category
    • getNextProject(roundId, currentProjectId) — Smart navigation (skip to next un-voted project)
  3. Audience participation tracking — Integration with AudienceVoter for real-time vote counts

New function signatures:

// live-control.ts (ENHANCED)

// ─── Existing functions preserved ────────────────────────────────────
// startSession, setActiveProject, jumpToProject, reorderQueue, pauseResume
// openCohortWindow, closeCohortWindow

// ─── New: Category-specific cohort windows ───────────────────────────

/**
 * Open voting for a specific category within a cohort.
 * Useful for staggered voting (e.g., "Startups vote now, Concepts later").
 */
export async function openCohortWindowForCategory(
  cohortId: string,
  category: CompetitionCategory,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Close voting for a specific category within a cohort.
 */
export async function closeCohortWindowForCategory(
  cohortId: string,
  category: CompetitionCategory,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

// ─── New: Stage manager utilities ────────────────────────────────────

/**
 * Get the current status of live session (for stage manager UI).
 * Returns: active project, cohort status, vote counts, next project preview.
 */
export async function getCurrentCohortStatus(
  roundId: string,
  prisma: PrismaClient | any
): Promise<{
  sessionId: string | null
  isPaused: boolean
  activeProjectId: string | null
  activeProjectTitle: string | null
  activeCohort: {
    id: string
    name: string
    isOpen: boolean
    categoryWindows: Array<{
      category: CompetitionCategory
      isOpen: boolean
      voteCount: number
    }>
  } | null
  queueStatus: {
    totalProjects: number
    currentIndex: number
    remaining: number
  }
  nextProject: {
    id: string
    title: string
    category: CompetitionCategory
  } | null
}>

/**
 * Navigate to the next project in the queue.
 * Optionally skip projects that have already been voted on.
 */
export async function getNextProject(
  roundId: string,
  currentProjectId: string | null,
  skipVoted?: boolean,
  prisma: PrismaClient | any
): Promise<{
  projectId: string | null
  projectTitle: string | null
  orderIndex: number | null
}>

/**
 * Get real-time vote counts for the active project (jury + audience).
 */
export async function getActiveProjectVotes(
  roundId: string,
  projectId: string,
  prisma: PrismaClient | any
): Promise<{
  juryVotes: Array<{
    userId: string
    userName: string
    score: number | null
    votedAt: Date | null
  }>
  audienceVotes: {
    totalVotes: number
    averageScore: number
  }
  combined: {
    totalScore: number
    averageScore: number
  }
}>

Migration Path

  1. Update live-control.ts with new functions
  2. Add Cohort.categoryWindowStatus JSON field (optional — stores per-category open/close state)
  3. Create getCurrentCohortStatus utility for stage manager UI
  4. Implement getNextProject navigation with skip logic
  5. Update stage manager UI to use new utilities

Code impact: ~5 files (1 service, 1 router, 3 UI components)


6. NEW: Submission Round Manager

Purpose

The submission-round-manager.ts service manages the lifecycle of multi-round submission windows. It handles:

  1. Window lifecycle — Opening, closing, locking, extending submission windows
  2. File requirement validation — Ensuring teams upload all required files
  3. Deadline enforcement — HARD (reject), FLAG (accept but mark late), GRACE (accept during grace period)
  4. Window locking — When a new window opens, previous windows lock for applicants (jury can still view)

Complete function signatures:

// submission-round-manager.ts (NEW)

export interface SubmissionWindowStatus {
  id: string
  name: string
  roundNumber: number
  isOpen: boolean
  isLocked: boolean
  windowOpenAt: Date | null
  windowCloseAt: Date | null
  deadlinePolicy: DeadlinePolicy
  graceHours: number | null
  graceEndsAt: Date | null  // Calculated: windowCloseAt + graceHours
  requirements: Array<{
    id: string
    name: string
    description: string | null
    acceptedMimeTypes: string[]
    maxSizeMB: number | null
    isRequired: boolean
    sortOrder: number
  }>
  stats: {
    totalProjects: number
    completeSubmissions: number
    partialSubmissions: number
    lateSubmissions: number
  }
}

export interface SubmissionValidation {
  valid: boolean
  errors: string[]
  warnings: string[]
  missingRequirements: Array<{
    requirementId: string
    name: string
  }>
}

/**
 * Open a submission window. Sets windowOpenAt to now, locks previous windows.
 */
export async function openSubmissionWindow(
  submissionWindowId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Close a submission window. Sets windowCloseAt to now.
 * If deadlinePolicy is HARD or GRACE (with grace expired), further submissions are rejected.
 */
export async function closeSubmissionWindow(
  submissionWindowId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Extend a submission window deadline.
 * Updates windowCloseAt and recalculates grace period if applicable.
 */
export async function extendSubmissionWindow(
  submissionWindowId: string,
  newCloseAt: Date,
  reason: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Lock a submission window for applicants (admin override).
 * Locked windows are read-only for teams but visible to jury.
 */
export async function lockSubmissionWindow(
  submissionWindowId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Unlock a submission window (admin override).
 */
export async function unlockSubmissionWindow(
  submissionWindowId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Check if a project has submitted all required files for a window.
 * Returns validation result with missing requirements.
 */
export async function validateSubmission(
  projectId: string,
  submissionWindowId: string,
  prisma: PrismaClient | any
): Promise<SubmissionValidation>

/**
 * Enforce deadline policy when a file is uploaded.
 * Returns whether the upload should be accepted or rejected.
 */
export async function checkDeadlinePolicy(
  submissionWindowId: string,
  uploadedAt: Date,
  prisma: PrismaClient | any
): Promise<{
  accepted: boolean
  isLate: boolean
  reason?: string  // If rejected, why?
}>

/**
 * Get status of a submission window including requirements and stats.
 */
export async function getSubmissionWindowStatus(
  submissionWindowId: string,
  prisma: PrismaClient | any
): Promise<SubmissionWindowStatus>

/**
 * Get all submission windows for a competition, ordered by roundNumber.
 */
export async function listSubmissionWindows(
  competitionId: string,
  prisma: PrismaClient | any
): Promise<SubmissionWindowStatus[]>

/**
 * Lock all previous submission windows when a new window opens.
 * Called automatically by openSubmissionWindow.
 */
async function lockPreviousWindows(
  competitionId: string,
  currentRoundNumber: number,
  prisma: PrismaClient | any
): Promise<void>

Deadline Policy Logic

// Deadline enforcement logic

async function checkDeadlinePolicy(
  submissionWindowId: string,
  uploadedAt: Date,
  prisma: PrismaClient | any
): Promise<{ accepted: boolean; isLate: boolean; reason?: string }> {
  const window = await prisma.submissionWindow.findUnique({
    where: { id: submissionWindowId }
  })

  if (!window) {
    return { accepted: false, isLate: false, reason: 'Submission window not found' }
  }

  // Before window opens
  if (window.windowOpenAt && uploadedAt < window.windowOpenAt) {
    return { accepted: false, isLate: false, reason: 'Submission window has not opened yet' }
  }

  // Window still open
  if (!window.windowCloseAt || uploadedAt <= window.windowCloseAt) {
    return { accepted: true, isLate: false }
  }

  // After window closes
  switch (window.deadlinePolicy) {
    case 'HARD':
      return {
        accepted: false,
        isLate: true,
        reason: 'Submission deadline has passed (HARD policy)'
      }

    case 'FLAG':
      return {
        accepted: true,
        isLate: true  // Accepted but marked as late
      }

    case 'GRACE': {
      if (!window.graceHours) {
        return { accepted: false, isLate: true, reason: 'Grace period not configured' }
      }

      const graceEndsAt = new Date(window.windowCloseAt.getTime() + window.graceHours * 60 * 60 * 1000)

      if (uploadedAt <= graceEndsAt) {
        return { accepted: true, isLate: true }  // Within grace period
      } else {
        return {
          accepted: false,
          isLate: true,
          reason: `Grace period ended at ${graceEndsAt.toISOString()}`
        }
      }
    }

    default:
      return { accepted: false, isLate: true, reason: 'Unknown deadline policy' }
  }
}

Integration Points

  • Called by: project.uploadFile router (before accepting uploads)
  • Calls: round-notifications.onSubmissionWindowOpened, scheduleDeadlineReminders
  • Emits events: submission_window.opened, submission_window.closed, submission_window.extended

Code impact: New file + 2 router endpoints + 3 UI components


7. NEW: Mentor Workspace Service

Purpose

The mentor-workspace.ts service manages the mentoring workspace where mentors and teams collaborate. It handles:

  1. File upload to workspace — Mentors and teams upload working files (drafts, notes, feedback)
  2. Threaded comments — Both parties can comment on files (with replies)
  3. File promotion — Mentors can promote workspace files to official submissions
  4. Activity tracking — Last viewed timestamps, unread counts

Complete function signatures:

// mentor-workspace.ts (NEW)

export interface WorkspaceFile {
  id: string
  fileName: string
  mimeType: string
  size: number
  description: string | null
  uploadedByUserId: string
  uploadedByName: string
  uploadedAt: Date
  isPromoted: boolean
  promotedToFileId: string | null
  promotedAt: Date | null
  commentCount: number
  unreadComments: number  // For current user
}

export interface WorkspaceComment {
  id: string
  authorId: string
  authorName: string
  content: string
  createdAt: Date
  updatedAt: Date
  parentCommentId: string | null
  replies: WorkspaceComment[]  // Nested replies
}

/**
 * Activate the workspace for a mentor assignment.
 * Sets workspaceEnabled = true and workspaceOpenAt.
 * Called when a MENTORING round opens.
 */
export async function activateWorkspace(
  mentorAssignmentId: string,
  workspaceOpenAt: Date,
  workspaceCloseAt: Date | null,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Deactivate the workspace (close access).
 * Sets workspaceCloseAt to now.
 */
export async function deactivateWorkspace(
  mentorAssignmentId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Upload a file to the workspace.
 * Can be uploaded by mentor or team member.
 */
export async function uploadWorkspaceFile(
  mentorAssignmentId: string,
  uploadedByUserId: string,
  file: {
    fileName: string
    mimeType: string
    size: number
    bucket: string
    objectKey: string
    description?: string
  },
  prisma: PrismaClient | any
): Promise<{ fileId: string; errors?: string[] }>

/**
 * Delete a workspace file.
 * Only the uploader or an admin can delete.
 */
export async function deleteWorkspaceFile(
  fileId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Add a comment to a workspace file.
 * Supports threaded replies via parentCommentId.
 */
export async function addFileComment(
  fileId: string,
  authorId: string,
  content: string,
  parentCommentId?: string,
  prisma: PrismaClient | any
): Promise<{ commentId: string; errors?: string[] }>

/**
 * Update a comment (edit).
 * Only the author can edit.
 */
export async function updateFileComment(
  commentId: string,
  authorId: string,
  newContent: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Delete a comment.
 * Only the author or an admin can delete.
 */
export async function deleteFileComment(
  commentId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Promote a workspace file to an official submission.
 * Creates a ProjectFile record linked to a SubmissionWindow.
 * Only mentors can promote.
 */
export async function promoteFileToSubmission(
  fileId: string,
  submissionWindowId: string,
  requirementId: string | null,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{
  projectFileId: string
  errors?: string[]
}>

/**
 * Get all files in a workspace with comment counts and unread status.
 */
export async function getWorkspaceFiles(
  mentorAssignmentId: string,
  currentUserId: string,
  prisma: PrismaClient | any
): Promise<WorkspaceFile[]>

/**
 * Get all comments for a file, nested by thread.
 */
export async function getFileComments(
  fileId: string,
  prisma: PrismaClient | any
): Promise<WorkspaceComment[]>

/**
 * Mark comments as read for the current user.
 */
export async function markCommentsAsRead(
  fileId: string,
  userId: string,
  prisma: PrismaClient | any
): Promise<void>

File Promotion Logic

When a mentor promotes a workspace file to an official submission:

async function promoteFileToSubmission(
  fileId: string,
  submissionWindowId: string,
  requirementId: string | null,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ projectFileId: string; errors?: string[] }> {
  const mentorFile = await prisma.mentorFile.findUnique({
    where: { id: fileId },
    include: { mentorAssignment: true }
  })

  if (!mentorFile) {
    return { projectFileId: '', errors: ['File not found'] }
  }

  if (mentorFile.isPromoted) {
    return { projectFileId: '', errors: ['File has already been promoted'] }
  }

  // Verify actor is the mentor
  if (actorId !== mentorFile.mentorAssignment.mentorId) {
    return { projectFileId: '', errors: ['Only the mentor can promote files'] }
  }

  const result = await prisma.$transaction(async (tx: any) => {
    // Create ProjectFile
    const projectFile = await tx.projectFile.create({
      data: {
        projectId: mentorFile.mentorAssignment.projectId,
        submissionWindowId,
        requirementId,
        fileType: 'SUBMISSION_DOCUMENT',
        fileName: mentorFile.fileName,
        mimeType: mentorFile.mimeType,
        size: mentorFile.size,
        bucket: mentorFile.bucket,
        objectKey: mentorFile.objectKey,
        isLate: false,  // Promotions are never late
        version: 1,
      }
    })

    // Update MentorFile
    await tx.mentorFile.update({
      where: { id: fileId },
      data: {
        isPromoted: true,
        promotedToFileId: projectFile.id,
        promotedAt: new Date(),
        promotedByUserId: actorId,
      }
    })

    // Audit log
    await tx.decisionAuditLog.create({
      data: {
        eventType: 'mentoring.file_promoted',
        entityType: 'MentorFile',
        entityId: fileId,
        actorId,
        detailsJson: {
          projectFileId: projectFile.id,
          projectId: mentorFile.mentorAssignment.projectId,
          submissionWindowId,
        }
      }
    })

    return projectFile
  })

  // Emit notification
  await onMentoringFilePromoted(
    fileId,
    result.id,
    mentorFile.mentorAssignment.projectId,
    actorId,
    prisma
  )

  return { projectFileId: result.id, errors: [] }
}

Integration Points

  • Called by: mentor.uploadFile, mentor.promoteFile routers
  • Calls: round-notifications.onMentoringFileUploaded, onMentoringFilePromoted
  • Emits events: mentoring.workspace_activated, mentoring.file_uploaded, mentoring.file_promoted

Code impact: New file + 5 router endpoints + 4 UI components (mentor dashboard, workspace viewer, file uploader, comment thread)


8. NEW: Winner Confirmation Service

Purpose

The winner-confirmation.ts service orchestrates the multi-party winner agreement process for the CONFIRMATION round. It handles:

  1. Proposal generation — Create WinnerProposal with ranked project IDs (from scores, admin selection, or AI recommendation)
  2. Approval processing — Track jury member approvals/rejections
  3. Override logic — Admin can override with FORCE_MAJORITY or ADMIN_DECISION
  4. Freeze mechanism — Lock results once approved (or overridden)

Complete function signatures:

// winner-confirmation.ts (NEW)

export interface WinnerProposalData {
  competitionId: string
  category: CompetitionCategory
  rankedProjectIds: string[]  // Ordered: 1st, 2nd, 3rd
  sourceRoundId: string        // Which round's scores/votes informed this
  selectionBasis: {
    method: 'SCORE_BASED' | 'LIVE_VOTE_BASED' | 'AI_RECOMMENDED' | 'ADMIN_SELECTION'
    scores?: Array<{ projectId: string; score: number }>
    aiRecommendation?: {
      confidenceScore: number
      reasoning: string
    }
    reasoning: string
  }
}

export interface ApprovalStatus {
  approvalId: string
  userId: string
  userName: string
  role: WinnerApprovalRole
  approved: boolean | null
  comments: string | null
  respondedAt: Date | null
}

export interface ProposalStatus {
  proposalId: string
  category: CompetitionCategory
  status: WinnerProposalStatus
  rankedProjectIds: string[]
  rankedProjects: Array<{
    id: string
    title: string
    teamName: string | null
    rank: number  // 1, 2, 3
  }>
  selectionBasis: Record<string, unknown>
  approvals: ApprovalStatus[]
  overrideUsed: boolean
  overrideMode: string | null
  overrideReason: string | null
  frozenAt: Date | null
}

/**
 * Generate a winner proposal from a round's results.
 * Creates WinnerProposal and WinnerApproval records for each jury member.
 */
export async function generateWinnerProposal(
  proposalData: WinnerProposalData,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ proposalId: string; errors?: string[] }>

/**
 * Submit an approval or rejection from a jury member.
 */
export async function submitApproval(
  approvalId: string,
  userId: string,
  approved: boolean,
  comments: string | null,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Check if a proposal has enough approvals to proceed.
 * Returns approval status and whether auto-freeze should happen.
 */
export async function checkApprovalStatus(
  proposalId: string,
  prisma: PrismaClient | any
): Promise<{
  allApproved: boolean
  anyRejected: boolean
  pendingCount: number
  shouldAutoFreeze: boolean
}>

/**
 * Override a proposal (force approval or make admin decision).
 * Modes:
 * - FORCE_MAJORITY: Ignore dissenting votes, use majority rule
 * - ADMIN_DECISION: Admin unilaterally sets winners
 */
export async function overrideProposal(
  proposalId: string,
  overrideMode: 'FORCE_MAJORITY' | 'ADMIN_DECISION',
  overrideReason: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Freeze a proposal (lock results, make official).
 * Can only freeze if:
 * - All approvals received OR
 * - Override has been applied
 */
export async function freezeProposal(
  proposalId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Unfreeze a proposal (admin undo).
 * Allows re-opening the approval process.
 */
export async function unfreezeProposal(
  proposalId: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }>

/**
 * Get the status of a winner proposal including approval progress.
 */
export async function getProposalStatus(
  proposalId: string,
  prisma: PrismaClient | any
): Promise<ProposalStatus>

/**
 * List all proposals for a competition (both categories).
 */
export async function listProposals(
  competitionId: string,
  prisma: PrismaClient | any
): Promise<ProposalStatus[]>

Approval Logic

// Check approval status and determine if auto-freeze should happen

async function checkApprovalStatus(
  proposalId: string,
  prisma: PrismaClient | any
): Promise<{
  allApproved: boolean
  anyRejected: boolean
  pendingCount: number
  shouldAutoFreeze: boolean
}> {
  const proposal = await prisma.winnerProposal.findUnique({
    where: { id: proposalId },
    include: {
      approvals: {
        where: { role: 'JURY_MEMBER' }
      }
    }
  })

  if (!proposal) {
    return {
      allApproved: false,
      anyRejected: false,
      pendingCount: 0,
      shouldAutoFreeze: false
    }
  }

  const approvals = proposal.approvals
  const approved = approvals.filter(a => a.approved === true)
  const rejected = approvals.filter(a => a.approved === false)
  const pending = approvals.filter(a => a.approved === null)

  const allApproved = pending.length === 0 && rejected.length === 0 && approved.length === approvals.length
  const anyRejected = rejected.length > 0

  // Auto-freeze if all approved and config allows
  const roundConfig = await prisma.round.findUnique({
    where: { id: proposal.sourceRoundId },
    select: { configJson: true }
  })

  const config = (roundConfig?.configJson as Record<string, unknown>) ?? {}
  const autoFreezeOnApproval = (config.autoFreezeOnApproval as boolean) ?? true

  const shouldAutoFreeze = allApproved && autoFreezeOnApproval

  return {
    allApproved,
    anyRejected,
    pendingCount: pending.length,
    shouldAutoFreeze
  }
}

Override Logic

// Apply admin override to a proposal

async function overrideProposal(
  proposalId: string,
  overrideMode: 'FORCE_MAJORITY' | 'ADMIN_DECISION',
  overrideReason: string,
  actorId: string,
  prisma: PrismaClient | any
): Promise<{ success: boolean; errors?: string[] }> {
  const proposal = await prisma.winnerProposal.findUnique({
    where: { id: proposalId },
    include: { approvals: true }
  })

  if (!proposal) {
    return { success: false, errors: ['Proposal not found'] }
  }

  if (proposal.status === 'FROZEN') {
    return { success: false, errors: ['Proposal is already frozen'] }
  }

  await prisma.$transaction(async (tx: any) => {
    // Update proposal
    await tx.winnerProposal.update({
      where: { id: proposalId },
      data: {
        status: 'OVERRIDDEN',
        overrideUsed: true,
        overrideMode,
        overrideReason,
        overrideById: actorId,
      }
    })

    // Create override action
    await tx.overrideAction.create({
      data: {
        entityType: 'WinnerProposal',
        entityId: proposalId,
        previousValue: { status: proposal.status },
        newValueJson: { status: 'OVERRIDDEN', overrideMode },
        reasonCode: overrideMode,
        reasonText: overrideReason,
        actorId,
      }
    })

    // Audit log
    await tx.decisionAuditLog.create({
      data: {
        eventType: 'confirmation.proposal_overridden',
        entityType: 'WinnerProposal',
        entityId: proposalId,
        actorId,
        detailsJson: {
          overrideMode,
          overrideReason,
          competitionId: proposal.competitionId,
          category: proposal.category,
        }
      }
    })
  })

  return { success: true }
}

Integration Points

  • Called by: confirmation.generateProposal, confirmation.submitApproval, confirmation.override, confirmation.freeze routers
  • Calls: round-notifications.onWinnerProposalCreated, onWinnerApprovalRequested, onWinnersFrozen
  • Emits events: confirmation.proposal_created, confirmation.approval_requested, confirmation.winners_frozen

Code impact: New file + 6 router endpoints + 5 UI components (proposal creator, approval dashboard, override dialog, results viewer, freeze button)


9. AI Services Changes

9.1 AI Assignment — Enhanced with Jury Groups

Changes:

  1. Add juryGroupId context to assignment prompts
  2. Include per-juror cap/quota info in anonymized data
  3. Enhanced reasoning to explain category ratio matches

Modified function signature:

// ai-assignment.ts (ENHANCED)

export async function generateAIAssignments(
  roundId: string,
  juryGroupId: string,        // ← NEW parameter
  config: Partial<AssignmentConfig>,
  userId?: string,
  onProgress?: AssignmentProgressCallback,
  prisma?: PrismaClient | any
): Promise<AIAssignmentResult>

// Internal: Enhanced anonymization to include jury group context
interface AnonymizedJuror {
  id: string  // Anonymized ID
  expertiseTags: string[]
  currentLoad: number
  maxAssignments: number
  capMode: 'HARD' | 'SOFT' | 'NONE'  // ← NEW
  categoryQuotas?: {                 // ← NEW
    STARTUP: { min: number; max: number }
    BUSINESS_CONCEPT: { min: number; max: number }
  }
  preferredStartupRatio?: number     // ← NEW
}

Migration: Update 1 function, add juryGroupId to all calls.

9.2 AI Filtering — Rename Only

Changes: Rename stageIdroundId in all function signatures. No logic changes.

Migration: Find/replace in file.

9.3 AI Evaluation Summary — Minimal

Changes: Rename stageIdroundId. Logic preserved.

Migration: Find/replace in file.

9.4 AI Tagging — No Changes

Preserved as-is. Used for auto-tagging projects based on descriptions.

9.5 AI Award Eligibility — Enhanced

Changes:

  1. Support new AwardEligibilityMode enum values: STAY_IN_MAIN, SEPARATE_POOL
  2. Enhanced prompt to distinguish between modes
  3. For STAY_IN_MAIN: AI flags projects as award-eligible without removing from main competition
  4. For SEPARATE_POOL: AI recommends pulling projects out of main flow

Modified function signature:

// ai-award-eligibility.ts (ENHANCED)

export async function evaluateAwardEligibility(
  awardId: string,
  projectIds: string[],
  eligibilityMode: AwardEligibilityMode,  // ← NEW parameter
  userId?: string,
  prisma?: PrismaClient | any
): Promise<AIAwardEligibilityResult>

// Updated result type
export interface AIAwardEligibilityResult {
  success: boolean
  eligibleProjects: Array<{
    projectId: string
    confidenceScore: number
    reasoning: string
    recommendPullFromMain?: boolean  // ← NEW (for SEPARATE_POOL mode)
  }>
  error?: string
  tokensUsed?: number
}

Migration: Add eligibilityMode parameter to all calls.

9.6 NEW: AI Mentoring Insights

Purpose: Generate AI-powered insights for mentors based on project data and progress.

Complete function signatures:

// ai-mentoring-insights.ts (NEW)

export interface MentoringInsight {
  insightType: 'STRENGTH' | 'WEAKNESS' | 'RECOMMENDATION' | 'MILESTONE'
  title: string
  description: string
  priority: 'HIGH' | 'MEDIUM' | 'LOW'
  actionable: boolean
  suggestedActions?: string[]
}

export interface AIMentoringInsightsResult {
  success: boolean
  insights: MentoringInsight[]
  overallAssessment: {
    readinessScore: number  // 0-10
    strengths: string[]
    areasForImprovement: string[]
    suggestedFocus: string
  }
  error?: string
  tokensUsed?: number
}

/**
 * Generate mentoring insights for a project based on:
 * - Project description and submission files
 * - Evaluation scores and feedback (if available)
 * - Mentor notes and messages
 * - Milestone completion status
 *
 * Returns actionable recommendations for the mentor to guide the team.
 */
export async function generateMentoringInsights(
  projectId: string,
  mentorAssignmentId: string,
  userId?: string,
  prisma?: PrismaClient | any
): Promise<AIMentoringInsightsResult>

Integration: Called by mentor.getInsights router.

Code impact: New file + 1 router endpoint + 1 UI component (insights panel)


10. Utility Services (Minimal/No Changes)

10.1 Anonymization — No Changes

Preserved as-is. Handles GDPR-compliant anonymization before AI calls.

10.2 Smart Assignment — Deprecated

Action: Delete smart-assignment.ts. All assignment logic now in round-assignment.ts with jury group awareness.

10.3 Mentor Matching — Preserve

Changes: None. Existing mentor-to-project matching logic preserved.

10.4 In-App Notification — Preserve

Changes: None. Notification creation and delivery preserved.

10.5 Email Digest — Preserve

Changes: None. Daily/weekly digest emails preserved.

10.6 Evaluation Reminders — Preserve

Changes: Rename stageIdroundId in queries. Logic preserved.

10.7 Webhook Dispatcher — Preserve

Changes: None. Webhook delivery for external integrations preserved.

10.8 Award Eligibility Job — Preserve

Changes: Update to use new AwardEligibilityMode. Otherwise preserved.


11. Service Dependency Graph

round-engine
  ├─ calls: round-notifications.onRoundTransitioned
  └─ used by: round router, admin UI

round-assignment
  ├─ calls: ai-assignment.generateAIAssignments
  ├─ calls: round-notifications.onAssignmentGenerated
  └─ used by: round router, jury router, admin UI

round-filtering
  ├─ calls: ai-filtering.runAIScreening
  ├─ calls: round-notifications.onFilteringCompleted
  └─ used by: round router, admin UI

round-notifications
  ├─ calls: email service, in-app-notification service
  └─ used by: ALL services (event emitter)

live-control
  ├─ calls: round-notifications.onCursorUpdated
  └─ used by: live router, stage manager UI

submission-round-manager
  ├─ calls: round-notifications.onSubmissionWindowOpened
  ├─ calls: round-notifications.scheduleDeadlineReminders
  └─ used by: submission router, applicant UI

mentor-workspace
  ├─ calls: round-notifications.onMentoringFileUploaded
  ├─ calls: round-notifications.onMentoringFilePromoted
  └─ used by: mentor router, applicant UI

winner-confirmation
  ├─ calls: round-notifications.onWinnerProposalCreated
  ├─ calls: round-notifications.onWinnersFrozen
  └─ used by: confirmation router, jury UI, admin UI

ai-assignment
  ├─ calls: anonymization.anonymizeForAI
  └─ used by: round-assignment

ai-filtering
  ├─ calls: anonymization.anonymizeProjectsForAI
  └─ used by: round-filtering

ai-mentoring-insights
  ├─ calls: anonymization.anonymizeProjectsForAI
  └─ used by: mentor router

12. Migration Order

To minimize breakage, migrate services in this order:

Phase 1: Foundation (No Dependencies)

  1. anonymization.ts — No changes needed
  2. round-notifications.ts — Copy from stage-notifications, rename, add new event types
  3. ai-filtering.ts — Rename from stage-filtering (AI layer)

Phase 2: Core Services (Depend on Phase 1)

  1. round-engine.ts — Create new, simplified state machine
  2. round-filtering.ts — Rename from stage-filtering, use ai-filtering
  3. round-assignment.ts — Enhance from stage-assignment, use ai-assignment

Phase 3: New Services (Depend on Phase 1-2)

  1. submission-round-manager.ts — Create new
  2. mentor-workspace.ts — Create new
  3. winner-confirmation.ts — Create new

Phase 4: Enhanced Services

  1. live-control.ts — Enhance existing
  2. ai-assignment.ts — Enhance with jury groups
  3. ai-award-eligibility.ts — Enhance with new modes
  4. ai-mentoring-insights.ts — Create new

Phase 5: Cleanup

  1. Delete deprecated services:
    • stage-engine.ts
    • stage-assignment.ts
    • stage-filtering.ts
    • stage-notifications.ts
    • smart-assignment.ts

Summary

Category Count Action
Renamed services 4 stage-* → round-*
Enhanced services 4 round-assignment, live-control, ai-assignment, ai-award-eligibility
New services 4 submission-round-manager, mentor-workspace, winner-confirmation, ai-mentoring-insights
Preserved services 7 anonymization, mentor-matching, in-app-notification, email-digest, evaluation-reminders, webhook-dispatcher, award-eligibility-job
Deprecated services 1 smart-assignment
Total service files 19 (was 20, now 19)

Lines of code impact:

  • New code: ~2,500 lines (4 new services)
  • Modified code: ~1,200 lines (enhancements)
  • Renamed code: ~1,800 lines (copy-paste with renames)
  • Deleted code: ~400 lines (deprecated service)
  • Net change: +3,300 lines

Estimated development time:

  • Phase 1-2: 3 days (foundation + core)
  • Phase 3: 5 days (new services)
  • Phase 4: 3 days (enhancements)
  • Phase 5: 1 day (cleanup)
  • Total: 12 days (2.4 weeks)

Risk areas:

  1. Round engine simplification — Ensure advancement rules fully replace guard logic
  2. Jury group assignment scoring — Test category ratio calculations with edge cases
  3. Deadline policy enforcement — Verify HARD/FLAG/GRACE logic in all scenarios
  4. Winner confirmation approval flow — Test multi-jury approval + override paths
  5. Notification event cascades — Ensure no infinite loops or missing recipients