2186 lines
64 KiB
Markdown
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
|