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

2186 lines
64 KiB
Markdown

# 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:**
```typescript
// 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:**
```typescript
// 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.ts``round.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:**
```typescript
// 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 caps**`JuryGroupMember.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 defaults**`JuryGroup.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:**
```typescript
// 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
```typescript
// 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):**
```typescript
// 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:** `stageId``roundId`, `Stage``Round`, `PSS``PRS`
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:**
```typescript
// 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:
```typescript
// 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:** `stageId``roundId`, `Stage``Round`
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:**
```typescript
// 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:**
```typescript
// 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
```typescript
// 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:**
```typescript
// 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:
```typescript
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:**
```typescript
// 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
```typescript
// 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
```typescript
// 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:**
```typescript
// 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 `stageId``roundId` in all function signatures. No logic changes.
**Migration:** Find/replace in file.
### 9.3 AI Evaluation Summary — Minimal
**Changes:** Rename `stageId``roundId`. 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:**
```typescript
// 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:**
```typescript
// 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 `stageId``roundId` 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)
4. **round-engine.ts** — Create new, simplified state machine
5. **round-filtering.ts** — Rename from stage-filtering, use ai-filtering
6. **round-assignment.ts** — Enhance from stage-assignment, use ai-assignment
### Phase 3: New Services (Depend on Phase 1-2)
7. **submission-round-manager.ts** — Create new
8. **mentor-workspace.ts** — Create new
9. **winner-confirmation.ts** — Create new
### Phase 4: Enhanced Services
10. **live-control.ts** — Enhance existing
11. **ai-assignment.ts** — Enhance with jury groups
12. **ai-award-eligibility.ts** — Enhance with new modes
13. **ai-mentoring-insights.ts** — Create new
### Phase 5: Cleanup
14. **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