MOPC-App/docs/claude-architecture-redesign/19-api-router-reference.md

43 KiB

API Router Reference: Architecture Redesign

Overview

This document provides a complete reference of all tRPC router changes for the MOPC architecture redesign. Every procedure — new, modified, preserved, and removed — is documented with full Zod schemas.

Key Changes Summary:

  • Renamed Routers: pipelinecompetition, Stage CRUD split between competition and round routers
  • New Routers: jury-group, winner-confirmation, submission-window, mentor-workspace
  • Enhanced Routers: assignment, evaluation, file, applicant, award, live-control, mentor
  • Eliminated: stageTransition procedures (replaced by linear advancement rules)
  • Procedure Count: ~180 existing procedures, ~60 new, ~30 modified, ~20 removed

Router Rename Map

Current Router Redesigned Router Reason
pipeline.ts competition.ts Domain-specific naming
stage.ts Split: competition.ts (CRUD) + round.ts (runtime) Clearer separation of concerns
assignment.ts assignment.ts (enhanced) Add jury group awareness
specialAward.ts award.ts Enhanced for two-mode awards
mentor.ts mentor.ts + mentor-workspace.ts Separate workspace features
(new) jury-group.ts Explicit jury management
(new) winner-confirmation.ts Multi-party winner approval
(new) submission-window.ts Multi-round document requirements

1. Competition Router (replaces Pipeline)

File: src/server/routers/competition.ts (renamed from pipeline.ts)

Removed Procedures

All Track-related procedures are eliminated (Track model is removed):

  • pipeline.createStructure — Replaced by competition.createStructure (no tracks)
  • pipeline.getDraft — Replaced by competition.getDraft (returns rounds, not tracks)
  • pipeline.updateStructure — Replaced by competition.updateStructure (rounds only)
  • pipeline.getApplicantView — Moved to applicant.getCompetitionView

Modified Procedures

createcompetition.create

Changes: Rename references, simplified settings (no track concept)

// Input schema
z.object({
  programId: z.string(),
  name: z.string().min(1).max(255),
  slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),

  // NEW: Competition-wide settings (typed, not generic JSON)
  categoryMode: z.enum(['SHARED', 'SPLIT']).default('SHARED'),
  startupFinalistCount: z.number().int().min(1).default(3),
  conceptFinalistCount: z.number().int().min(1).default(3),

  // Notification preferences
  notifyOnRoundAdvance: z.boolean().default(true),
  notifyOnDeadlineApproach: z.boolean().default(true),
  deadlineReminderDays: z.array(z.number().int()).default([7, 3, 1]),
})

// Output: Competition

updatecompetition.update

Changes: Add typed settings fields

// Input schema
z.object({
  id: z.string(),
  name: z.string().min(1).max(255).optional(),
  slug: z.string().regex(/^[a-z0-9-]+$/).optional(),
  status: z.enum(['DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED']).optional(),

  // NEW: Typed settings
  categoryMode: z.enum(['SHARED', 'SPLIT']).optional(),
  startupFinalistCount: z.number().int().min(1).optional(),
  conceptFinalistCount: z.number().int().min(1).optional(),
  notifyOnRoundAdvance: z.boolean().optional(),
  notifyOnDeadlineApproach: z.boolean().optional(),
  deadlineReminderDays: z.array(z.number().int()).optional(),
})

// Output: Competition

getcompetition.get

Changes: Returns rounds array (flat), not tracks

// Input schema
z.object({ id: z.string() })

// Output
{
  ...competition,
  program: { id, name },
  rounds: Array<{
    id, name, slug, roundType, status, sortOrder,
    windowOpenAt, windowCloseAt,
    juryGroup: { id, name } | null,
    submissionWindow: { id, name } | null,
    _count: { projectRoundStates, cohorts }
  }>,
  juryGroups: Array<{ id, name, _count: { members } }>,
  submissionWindows: Array<{ id, name, roundNumber }>,
  specialAwards: Array<{ id, name, status }>,
}

listcompetition.list

Changes: Returns competition counts (no tracks)

// Input schema
z.object({ programId: z.string() })

// Output
Array<{
  ...competition,
  _count: { rounds, juryGroups, submissionWindows, specialAwards }
}>

publishcompetition.publish

Changes: Validates rounds instead of tracks

// Input schema
z.object({ id: z.string() })

// Validation: Must have at least one round, all rounds must have configs
// Output: Competition with status = ACTIVE

New Procedures

createStructure

Purpose: Atomically create Competition + Rounds + Jury Groups + Submission Windows

// Input schema
z.object({
  programId: z.string().min(1),
  name: z.string().min(1).max(255),
  slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),

  // Competition-wide settings
  categoryMode: z.enum(['SHARED', 'SPLIT']).default('SHARED'),
  startupFinalistCount: z.number().int().default(3),
  conceptFinalistCount: z.number().int().default(3),

  // Rounds (linear array, no tracks)
  rounds: z.array(z.object({
    name: z.string().min(1).max(255),
    slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
    roundType: z.enum([
      'INTAKE', 'FILTERING', 'EVALUATION',
      'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'CONFIRMATION'
    ]),
    sortOrder: z.number().int().min(0),
    configJson: z.record(z.unknown()).optional(),
    windowOpenAt: z.date().optional(),
    windowCloseAt: z.date().optional(),

    // Links to other entities
    juryGroupSlug: z.string().optional(),  // Will link to JuryGroup after creation
    submissionWindowSlug: z.string().optional(),
  })),

  // Jury groups
  juryGroups: z.array(z.object({
    name: z.string(),
    slug: z.string(),
    description: z.string().optional(),
    sortOrder: z.number().int(),
    defaultMaxAssignments: z.number().int().default(20),
    defaultCapMode: z.enum(['HARD', 'SOFT', 'NONE']).default('SOFT'),
    softCapBuffer: z.number().int().default(2),
    categoryQuotasEnabled: z.boolean().default(false),
    defaultCategoryQuotas: z.record(z.object({
      min: z.number().int(),
      max: z.number().int(),
    })).optional(),
  })),

  // Submission windows
  submissionWindows: z.array(z.object({
    name: z.string(),
    slug: z.string(),
    roundNumber: z.number().int(),
    sortOrder: z.number().int(),
    windowOpenAt: z.date().optional(),
    windowCloseAt: z.date().optional(),
    deadlinePolicy: z.enum(['HARD', 'FLAG', 'GRACE']).default('FLAG'),
    graceHours: z.number().int().optional(),
    lockOnClose: z.boolean().default(true),

    fileRequirements: z.array(z.object({
      name: z.string(),
      description: z.string().optional(),
      acceptedMimeTypes: z.array(z.string()),
      maxSizeMB: z.number().int().optional(),
      isRequired: z.boolean().default(true),
      sortOrder: z.number().int(),
    })),
  })),

  // Advancement rules (auto-generated linear flow by default)
  autoAdvancement: z.boolean().default(true),
})

// Output
{
  competition: Competition,
  rounds: Array<Round>,
  juryGroups: Array<JuryGroup>,
  submissionWindows: Array<SubmissionWindow>,
}

updateStructure

Purpose: Diff-based update (create/update/delete rounds, jury groups, windows)

// Input schema
z.object({
  id: z.string(),
  name: z.string().optional(),
  slug: z.string().optional(),
  status: z.enum(['DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED']).optional(),

  // Same nested arrays as createStructure, but with optional `id` fields
  rounds: z.array(z.object({
    id: z.string().optional(),  // Present = update, absent = create
    name: z.string(),
    // ... all other round fields
  })),

  juryGroups: z.array(z.object({
    id: z.string().optional(),
    name: z.string(),
    // ... all other jury group fields
  })),

  submissionWindows: z.array(z.object({
    id: z.string().optional(),
    name: z.string(),
    // ... all other submission window fields
  })),
})

// Logic:
// - Rounds/groups/windows with ID → update
// - Rounds/groups/windows without ID → create
// - Existing IDs not in input → delete (with safety checks: no active ProjectRoundStates)

getDraft

Purpose: Get full structure for wizard editing

// Input schema
z.object({ id: z.string() })

// Output
{
  ...competition,
  program: { id, name },
  rounds: Array<{
    ...round,
    juryGroup: JuryGroup | null,
    submissionWindow: SubmissionWindow | null,
    visibleSubmissionWindows: Array<RoundSubmissionVisibility>,
    advancementRules: Array<AdvancementRule>,
    _count: { projectRoundStates },
  }>,
  juryGroups: Array<{
    ...juryGroup,
    members: Array<JuryGroupMember>,
    _count: { rounds, assignments },
  }>,
  submissionWindows: Array<{
    ...submissionWindow,
    fileRequirements: Array<SubmissionFileRequirement>,
    rounds: Array<{ id, name }>,
  }>,
  specialAwards: Array<{
    ...award,
    juryGroup: { id, name } | null,
    evaluationRound: { id, name } | null,
  }>,
}

simulate

Purpose: Dry-run project routing through competition

// Input schema
z.object({
  id: z.string(),
  projectIds: z.array(z.string()).min(1).max(500),
})

// Output
{
  competitionId: string,
  competitionName: string,
  projectCount: number,
  simulations: Array<{
    projectId: string,
    projectTitle: string,
    currentStatus: ProjectStatus,
    targetRoundId: string | null,
    targetRoundName: string,
  }>,
}

Preserved Procedures (renamed only)

  • deletecompetition.delete (archive)
  • getSummarycompetition.getSummary (aggregated stats)
  • getStageAnalyticscompetition.getRoundAnalytics (per-round stats)

2. Round Router (new, split from Stage)

File: src/server/routers/round.ts

Runtime operations for rounds (open/close windows, transition status, project states).

New Procedures

get

// Input schema
z.object({ id: z.string() })

// Output
{
  ...round,
  competition: { id, name, programId },
  juryGroup: JuryGroup | null,
  submissionWindow: SubmissionWindow | null,
  visibleSubmissionWindows: Array<RoundSubmissionVisibility>,
  cohorts: Array<Cohort>,
  advancementRules: Array<AdvancementRule>,
  stateDistribution: Record<ProjectRoundStateValue, number>,
  _count: { projectRoundStates, assignments, evaluationForms },
}

transition

Purpose: State machine for round status

// Input schema
z.object({
  id: z.string(),
  targetStatus: z.enum(['ROUND_DRAFT', 'ROUND_ACTIVE', 'ROUND_CLOSED', 'ROUND_ARCHIVED']),
})

// Validation: Enforces state machine rules
// ROUND_DRAFT → ROUND_ACTIVE
// ROUND_ACTIVE → ROUND_CLOSED
// ROUND_CLOSED → ROUND_ARCHIVED | ROUND_ACTIVE (reopen)

// Output: Round with new status

openWindow

// Input schema
z.object({
  id: z.string(),
  windowCloseAt: z.date().optional(),
})

// Sets windowOpenAt = now, optionally sets windowCloseAt
// Output: Round

closeWindow

// Input schema
z.object({ id: z.string() })

// Sets windowCloseAt = now
// Output: Round

getProjectStates

Purpose: Paginated list of project states in a round

// Input schema
z.object({
  roundId: z.string(),
  state: z.enum([
    'PENDING', 'IN_PROGRESS', 'PASSED',
    'REJECTED', 'COMPLETED', 'WITHDRAWN'
  ]).optional(),
  cursor: z.string().optional(),
  limit: z.number().int().min(1).max(100).default(50),
})

// Output
{
  items: Array<{
    ...projectRoundState,
    project: { id, title, status, tags, teamName, competitionCategory },
  }>,
  nextCursor: string | undefined,
}

getForJury

Purpose: Jury-facing round details

// Input schema
z.object({ id: z.string() })

// Output
{
  ...round,
  competition: { id, name, programId },
  juryGroup: JuryGroup | null,
  evaluationForms: Array<{ id, criteriaJson, scalesJson }>,
  isWindowOpen: boolean,
  windowTimeRemaining: number | null,  // milliseconds
  myAssignmentCount: number,
  myCompletedCount: number,
}

getApplicantTimeline

Purpose: Show a project's journey through rounds

// Input schema
z.object({
  projectId: z.string(),
  competitionId: z.string(),
})

// Output
Array<{
  roundId: string,
  roundName: string,
  roundType: RoundType,
  state: ProjectRoundStateValue,
  enteredAt: DateTime,
  exitedAt: DateTime | null,
  isCurrent: boolean,
}>

getRequirements

Purpose: File requirements and upload status for a round

// Input schema
z.object({
  roundId: z.string(),
  projectId: z.string(),
})

// Output
{
  submissionWindow: {
    id: string,
    name: string,
    windowOpenAt: DateTime | null,
    windowCloseAt: DateTime | null,
    deadlinePolicy: DeadlinePolicy,
  } | null,
  fileRequirements: Array<SubmissionFileRequirement>,
  uploadedFiles: Array<ProjectFile>,
  windowStatus: {
    isOpen: boolean,
    closesAt: DateTime | null,
    isLate: boolean,
  },
  deadlineInfo: {
    windowOpenAt: DateTime | null,
    windowCloseAt: DateTime | null,
    graceHours: number,
  },
}

3. Jury Group Router (new)

File: src/server/routers/jury-group.ts

New Procedures

create

// Input schema
z.object({
  competitionId: z.string(),
  name: z.string().min(1).max(255),
  slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
  description: z.string().optional(),
  sortOrder: z.number().int().min(0).optional(),

  // Default assignment configuration
  defaultMaxAssignments: z.number().int().min(1).default(20),
  defaultCapMode: z.enum(['HARD', 'SOFT', 'NONE']).default('SOFT'),
  softCapBuffer: z.number().int().min(0).default(2),

  // Category quotas
  categoryQuotasEnabled: z.boolean().default(false),
  defaultCategoryQuotas: z.record(z.object({
    min: z.number().int().min(0),
    max: z.number().int().min(1),
  })).optional(),

  // Onboarding settings
  allowJurorCapAdjustment: z.boolean().default(false),
  allowJurorRatioAdjustment: z.boolean().default(false),
})

// Output: JuryGroup

update

// Input schema
z.object({
  id: z.string(),
  name: z.string().optional(),
  description: z.string().optional(),
  sortOrder: z.number().int().optional(),
  defaultMaxAssignments: z.number().int().optional(),
  defaultCapMode: z.enum(['HARD', 'SOFT', 'NONE']).optional(),
  softCapBuffer: z.number().int().optional(),
  categoryQuotasEnabled: z.boolean().optional(),
  defaultCategoryQuotas: z.record(z.object({
    min: z.number().int(),
    max: z.number().int(),
  })).optional(),
  allowJurorCapAdjustment: z.boolean().optional(),
  allowJurorRatioAdjustment: z.boolean().optional(),
})

// Output: JuryGroup

delete

// Input schema
z.object({ id: z.string() })

// Validation: Cannot delete if linked to any rounds or assignments
// Output: { success: true }

get

// Input schema
z.object({ id: z.string() })

// Output
{
  ...juryGroup,
  competition: { id, name },
  members: Array<{
    ...juryGroupMember,
    user: { id, name, email, expertiseTags },
  }>,
  rounds: Array<{ id, name, roundType }>,
  _count: { members, assignments },
}

list

// Input schema
z.object({ competitionId: z.string() })

// Output
Array<{
  ...juryGroup,
  _count: { members, rounds, assignments },
}>

addMember

// Input schema
z.object({
  juryGroupId: z.string(),
  userId: z.string(),
  isLead: z.boolean().default(false),

  // Per-juror overrides
  maxAssignmentsOverride: z.number().int().optional(),
  capModeOverride: z.enum(['HARD', 'SOFT', 'NONE']).optional(),
  categoryQuotasOverride: z.record(z.object({
    min: z.number().int(),
    max: z.number().int(),
  })).optional(),
})

// Validation: User must have role JURY_MEMBER
// Output: JuryGroupMember

updateMember

// Input schema
z.object({
  id: z.string(),  // JuryGroupMember ID
  isLead: z.boolean().optional(),
  maxAssignmentsOverride: z.number().int().optional().nullable(),
  capModeOverride: z.enum(['HARD', 'SOFT', 'NONE']).optional().nullable(),
  categoryQuotasOverride: z.record(z.object({
    min: z.number().int(),
    max: z.number().int(),
  })).optional().nullable(),

  // Juror preferences (self-service during onboarding)
  preferredStartupRatio: z.number().min(0).max(1).optional(),
  availabilityNotes: z.string().optional(),
})

// Output: JuryGroupMember

removeMember

// Input schema
z.object({ id: z.string() })

// Validation: Cannot remove if has active assignments
// Output: { success: true }

bulkAddMembers

// Input schema
z.object({
  juryGroupId: z.string(),
  userIds: z.array(z.string()).min(1).max(100),
})

// Creates JuryGroupMember for each user with default settings
// Output: { created: number, skipped: number }

getOnboarding

Purpose: Jury member views their onboarding settings for a group

// Input schema (protectedProcedure)
z.object({ juryGroupId: z.string() })

// Output
{
  juryGroup: JuryGroup,
  membership: JuryGroupMember,
  allowCapAdjustment: boolean,
  allowRatioAdjustment: boolean,
  hasCompleted: boolean,  // Has filled preferences
}

submitOnboarding

Purpose: Jury member submits preferences

// Input schema (protectedProcedure)
z.object({
  juryGroupId: z.string(),

  // Conditionally allowed based on juryGroup.allowJurorCapAdjustment
  maxAssignmentsOverride: z.number().int().optional(),

  // Conditionally allowed based on juryGroup.allowJurorRatioAdjustment
  preferredStartupRatio: z.number().min(0).max(1).optional(),

  availabilityNotes: z.string().optional(),
})

// Output: JuryGroupMember

4. Submission Window Router (new)

File: src/server/routers/submission-window.ts

New Procedures

create

// Input schema
z.object({
  competitionId: z.string(),
  name: z.string().min(1),
  slug: z.string().regex(/^[a-z0-9-]+$/),
  roundNumber: z.number().int().min(1),
  sortOrder: z.number().int().optional(),

  // Window timing
  windowOpenAt: z.date().optional(),
  windowCloseAt: z.date().optional(),

  // Deadline behavior
  deadlinePolicy: z.enum(['HARD', 'FLAG', 'GRACE']).default('FLAG'),
  graceHours: z.number().int().min(0).optional(),
  lockOnClose: z.boolean().default(true),
})

// Output: SubmissionWindow

update

// Input schema
z.object({
  id: z.string(),
  name: z.string().optional(),
  windowOpenAt: z.date().optional().nullable(),
  windowCloseAt: z.date().optional().nullable(),
  deadlinePolicy: z.enum(['HARD', 'FLAG', 'GRACE']).optional(),
  graceHours: z.number().int().optional(),
  lockOnClose: z.boolean().optional(),
})

// Output: SubmissionWindow

delete

// Input schema
z.object({ id: z.string() })

// Validation: Cannot delete if has uploaded files
// Output: { success: true }

get

// Input schema
z.object({ id: z.string() })

// Output
{
  ...submissionWindow,
  competition: { id, name },
  fileRequirements: Array<SubmissionFileRequirement>,
  rounds: Array<{ id, name, roundType }>,
  _count: { fileRequirements, projectFiles },
}

list

// Input schema
z.object({ competitionId: z.string() })

// Output
Array<{
  ...submissionWindow,
  _count: { fileRequirements, projectFiles, rounds },
}>

addFileRequirement

// Input schema
z.object({
  submissionWindowId: z.string(),
  name: z.string().min(1),
  description: z.string().optional(),
  acceptedMimeTypes: z.array(z.string()).min(1),
  maxSizeMB: z.number().int().min(1).optional(),
  isRequired: z.boolean().default(true),
  sortOrder: z.number().int().optional(),
})

// Output: SubmissionFileRequirement

updateFileRequirement

// Input schema
z.object({
  id: z.string(),
  name: z.string().optional(),
  description: z.string().optional(),
  acceptedMimeTypes: z.array(z.string()).optional(),
  maxSizeMB: z.number().int().optional(),
  isRequired: z.boolean().optional(),
  sortOrder: z.number().int().optional(),
})

// Output: SubmissionFileRequirement

deleteFileRequirement

// Input schema
z.object({ id: z.string() })

// Validation: Cannot delete if has uploaded files against this requirement
// Output: { success: true }

setRoundVisibility

Purpose: Configure which submission windows a jury round can see

// Input schema
z.object({
  roundId: z.string(),
  submissionWindowIds: z.array(z.string()),  // Windows jury can see
  displayLabels: z.record(z.string()).optional(),  // submissionWindowId → label
})

// Creates/updates RoundSubmissionVisibility records
// Output: Array<RoundSubmissionVisibility>

5. Winner Confirmation Router (new)

File: src/server/routers/winner-confirmation.ts

New Procedures

createProposal

Purpose: Admin or system generates a winner proposal

// Input schema
z.object({
  competitionId: z.string(),
  category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']),

  // Proposed rankings (ordered project IDs)
  rankedProjectIds: z.array(z.string()).min(1).max(10),

  // Evidence/basis
  sourceRoundId: z.string(),
  selectionBasis: z.object({
    method: z.enum(['SCORE_BASED', 'VOTE_BASED', 'AI_RECOMMENDED', 'ADMIN_MANUAL']),
    scores: z.record(z.number()).optional(),
    aiRecommendation: z.string().optional(),
    reasoning: z.string().optional(),
  }),
})

// Output: WinnerProposal (status: PENDING)

getProposal

// Input schema
z.object({ id: z.string() })

// Output
{
  ...winnerProposal,
  competition: { id, name },
  sourceRound: { id, name, roundType },
  proposedBy: { id, name, email },
  frozenBy: { id, name } | null,
  overrideBy: { id, name } | null,
  approvals: Array<{
    ...winnerApproval,
    user: { id, name, email },
  }>,
  projects: Array<{  // Ordered by rankedProjectIds
    id, title, teamName, rank,
  }>,
}

listProposals

// Input schema
z.object({
  competitionId: z.string(),
  status: z.enum([
    'PENDING', 'APPROVED', 'REJECTED', 'OVERRIDDEN', 'FROZEN'
  ]).optional(),
})

// Output
Array<{
  ...winnerProposal,
  approvalProgress: {
    total: number,
    approved: number,
    rejected: number,
    pending: number,
  },
}>

requestApproval

Purpose: Admin adds jury members to approval list

// Input schema
z.object({
  winnerProposalId: z.string(),
  userIds: z.array(z.string()).min(1),  // Jury members to request approval from
  role: z.enum(['JURY_MEMBER', 'ADMIN']).default('JURY_MEMBER'),
})

// Creates WinnerApproval records (approved: null)
// Output: Array<WinnerApproval>

approve

Purpose: Jury member or admin approves the proposal

// Input schema (protectedProcedure)
z.object({
  winnerProposalId: z.string(),
  approved: z.boolean(),
  comments: z.string().optional(),
})

// Updates WinnerApproval.approved = true/false, respondedAt = now
// If all approvals received and all true → sets WinnerProposal.status = APPROVED
// If any approval is false → sets WinnerProposal.status = REJECTED
// Output: WinnerApproval

getMyPendingApprovals

Purpose: Jury member sees proposals awaiting their approval

// Input schema (protectedProcedure)
z.object({ competitionId: z.string().optional() })

// Output
Array<{
  ...winnerApproval,
  proposal: {
    ...winnerProposal,
    competition: { id, name },
    projects: Array<{ id, title, rank }>,
  },
}>

override

Purpose: Admin forces a result (bypasses jury approval)

// Input schema
z.object({
  winnerProposalId: z.string(),
  mode: z.enum(['FORCE_MAJORITY', 'ADMIN_DECISION']),
  reason: z.string().min(10),
})

// Sets WinnerProposal.status = OVERRIDDEN
// Sets overrideUsed = true, overrideMode, overrideReason, overrideById
// Output: WinnerProposal

freeze

Purpose: Lock the results (official, no further changes)

// Input schema
z.object({ winnerProposalId: z.string() })

// Validation: Must be status APPROVED or OVERRIDDEN
// Sets WinnerProposal.status = FROZEN, frozenAt = now, frozenById
// Updates Competition with winnerProjectIds
// Output: WinnerProposal

unfreeze

Purpose: Admin unlocks results (rare, exceptional circumstances)

// Input schema
z.object({
  winnerProposalId: z.string(),
  reason: z.string().min(10),
})

// Sets status = PENDING, frozenAt = null
// Creates audit log with reason
// Output: WinnerProposal

6. Assignment Router (enhanced)

File: src/server/routers/assignment.ts

Modified Procedures

create → Enhanced with jury group awareness

Changes: Add juryGroupId parameter, enforce per-juror caps from JuryGroupMember

// Input schema
z.object({
  userId: z.string(),
  projectId: z.string(),
  roundId: z.string(),  // RENAMED from stageId
  juryGroupId: z.string().optional(),  // NEW: Link to jury group
  isRequired: z.boolean().default(true),
  forceOverride: z.boolean().default(false),
})

// Validation:
// - Checks JuryGroupMember.maxAssignmentsOverride if set
// - Falls back to JuryGroup.defaultMaxAssignments
// - Falls back to User.maxAssignments
// - Enforces CapMode (HARD vs SOFT)

// Output: Assignment (with juryGroupId)

bulkCreate → Enhanced with capacity checking

Changes: Respect juryGroupId, apply category quotas

// Input schema
z.object({
  roundId: z.string(),  // RENAMED from stageId
  juryGroupId: z.string().optional(),  // NEW
  assignments: z.array(z.object({
    userId: z.string(),
    projectId: z.string(),
  })),
})

// Logic:
// - For each juror, checks current count vs cap
// - If CapMode = HARD → rejects over-cap assignments
// - If CapMode = SOFT → allows up to softCapBuffer extra
// - Applies category quotas if categoryQuotasEnabled
// - Skips assignments that would violate quotas

// Output
{
  created: number,
  requested: number,
  skipped: number,
  skippedDueToCapacity: number,
  skippedDueToCategoryQuota: number,
}

getSuggestions → Enhanced with category quotas

Changes: Add quota scoring, penalty for over-quota categories

// Input schema
z.object({
  roundId: z.string(),  // RENAMED from stageId
  juryGroupId: z.string().optional(),  // NEW
})

// Algorithm enhancements:
// - Fetches JuryGroup.defaultCategoryQuotas
// - Tracks per-juror category distribution
// - Penalizes assignments to over-quota categories (-25 score)
// - Bonuses assignments to under-quota categories (+10 score)

// Output: Array of suggestions with quota awareness

New Procedures

getJuryGroupStats

Purpose: Per-group assignment coverage stats

// Input schema
z.object({
  juryGroupId: z.string(),
  roundId: z.string(),
})

// Output
{
  juryGroup: { id, name },
  round: { id, name },
  members: Array<{
    user: { id, name, email },
    currentAssignments: number,
    cap: number,
    capMode: CapMode,
    categoryDistribution: Record<string, number>,  // Category → count
    quotaStatus: Record<string, { current, min, max, status }>,
  }>,
  totalAssignments: number,
  averageLoad: number,
  minLoad: number,
  maxLoad: number,
}

7. Evaluation Router (enhanced)

File: src/server/routers/evaluation.ts

Modified Procedures

All procedures: Rename stageIdroundId in inputs/outputs

get, start, autosave, submit — No functional changes, just renames

New Procedures

getCrossRoundDocs

Purpose: Jury sees documents from multiple submission windows

// Input schema (protectedProcedure)
z.object({
  projectId: z.string(),
  roundId: z.string(),
})

// Logic:
// - Fetches RoundSubmissionVisibility records for roundId
// - Returns files from all visible submission windows

// Output
{
  round: { id, name },
  submissionWindows: Array<{
    window: { id, name, roundNumber },
    displayLabel: string,
    files: Array<ProjectFile>,
    requirements: Array<SubmissionFileRequirement>,
  }>,
}

8. File Router (enhanced)

File: src/server/routers/file.ts

Modified Procedures

getDownloadUrl → Enhanced for multi-round visibility

Changes: Check RoundSubmissionVisibility for jury access

// Input schema (no changes)
z.object({
  bucket: z.string(),
  objectKey: z.string(),
})

// Logic changes:
// - If user is jury, checks Assignment.roundId
// - Looks up RoundSubmissionVisibility for that round
// - Grants access if file.submissionWindowId is in visible windows

// Output: { url }

getUploadUrl → Enhanced for submission window association

Changes: Link file to submissionWindowId

// Input schema
z.object({
  projectId: z.string(),
  fileName: z.string(),
  fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
  mimeType: z.string(),
  size: z.number().int().positive(),

  submissionWindowId: z.string().optional(),  // NEW: Which window is this for?
  requirementId: z.string().optional(),  // Links to SubmissionFileRequirement
})

// Creates ProjectFile with submissionWindowId
// Output: { uploadUrl, fileId }

New Procedures

promoteFromMentor

Purpose: Promote a MentorFile to official ProjectFile

// Input schema
z.object({
  mentorFileId: z.string(),
  submissionWindowId: z.string(),
  requirementId: z.string().optional(),
})

// Logic:
// - Copies object in MinIO to new key
// - Creates ProjectFile linked to submissionWindow
// - Sets MentorFile.isPromoted = true, promotedToFileId

// Output: ProjectFile

9. Applicant Router (enhanced)

File: src/server/routers/applicant.ts

Modified Procedures

getMySubmission → Enhanced for multi-round

Changes: Returns submission status for all rounds

// Input schema
z.object({
  competitionId: z.string().optional(),  // NEW: Filter by competition
  programId: z.string().optional(),
})

// Output
{
  ...project,
  roundStates: Array<{
    round: { id, name, roundType, sortOrder },
    state: ProjectRoundStateValue,
    enteredAt: DateTime,
    exitedAt: DateTime | null,
  }>,
  currentRound: { id, name, roundType } | null,
  eligibleForNextSubmission: boolean,
  nextSubmissionWindow: SubmissionWindow | null,
}

New Procedures

getCompetitionView

Purpose: Applicant's view of their journey through a competition

// Input schema (protectedProcedure)
z.object({
  competitionId: z.string(),
  projectId: z.string(),
})

// Output
{
  competition: { id, name, status },
  project: { id, title, teamName, status },
  rounds: Array<{
    round: { id, name, roundType, sortOrder, windowOpenAt, windowCloseAt },
    projectState: ProjectRoundState | null,
    isCurrent: boolean,
    isPassed: boolean,
    canSubmit: boolean,
    submissionWindow: SubmissionWindow | null,
  }>,
  timeline: Array<{
    date: DateTime,
    event: string,
    description: string,
  }>,
}

submitToWindow

Purpose: Submit files for a specific submission window

// Input schema (protectedProcedure)
z.object({
  projectId: z.string(),
  submissionWindowId: z.string(),
  fileIds: z.array(z.string()),  // ProjectFile IDs to mark as submitted
})

// Validation:
// - Window must be open
// - All required FileRequirements must have files
// - User must own project or be team member

// Output: { success: true, submittedAt: DateTime }

10. Live Control Router (enhanced)

File: src/server/routers/live-control.ts (or live.ts)

Modified Procedures

All procedures: Rename stageIdroundId

New Procedures

getStageManager

Purpose: Admin view of live ceremony control panel

// Input schema
z.object({ roundId: z.string() })

// Output
{
  round: { id, name, roundType },
  liveCursor: LiveProgressCursor | null,
  liveVotingSession: LiveVotingSession | null,

  // Cohorts (project presentation order)
  cohorts: Array<{
    ...cohort,
    projects: Array<{
      project: { id, title, teamName },
      presentationOrder: number,
    }>,
  }>,

  // Jury voting status
  juryVotes: Array<{
    juror: { id, name },
    votedProjects: Array<string>,  // Project IDs
    hasCompleted: boolean,
  }>,

  // Audience voting status
  audienceVotes: {
    total: number,
    byProject: Record<string, number>,
  },

  // Controls
  canStartVoting: boolean,
  canCloseVoting: boolean,
  canRevealResults: boolean,
}

setCursor

Purpose: Move live ceremony to a specific project/cohort

// Input schema
z.object({
  roundId: z.string(),
  cohortId: z.string().optional(),
  projectId: z.string().optional(),
})

// Updates LiveProgressCursor.currentCohortId, currentProjectId
// Output: LiveProgressCursor

11. Mentor Workspace Router (new)

File: src/server/routers/mentor-workspace.ts

New Procedures

getWorkspace

Purpose: Mentor or applicant views the shared workspace

// Input schema (protectedProcedure)
z.object({ mentorAssignmentId: z.string() })

// Validation: User must be mentor or project team member
// Output
{
  mentorAssignment: {
    ...assignment,
    mentor: { id, name, email },
    project: { id, title, teamName },
    workspaceEnabled: boolean,
    workspaceOpenAt: DateTime | null,
    workspaceCloseAt: DateTime | null,
  },
  files: Array<{
    ...mentorFile,
    uploadedBy: { id, name },
    comments: Array<MentorFileComment>,
    isPromoted: boolean,
    promotedToFile: { id, fileName } | null,
  }>,
  canUpload: boolean,
  canComment: boolean,
  canPromote: boolean,
}

uploadFile

// Input schema (protectedProcedure)
z.object({
  mentorAssignmentId: z.string(),
  fileName: z.string(),
  mimeType: z.string(),
  size: z.number().int().positive(),
  description: z.string().optional(),
})

// Creates MentorFile, returns pre-signed upload URL
// Output: { uploadUrl, fileId }

addComment

// Input schema (protectedProcedure)
z.object({
  mentorFileId: z.string(),
  content: z.string().min(1).max(2000),
  parentCommentId: z.string().optional(),  // For threading
})

// Output: MentorFileComment

promoteFile

Purpose: Mentor promotes a workspace file to official submission

// Input schema (mentorProcedure)
z.object({
  mentorFileId: z.string(),
  submissionWindowId: z.string(),
  requirementId: z.string().optional(),
})

// Calls file.promoteFromMentor internally
// Output: ProjectFile

12. Special Award Router (renamed to Award, enhanced)

File: src/server/routers/award.ts (renamed from specialAward.ts)

Modified Procedures

create → Enhanced for two-mode awards

Changes: Add eligibilityMode, juryGroupId, evaluationRoundId

// Input schema
z.object({
  competitionId: z.string(),  // CHANGED from programId
  name: z.string().min(1),
  description: z.string().optional(),
  criteriaText: z.string().optional(),

  // NEW: Award mode
  eligibilityMode: z.enum(['SEPARATE_POOL', 'STAY_IN_MAIN']).default('STAY_IN_MAIN'),

  // Scoring/voting
  scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
  maxRankedPicks: z.number().int().optional(),

  // Decision
  decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).default('JURY_VOTE'),

  // NEW: Links to other entities
  evaluationRoundId: z.string().optional(),  // Which round this award runs alongside
  juryGroupId: z.string().optional(),  // Dedicated or shared jury group

  // AI eligibility
  useAiEligibility: z.boolean().default(false),
  autoTagRulesJson: z.record(z.unknown()).optional(),
})

// Output: SpecialAward

update → Add new fields

// Input schema (all fields optional)
z.object({
  id: z.string(),
  name: z.string().optional(),
  description: z.string().optional(),
  criteriaText: z.string().optional(),
  eligibilityMode: z.enum(['SEPARATE_POOL', 'STAY_IN_MAIN']).optional(),  // NEW
  scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(),
  maxRankedPicks: z.number().int().optional(),
  decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).optional(),
  evaluationRoundId: z.string().optional(),  // NEW
  juryGroupId: z.string().optional(),  // NEW
  useAiEligibility: z.boolean().optional(),
  votingStartAt: z.date().optional(),
  votingEndAt: z.date().optional(),
})

// Output: SpecialAward

New Procedures

getEligibilityByMode

Purpose: View eligible projects with mode-specific filtering

// Input schema
z.object({ awardId: z.string() })

// Output
{
  award: { ...specialAward, eligibilityMode },
  eligibleProjects: Array<{
    project: { id, title, teamName, competitionCategory },
    eligibility: AwardEligibility,

    // For SEPARATE_POOL mode
    wasRemovedFromMain: boolean,

    // For STAY_IN_MAIN mode
    mainRoundStatus: ProjectRoundStateValue,
  }>,
}

13. Auth & User Router

File: src/server/routers/user.ts

New Procedures

listJuryMembers

Purpose: Admin lists all jury members for jury group assignment

// Input schema
z.object({
  programId: z.string().optional(),
  onlyActive: z.boolean().default(true),
})

// Output
Array<{
  ...user,
  juryGroupMemberships: Array<{
    juryGroup: { id, name, competition: { id, name } },
    isLead: boolean,
  }>,
  _count: { assignments, juryGroupMemberships },
}>

Procedure Access Matrix

Router Procedure Public Applicant Jury Mentor Observer Admin Super Admin
competition create
update
delete
get
list
publish
createStructure
updateStructure
getDraft
simulate
getRoundAnalytics
round get
transition
openWindow
closeWindow
getProjectStates
getForJury
getApplicantTimeline
getRequirements
jury-group create
update
delete
get ✓*
list ✓*
addMember
updateMember
removeMember
bulkAddMembers
getOnboarding ✓*
submitOnboarding ✓*
submission-window create
update
delete
get ✓* ✓* ✓*
list ✓* ✓* ✓*
addFileRequirement
updateFileRequirement
deleteFileRequirement
setRoundVisibility
winner-confirmation createProposal
getProposal ✓*
listProposals
requestApproval
approve ✓* ✓*
getMyPendingApprovals ✓* ✓*
override
freeze
unfreeze
assignment create
bulkCreate
delete
listByRound
listByProject
myAssignments
get ✓* ✓*
getStats
getSuggestions
getAISuggestions
applySuggestions
getJuryGroupStats
evaluation get ✓*
start ✓*
autosave ✓*
submit ✓*
getProjectStats
listByRound
myPastEvaluations
declareCOI ✓*
getCOIStatus ✓*
listCOIByRound
reviewCOI
generateSummary
getSummary
getCrossRoundDocs ✓*
file getDownloadUrl ✓* ✓* ✓*
getUploadUrl
promoteFromMentor ✓*
applicant getSubmissionBySlug
getMySubmission
saveSubmission
getCompetitionView ✓*
submitToWindow ✓*
mentor-workspace getWorkspace ✓* ✓*
uploadFile ✓* ✓*
addComment ✓* ✓*
promoteFile
award create
update
delete
get ✓* ✓* ✓*
list ✓* ✓* ✓*
updateStatus
runEligibility
getEligibilityByMode
live-control getStageManager
setCursor
startVoting
closeVoting
castJuryVote ✓*
castAudienceVote ✓* ✓* ✓* ✓* ✓* ✓*

Legend:

  • ✓* = Conditional access (must be assigned, own project, be in jury group, etc.)
  • = Unconditional access for that role

Migration Checklist: tRPC Updates

Phase 1: Rename Existing Routers

  • Rename pipeline.tscompetition.ts
  • Update all imports in UI components
  • Update all trpc.pipeline.* calls → trpc.competition.*

Phase 2: Split Stage Router

  • Create round.ts for runtime operations
  • Move procedures: transition, openWindow, closeWindow, getProjectStates, getForJury, getApplicantTimeline, getRequirements
  • Update all trpc.stage.* calls to reference correct router

Phase 3: Create New Routers

  • Create jury-group.ts
  • Create winner-confirmation.ts
  • Create submission-window.ts
  • Create mentor-workspace.ts

Phase 4: Enhance Existing Routers

  • Update assignment.ts with jury group awareness
  • Update evaluation.ts with cross-round doc visibility
  • Update file.ts with submission window association
  • Update applicant.ts with multi-round support
  • Rename specialAward.tsaward.ts and enhance

Phase 5: Update App Router

  • Update src/server/routers/_app.ts with new routers
  • Export all renamed routers
  • Run type generation: npm run trpc:generate

Total Procedures:

  • Current: ~180
  • New: ~60
  • Modified: ~30
  • Removed: ~20
  • Final: ~250

This comprehensive reference ensures every API change is documented with complete type safety.