MOPC-App/docs/claude-architecture-redesign/11-special-awards.md

32 KiB

Special Awards System

Overview

Special Awards are standalone award tracks that run parallel to the main competition flow. They enable the MOPC platform to recognize excellence in specific areas (e.g., "Innovation Award", "Impact Award", "Youth Leadership Award") with dedicated juries and evaluation processes while referencing the same pool of projects.

Purpose

Special Awards serve three key purposes:

  1. Parallel Recognition — Recognize excellence in specific domains beyond the main competition prizes
  2. Specialized Evaluation — Enable dedicated jury groups with domain expertise to evaluate specific criteria
  3. Flexible Integration — Awards can piggyback on main rounds or run independently with their own timelines

Design Philosophy

  • Standalone Entities — Awards are not tracks; they're first-class entities linked to competitions
  • Two Modes — STAY_IN_MAIN (piggyback evaluation) or SEPARATE_POOL (independent flow)
  • Dedicated Juries — Each award can have its own jury group with unique members or shared members
  • Flexible Eligibility — AI-suggested, manual, round-based, or all-eligible modes
  • Integration with Results — Award results feed into the confirmation round alongside main competition winners

Current System Analysis

Current Architecture (Pipeline-Based)

Current State:

Program
 └── Pipeline
      ├── Track: "Main Competition" (MAIN)
      └── Track: "Innovation Award" (AWARD)
           ├── Stage: "Evaluation" (EVALUATION)
           └── Stage: "Results" (RESULTS)

SpecialAward {
  id, programId, name, description
  trackId → Track (AWARD track)
  criteriaText (for AI)
  scoringMode: PICK_WINNER | RANKED | SCORED
  votingStartAt, votingEndAt
  winnerProjectId
  useAiEligibility: boolean
}

AwardEligibility { awardId, projectId, eligible, method, aiReasoningJson }
AwardJuror { awardId, userId }
AwardVote { awardId, userId, projectId, rank? }

Current Flow:

  1. Admin creates AWARD track within pipeline
  2. Admin configures SpecialAward linked to track
  3. Projects routed to award track via ProjectStageState
  4. AI or manual eligibility determination
  5. Award jurors evaluate/vote
  6. Winner selected (admin/award master decision)

Current Limitations:

  • Awards tied to track concept (being eliminated)
  • No distinction between "piggyback" awards and independent awards
  • No round-based eligibility
  • No jury group integration
  • No evaluation form linkage
  • No audience voting support
  • No integration with confirmation round

Redesigned System: Two Award Modes

Mode 1: STAY_IN_MAIN

Concept: Projects remain in the main competition flow. A dedicated award jury evaluates them using the same submissions, during the same evaluation windows.

Use Case: "Innovation Award" — Members of Jury 2 who also serve on the Innovation Award jury score projects specifically for innovation criteria during the Jury 2 evaluation round.

Characteristics:

  • Projects never leave main track
  • Award jury evaluates during specific main evaluation rounds
  • Award jury sees the same docs/submissions as main jury
  • Award uses its own evaluation form with award-specific criteria
  • No separate stages/timeline needed
  • Results announced alongside main results

Data Flow:

Competition → Round 5 (Jury 2 Evaluation)
                ├─ Main Jury (Jury 2) evaluates with standard criteria
                └─ Innovation Award Jury evaluates same projects with innovation criteria

SpecialAward {
  evaluationMode: "STAY_IN_MAIN"
  evaluationRoundId: "round-5" ← Which main round this award evaluates during
  juryGroupId: "innovation-jury" ← Dedicated jury
  evaluationFormId: "innovation-form" ← Award-specific criteria
}

Mode 2: SEPARATE_POOL

Concept: Dedicated evaluation with separate criteria, submission requirements, and timeline. Projects may be pulled out for award-specific evaluation.

Use Case: "Community Impact Award" — Separate jury evaluates finalists specifically for community impact using a unique rubric and potentially additional documentation.

Characteristics:

  • Own jury group with unique members
  • Own evaluation criteria/form
  • Can have own submission requirements
  • Runs on its own timeline
  • Can pull projects from specific rounds
  • Independent results timeline

Data Flow:

Competition
 └── SpecialAward {
      evaluationMode: "SEPARATE_POOL"
      eligibilityMode: "ROUND_BASED" ← Projects from Round 5 (finalists)
      juryGroupId: "impact-jury"
      evaluationFormId: "impact-form"
      votingStartAt: [own window]
      votingEndAt: [own window]
     }

Enhanced SpecialAward Model

Complete Schema

model SpecialAward {
  id              String @id @default(cuid())
  competitionId   String                    // CHANGED: Links to Competition, not Track
  name            String
  description     String? @db.Text

  // Eligibility configuration
  eligibilityMode      AwardEligibilityMode @default(AI_SUGGESTED)
  eligibilityCriteria  Json? @db.JsonB      // Mode-specific config

  // Evaluation configuration
  evaluationMode       AwardEvaluationMode @default(STAY_IN_MAIN)
  evaluationRoundId    String?              // Which main round (for STAY_IN_MAIN)
  evaluationFormId     String?              // Custom criteria
  juryGroupId          String?              // Dedicated or shared jury

  // Voting configuration
  votingMode           AwardVotingMode @default(JURY_ONLY)
  scoringMode          AwardScoringMode @default(PICK_WINNER)
  maxRankedPicks       Int?                 // For RANKED mode
  maxWinners           Int @default(1)      // Number of winners
  audienceVotingWeight Float?               // 0.0-1.0 for COMBINED mode

  // Timing
  votingStartAt   DateTime?
  votingEndAt     DateTime?

  // Results
  status          AwardStatus @default(DRAFT)
  winnerProjectId String?                   // Single winner (for backward compat)

  // AI eligibility
  useAiEligibility Boolean @default(false)
  criteriaText     String? @db.Text         // Plain-language for AI

  // Job tracking (for AI eligibility)
  eligibilityJobStatus  String?
  eligibilityJobTotal   Int?
  eligibilityJobDone    Int?
  eligibilityJobError   String? @db.Text
  eligibilityJobStarted DateTime?

  sortOrder       Int @default(0)
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  // Relations
  competition      Competition        @relation(fields: [competitionId], references: [id], onDelete: Cascade)
  evaluationRound  Round?             @relation("AwardEvaluationRound", fields: [evaluationRoundId], references: [id], onDelete: SetNull)
  evaluationForm   EvaluationForm?    @relation(fields: [evaluationFormId], references: [id], onDelete: SetNull)
  juryGroup        JuryGroup?         @relation("AwardJuryGroup", fields: [juryGroupId], references: [id], onDelete: SetNull)
  winnerProject    Project?           @relation("AwardWinner", fields: [winnerProjectId], references: [id], onDelete: SetNull)

  eligibilities    AwardEligibility[]
  votes            AwardVote[]
  winners          AwardWinner[]      // NEW: Multi-winner support

  @@index([competitionId])
  @@index([status])
  @@index([evaluationRoundId])
  @@index([juryGroupId])
}

enum AwardEligibilityMode {
  AI_SUGGESTED     // AI analyzes and suggests eligible projects
  MANUAL           // Admin manually selects eligible projects
  ALL_ELIGIBLE     // All projects in competition are eligible
  ROUND_BASED      // All projects that reach a specific round
}

enum AwardEvaluationMode {
  STAY_IN_MAIN     // Evaluate during main competition round
  SEPARATE_POOL    // Independent evaluation flow
}

enum AwardVotingMode {
  JURY_ONLY        // Only jury votes
  AUDIENCE_ONLY    // Only audience votes
  COMBINED         // Jury + audience with weighted scoring
}

enum AwardScoringMode {
  PICK_WINNER      // Simple winner selection (1 or N winners)
  RANKED           // Ranked-choice voting
  SCORED           // Criteria-based scoring
}

enum AwardStatus {
  DRAFT
  NOMINATIONS_OPEN
  EVALUATION       // NEW: Award jury evaluation in progress
  DECIDED          // NEW: Winner(s) selected, pending announcement
  ANNOUNCED        // NEW: Winner(s) publicly announced
  ARCHIVED
}

New Model: AwardWinner (Multi-Winner Support)

model AwardWinner {
  id              String @id @default(cuid())
  awardId         String
  projectId       String
  rank            Int                // 1st place, 2nd place, etc.

  // Selection metadata
  selectedAt      DateTime @default(now())
  selectedById    String
  selectionMethod String             // "JURY_VOTE" | "AUDIENCE_VOTE" | "COMBINED" | "ADMIN_DECISION"

  // Score breakdown (for transparency)
  juryScore       Float?
  audienceScore   Float?
  finalScore      Float?

  createdAt DateTime @default(now())

  // Relations
  award       SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
  project     Project      @relation("AwardWinners", fields: [projectId], references: [id], onDelete: Cascade)
  selectedBy  User         @relation("AwardWinnerSelector", fields: [selectedById], references: [id])

  @@unique([awardId, projectId])
  @@unique([awardId, rank])
  @@index([awardId])
  @@index([projectId])
}

Enhanced AwardVote Model

model AwardVote {
  id        String   @id @default(cuid())
  awardId   String
  userId    String?              // Nullable for audience votes
  projectId String

  // Voting type
  isAudienceVote Boolean @default(false)

  // Scoring (mode-dependent)
  rank      Int?                 // For RANKED mode (1 = first choice)
  score     Float?               // For SCORED mode

  // Criteria scores (for SCORED mode)
  criterionScoresJson Json? @db.JsonB

  votedAt   DateTime @default(now())

  // Relations
  award   SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
  user    User?        @relation(fields: [userId], references: [id], onDelete: Cascade)
  project Project      @relation(fields: [projectId], references: [id], onDelete: Cascade)

  @@unique([awardId, userId, projectId])
  @@index([awardId])
  @@index([userId])
  @@index([projectId])
  @@index([awardId, isAudienceVote])
}

Eligibility System Deep Dive

Eligibility Modes

1. AI_SUGGESTED

AI analyzes all projects and suggests eligible ones based on plain-language criteria.

Config JSON:

type AISuggestedConfig = {
  criteriaText: string                    // "Projects using innovative ocean tech"
  confidenceThreshold: number             // 0.0-1.0 (default: 0.7)
  autoAcceptAbove: number                 // Auto-accept above this (default: 0.9)
  requireManualReview: boolean            // All need admin review (default: false)
  sourceRoundId?: string                  // Only projects from this round
}

Flow:

  1. Admin triggers AI eligibility analysis
  2. AI processes projects in batches (anonymized)
  3. AI returns: { projectId, eligible, confidence, reasoning }
  4. High-confidence results auto-applied
  5. Medium-confidence results flagged for review
  6. Low-confidence results rejected (or flagged if requireManualReview: true)

UI:

┌─────────────────────────────────────────────────────────────┐
│ Innovation Award — AI Eligibility Analysis                  │
├─────────────────────────────────────────────────────────────┤
│ Status: Running... (47/120 projects analyzed)              │
│ [████████████████░░░░░░░░] 68%                             │
│                                                             │
│ Results So Far:                                             │
│ ✓ Auto-Accepted (confidence > 0.9): 12 projects            │
│ ⚠ Flagged for Review (0.6-0.9): 23 projects                │
│ ✗ Rejected (< 0.6): 12 projects                            │
│                                                             │
│ [View Flagged Projects]  [Stop Analysis]                   │
└─────────────────────────────────────────────────────────────┘

2. MANUAL

Admin manually selects eligible projects.

Config JSON:

type ManualConfig = {
  sourceRoundId?: string      // Limit to projects from specific round
  categoryFilter?: "STARTUP" | "BUSINESS_CONCEPT"
  tagFilters?: string[]       // Only projects with these tags
}

3. ALL_ELIGIBLE

All projects in the competition are automatically eligible.

Config JSON:

type AllEligibleConfig = {
  minimumStatus?: ProjectStatus           // e.g., "SEMIFINALIST" or above
  excludeWithdrawn: boolean               // Exclude WITHDRAWN (default: true)
}

4. ROUND_BASED

All projects that reach a specific round are automatically eligible.

Config JSON:

type RoundBasedConfig = {
  sourceRoundId: string                   // Required: which round
  requiredState: ProjectRoundStateValue   // PASSED, COMPLETED, etc.
  autoUpdate: boolean                     // Auto-update when projects advance (default: true)
}

Example:

{
  "sourceRoundId": "round-5-jury-2",
  "requiredState": "PASSED",
  "autoUpdate": true
}

Admin Override System

All eligibility modes support admin override:

model AwardEligibility {
  id              String            @id @default(cuid())
  awardId         String
  projectId       String

  // Original determination
  method          EligibilityMethod @default(AUTO)  // AUTO, AI, MANUAL
  eligible        Boolean           @default(false)
  aiReasoningJson Json?             @db.JsonB

  // Override
  overriddenBy    String?
  overriddenAt    DateTime?
  overrideReason  String? @db.Text

  // Final decision
  finalEligible   Boolean           // Computed: overridden ? override : original

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  award            SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
  project          Project      @relation(fields: [projectId], references: [id], onDelete: Cascade)
  overriddenByUser User?        @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id])

  @@unique([awardId, projectId])
  @@index([awardId, eligible])
  @@index([awardId, finalEligible])
}

Award Jury Groups

Integration with JuryGroup Model

Awards can have:

  1. Dedicated Jury — Own JuryGroup with unique members
  2. Shared Jury — Reuse existing competition jury group (e.g., Jury 2)
  3. Mixed Jury — Some overlap with main jury, some unique members

Example:

// Dedicated jury for Innovation Award
const innovationJury = await prisma.juryGroup.create({
  data: {
    competitionId: "comp-2026",
    name: "Innovation Award Jury",
    slug: "innovation-jury",
    description: "Technology and innovation experts",
    defaultMaxAssignments: 15,
    defaultCapMode: "SOFT",
    categoryQuotasEnabled: false,
  }
})

// Add members (can overlap with main jury)
await prisma.juryGroupMember.createMany({
  data: [
    { juryGroupId: innovationJury.id, userId: "user-tech-1", isLead: true },
    { juryGroupId: innovationJury.id, userId: "user-tech-2" },
    { juryGroupId: innovationJury.id, userId: "jury-2-member-overlap" }, // Also on Jury 2
  ]
})

// Link to award
await prisma.specialAward.update({
  where: { id: awardId },
  data: { juryGroupId: innovationJury.id }
})

Award Jury Assignment

For STAY_IN_MAIN Mode

Award jury members evaluate the same projects as the main jury, but with award-specific criteria.

Assignment Creation:

// Main jury assignments (created by round)
Assignment { userId: "jury-2-member-1", projectId: "proj-A", roundId: "round-5", juryGroupId: "jury-2" }

// Award jury assignments (created separately, same round)
Assignment { userId: "innovation-jury-1", projectId: "proj-A", roundId: "round-5", juryGroupId: "innovation-jury" }

Evaluation:

  • Award jury uses evaluationFormId linked to award
  • Evaluations stored separately (different assignmentId)
  • Both juries can evaluate same project in same round

For SEPARATE_POOL Mode

Award has its own assignment workflow, potentially for a subset of projects.


Award Evaluation Flow

STAY_IN_MAIN Evaluation

Timeline:

Round 5: Jury 2 Evaluation (Main)
├─ Opens: 2026-03-01
├─ Main Jury evaluates with standard form
├─ Innovation Award Jury evaluates with innovation form
└─ Closes: 2026-03-15

Award results calculated separately but announced together

Step-by-Step:

  1. Setup Phase

    • Admin creates SpecialAward { evaluationMode: "STAY_IN_MAIN", evaluationRoundId: "round-5" }
    • Admin creates award-specific EvaluationForm with innovation criteria
    • Admin creates JuryGroup for Innovation Award
    • Admin adds members to jury group
  2. Eligibility Phase

    • Eligibility determined (AI/manual/round-based)
    • Only eligible projects evaluated by award jury
  3. Assignment Phase

    • When Round 5 opens, assignments created for award jury
    • Each award juror assigned eligible projects
    • Award assignments reference same roundId as main evaluation
  4. Evaluation Phase

    • Award jurors see projects in their dashboard
    • Form shows award-specific criteria
    • Evaluations stored with formId = innovation form
  5. Results Phase

    • Scores aggregated separately from main jury
    • Winner selection (jury vote, admin decision, etc.)
    • Results feed into confirmation round

SEPARATE_POOL Evaluation

Timeline:

Round 5: Jury 2 Evaluation (Main) — March 1-15
  ↓
Round 6: Finalist Selection
  ↓
Impact Award Evaluation (Separate) — March 20 - April 5
├─ Own voting window
├─ Own evaluation form
├─ Impact Award Jury evaluates finalists
└─ Results: April 10

Audience Voting for Awards

Voting Modes

JURY_ONLY

Only jury members vote. Standard model.

AUDIENCE_ONLY

Only audience (public) votes. No jury involvement.

Config:

type AudienceOnlyConfig = {
  requireIdentification: boolean          // Require email/phone (default: false)
  votesPerPerson: number                  // Max votes per person (default: 1)
  allowRanking: boolean                   // Ranked-choice (default: false)
  maxChoices?: number                     // For ranked mode
}

COMBINED

Jury + audience votes combined with weighted scoring.

Config:

type CombinedConfig = {
  audienceWeight: number                  // 0.0-1.0 (e.g., 0.3 = 30% audience, 70% jury)
  juryWeight: number                      // 0.0-1.0 (should sum to 1.0)
  requireMinimumAudienceVotes: number     // Min votes for validity (default: 50)
  showAudienceResultsToJury: boolean      // Jury sees audience results (default: false)
}

Scoring Calculation:

function calculateCombinedScore(
  juryScores: number[],
  audienceVoteCount: number,
  totalAudienceVotes: number,
  config: CombinedConfig
): number {
  const juryAvg = juryScores.reduce((a, b) => a + b, 0) / juryScores.length
  const audiencePercent = audienceVoteCount / totalAudienceVotes

  // Normalize jury score to 0-1 (assuming 1-10 scale)
  const normalizedJuryScore = juryAvg / 10

  const finalScore =
    (normalizedJuryScore * config.juryWeight) +
    (audiencePercent * config.audienceWeight)

  return finalScore
}

Admin Experience

Award Management Dashboard

┌─────────────────────────────────────────────────────────────────────────────┐
│ MOPC 2026 — Special Awards                                    [+ New Award] │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│ ┌───────────────────────────────────────────────────────────────────────┐   │
│ │ Innovation Award                                             [Edit ▼] │   │
│ │ Mode: Stay in Main (Jury 2 Evaluation) • Status: EVALUATION          │   │
│ ├───────────────────────────────────────────────────────────────────────┤   │
│ │ Eligible Projects: 18 / 20 finalists                                  │   │
│ │ Jury: Innovation Jury (5 members)                                     │   │
│ │ Evaluations: 72 / 90 (80% complete)                                   │   │
│ │ Voting Closes: March 15, 2026                                         │   │
│ │                                                                        │   │
│ │ [View Eligibility] [View Evaluations] [Select Winner]                │   │
│ └───────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│ ┌───────────────────────────────────────────────────────────────────────┐   │
│ │ Community Impact Award                                       [Edit ▼] │   │
│ │ Mode: Separate Pool • Status: DRAFT                                   │   │
│ ├───────────────────────────────────────────────────────────────────────┤   │
│ │ Eligible Projects: Not yet determined (AI pending)                    │   │
│ │ Jury: Not assigned                                                    │   │
│ │ Voting Window: Not set                                                │   │
│ │                                                                        │   │
│ │ [Configure Eligibility] [Set Up Jury] [Set Timeline]                 │   │
│ └───────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Integration with Main Flow

Awards Reference Main Competition Projects

Awards don't create their own project pool — they reference existing competition projects.

Data Relationship:

Competition
 ├── Projects (shared pool)
 │    ├── Project A
 │    ├── Project B
 │    └── Project C
 │
 ├── Main Rounds (linear flow)
 │    ├── Round 1: Intake
 │    ├── Round 5: Jury 2 Evaluation
 │    └── Round 7: Live Finals
 │
 └── Special Awards (parallel evaluation)
      ├── Innovation Award
      │    └── AwardEligibility { projectId: "A", eligible: true }
      │    └── AwardEligibility { projectId: "B", eligible: false }
      └── Impact Award
           └── AwardEligibility { projectId: "A", eligible: true }
           └── AwardEligibility { projectId: "C", eligible: true }

Award Results Feed into Confirmation Round

Confirmation Round Integration:

The confirmation round (Round 8) includes:

  1. Main competition winners (1st, 2nd, 3rd per category)
  2. Special award winners

WinnerProposal Extension:

model WinnerProposal {
  id              String @id @default(cuid())
  competitionId   String
  category        CompetitionCategory?   // Null for award winners

  // Main competition or award
  proposalType    WinnerProposalType @default(MAIN_COMPETITION)
  awardId         String?                // If proposalType = SPECIAL_AWARD

  status          WinnerProposalStatus @default(PENDING)
  rankedProjectIds String[]

  // ... rest of fields ...
}

enum WinnerProposalType {
  MAIN_COMPETITION    // Main 1st/2nd/3rd place
  SPECIAL_AWARD       // Award winner
}

API Changes

New tRPC Procedures

// src/server/routers/award-redesign.ts

export const awardRedesignRouter = router({
  /**
   * Create a new special award
   */
  create: adminProcedure
    .input(z.object({
      competitionId: z.string(),
      name: z.string().min(1).max(255),
      description: z.string().optional(),
      eligibilityMode: z.enum(['AI_SUGGESTED', 'MANUAL', 'ALL_ELIGIBLE', 'ROUND_BASED']),
      evaluationMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']),
      votingMode: z.enum(['JURY_ONLY', 'AUDIENCE_ONLY', 'COMBINED']),
      scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
      maxWinners: z.number().int().min(1).default(1),
    }))
    .mutation(async ({ ctx, input }) => { /* ... */ }),

  /**
   * Run eligibility determination
   */
  runEligibility: adminProcedure
    .input(z.object({ awardId: z.string() }))
    .mutation(async ({ ctx, input }) => { /* ... */ }),

  /**
   * Cast vote (jury or audience)
   */
  vote: protectedProcedure
    .input(z.object({
      awardId: z.string(),
      projectId: z.string(),
      rank: z.number().int().min(1).optional(),
      score: z.number().min(0).max(10).optional(),
    }))
    .mutation(async ({ ctx, input }) => { /* ... */ }),

  /**
   * Select winner(s)
   */
  selectWinners: adminProcedure
    .input(z.object({
      awardId: z.string(),
      winnerProjectIds: z.array(z.string()).min(1),
      selectionMethod: z.enum(['JURY_VOTE', 'AUDIENCE_VOTE', 'COMBINED', 'ADMIN_DECISION']),
    }))
    .mutation(async ({ ctx, input }) => { /* ... */ }),
})

Service Functions

Award Service Enhancements

// src/server/services/award-service.ts

/**
 * Run round-based eligibility
 */
export async function runRoundBasedEligibility(
  award: SpecialAward,
  prisma = getPrisma()
) {
  const config = award.eligibilityCriteria as RoundBasedConfig

  if (!config.sourceRoundId) {
    throw new Error('Round-based eligibility requires sourceRoundId')
  }

  // Get all projects in the specified round with the required state
  const projectRoundStates = await prisma.projectRoundState.findMany({
    where: {
      roundId: config.sourceRoundId,
      state: config.requiredState ?? 'PASSED',
    },
    select: { projectId: true }
  })

  // Create/update eligibility records
  let created = 0
  let updated = 0

  for (const prs of projectRoundStates) {
    const existing = await prisma.awardEligibility.findUnique({
      where: {
        awardId_projectId: {
          awardId: award.id,
          projectId: prs.projectId
        }
      }
    })

    if (existing) {
      await prisma.awardEligibility.update({
        where: { id: existing.id },
        data: { eligible: true, method: 'AUTO' }
      })
      updated++
    } else {
      await prisma.awardEligibility.create({
        data: {
          awardId: award.id,
          projectId: prs.projectId,
          eligible: true,
          method: 'AUTO',
        }
      })
      created++
    }
  }

  return { created, updated, total: projectRoundStates.length }
}

/**
 * Calculate combined jury + audience score
 */
export function calculateCombinedScore(
  juryScores: number[],
  audienceVoteCount: number,
  totalAudienceVotes: number,
  juryWeight: number,
  audienceWeight: number
): number {
  if (juryScores.length === 0) {
    throw new Error('Cannot calculate combined score without jury votes')
  }

  const juryAvg = juryScores.reduce((a, b) => a + b, 0) / juryScores.length
  const normalizedJuryScore = juryAvg / 10  // Assume 1-10 scale

  const audiencePercent = totalAudienceVotes > 0
    ? audienceVoteCount / totalAudienceVotes
    : 0

  const finalScore =
    (normalizedJuryScore * juryWeight) +
    (audiencePercent * audienceWeight)

  return finalScore
}

/**
 * Create award jury assignments
 */
export async function createAwardAssignments(
  awardId: string,
  prisma = getPrisma()
) {
  const award = await prisma.specialAward.findUniqueOrThrow({
    where: { id: awardId },
    include: {
      juryGroup: {
        include: { members: true }
      }
    }
  })

  if (!award.juryGroupId || !award.juryGroup) {
    throw new Error('Award must have a jury group to create assignments')
  }

  const eligibleProjects = await getEligibleProjects(awardId, prisma)

  const assignments = []

  for (const project of eligibleProjects) {
    for (const member of award.juryGroup.members) {
      assignments.push({
        userId: member.userId,
        projectId: project.id,
        roundId: award.evaluationRoundId ?? null,
        juryGroupId: award.juryGroupId,
        method: 'MANUAL' as const,
      })
    }
  }

  await prisma.assignment.createMany({
    data: assignments,
    skipDuplicates: true,
  })

  return { created: assignments.length }
}

Edge Cases

Scenario Handling
Project eligible for multiple awards Allowed — project can win multiple awards
Jury member on both main and award juries Allowed — separate assignments, separate evaluations
Award voting ends before main results Award winner held until main results finalized, announced together
Award eligibility changes mid-voting Admin override can remove eligibility; active votes invalidated
Audience vote spam/fraud IP rate limiting, device fingerprinting, email verification, admin review
Tie in award voting Admin decision or re-vote (configurable)
Award jury not complete evaluations Admin can close voting with partial data or extend deadline
Project withdrawn after eligible Eligibility auto-removed; votes invalidated
Award criteria change after eligibility Re-run eligibility or grandfather existing eligible projects
No eligible projects for award Award status set to DRAFT/ARCHIVED; no voting

Integration Points

With Evaluation System

  • Awards use EvaluationForm for criteria
  • Award evaluations stored in Evaluation table with formId linkage
  • Assignment system handles both main and award assignments

With Jury Groups

  • Awards can link to existing JuryGroup or have dedicated groups
  • Jury members can overlap between main and award juries
  • Caps and quotas honored for award assignments

With Confirmation Round

  • Award winners included in WinnerProposal system
  • Confirmation flow handles both main and award winners
  • Approval workflow requires sign-off on all winners

With Notification System

  • Eligibility notifications sent to eligible teams
  • Voting reminders sent to award jurors
  • Winner announcements coordinated with main results

Summary

The redesigned Special Awards system provides:

  1. Flexibility: Two modes (STAY_IN_MAIN, SEPARATE_POOL) cover all use cases
  2. Integration: Deep integration with competition rounds, juries, and results
  3. Autonomy: Awards can run independently or piggyback on main flow
  4. Transparency: AI eligibility with admin override, full audit trail
  5. Engagement: Audience voting support with anti-fraud measures
  6. Scalability: Support for multiple awards, multiple winners, complex scoring

This architecture eliminates the Track dependency, integrates awards as standalone entities, and provides a robust, flexible system for recognizing excellence across multiple dimensions while maintaining the integrity of the main competition flow.