Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s

Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 23:04:15 +01:00
parent 9ab4717f96
commit 6ca39c976b
349 changed files with 69938 additions and 28767 deletions

View File

@@ -0,0 +1,330 @@
import { z } from 'zod'
import type { RoundType, AwardEligibilityMode, AwardScoringMode, AwardStatus } from '@prisma/client'
// ============================================================================
// Round-Type-Specific Zod Config Schemas
// ============================================================================
// Each RoundType has a dedicated config schema stored in Round.configJson.
// These replace the loosely-typed pipeline-wizard.ts configs with
// Zod-validated, compile-time-safe contracts.
// ─── 1. IntakeConfig ─────────────────────────────────────────────────────────
export const IntakeConfigSchema = z.object({
allowDrafts: z.boolean().default(true),
draftExpiryDays: z.number().int().positive().default(30),
acceptedCategories: z
.array(z.enum(['STARTUP', 'BUSINESS_CONCEPT']))
.default(['STARTUP', 'BUSINESS_CONCEPT']),
maxFileSizeMB: z.number().int().positive().default(50),
maxFilesPerSlot: z.number().int().positive().default(1),
allowedMimeTypes: z.array(z.string()).default(['application/pdf']),
lateSubmissionNotification: z.boolean().default(true),
publicFormEnabled: z.boolean().default(false),
customFields: z
.array(
z.object({
id: z.string(),
label: z.string(),
type: z.enum(['text', 'textarea', 'select', 'checkbox', 'date']),
required: z.boolean().default(false),
options: z.array(z.string()).optional(),
}),
)
.default([]),
})
export type IntakeConfig = z.infer<typeof IntakeConfigSchema>
// ─── 2. FilteringConfig ──────────────────────────────────────────────────────
export const FilteringConfigSchema = z.object({
rules: z
.array(
z.object({
id: z.string(),
name: z.string(),
ruleType: z.enum(['FIELD_BASED', 'DOCUMENT_CHECK']),
conditions: z.any(),
action: z.enum(['PASS', 'REJECT', 'FLAG']),
}),
)
.default([]),
aiScreeningEnabled: z.boolean().default(true),
aiCriteriaText: z.string().optional(),
aiConfidenceThresholds: z
.object({
high: z.number().min(0).max(1).default(0.85),
medium: z.number().min(0).max(1).default(0.6),
low: z.number().min(0).max(1).default(0.4),
})
.default({ high: 0.85, medium: 0.6, low: 0.4 }),
manualReviewEnabled: z.boolean().default(true),
autoAdvanceEligible: z.boolean().default(false),
duplicateDetectionEnabled: z.boolean().default(true),
batchSize: z.number().int().positive().default(20),
})
export type FilteringConfig = z.infer<typeof FilteringConfigSchema>
// ─── 3. EvaluationConfig ─────────────────────────────────────────────────────
export const EvaluationConfigSchema = z.object({
requiredReviewsPerProject: z.number().int().positive().default(3),
scoringMode: z.enum(['criteria', 'global', 'binary']).default('criteria'),
requireFeedback: z.boolean().default(true),
feedbackMinLength: z.number().int().nonnegative().default(0),
requireAllCriteriaScored: z.boolean().default(true),
coiRequired: z.boolean().default(true),
peerReviewEnabled: z.boolean().default(false),
anonymizationLevel: z
.enum(['fully_anonymous', 'show_initials', 'named'])
.default('fully_anonymous'),
aiSummaryEnabled: z.boolean().default(false),
generateAiShortlist: z.boolean().default(false),
advancementMode: z
.enum(['auto_top_n', 'admin_selection', 'ai_recommended'])
.default('admin_selection'),
advancementConfig: z
.object({
perCategory: z.boolean().default(true),
startupCount: z.number().int().nonnegative().default(10),
conceptCount: z.number().int().nonnegative().default(10),
tieBreaker: z
.enum(['admin_decides', 'highest_individual', 'revote'])
.default('admin_decides'),
})
.optional(),
})
export type EvaluationConfig = z.infer<typeof EvaluationConfigSchema>
// ─── 4. SubmissionConfig ─────────────────────────────────────────────────────
export const SubmissionConfigSchema = z.object({
eligibleStatuses: z
.array(
z.enum([
'PENDING',
'IN_PROGRESS',
'PASSED',
'REJECTED',
'COMPLETED',
'WITHDRAWN',
]),
)
.default(['PASSED']),
notifyEligibleTeams: z.boolean().default(true),
lockPreviousWindows: z.boolean().default(true),
})
export type SubmissionConfig = z.infer<typeof SubmissionConfigSchema>
// ─── 5. MentoringConfig ──────────────────────────────────────────────────────
export const MentoringConfigSchema = z.object({
eligibility: z
.enum(['all_advancing', 'requested_only', 'admin_selected'])
.default('requested_only'),
chatEnabled: z.boolean().default(true),
fileUploadEnabled: z.boolean().default(true),
fileCommentsEnabled: z.boolean().default(true),
filePromotionEnabled: z.boolean().default(true),
promotionTargetWindowId: z.string().optional(),
autoAssignMentors: z.boolean().default(false),
})
export type MentoringConfig = z.infer<typeof MentoringConfigSchema>
// ─── 6. LiveFinalConfig ──────────────────────────────────────────────────────
export const LiveFinalConfigSchema = z.object({
juryVotingEnabled: z.boolean().default(true),
votingMode: z.enum(['simple', 'criteria']).default('simple'),
audienceVotingEnabled: z.boolean().default(false),
audienceVoteWeight: z.number().min(0).max(1).default(0),
audienceVotingMode: z
.enum(['per_project', 'per_category', 'favorites'])
.default('per_project'),
audienceMaxFavorites: z.number().int().positive().default(3),
audienceRequireIdentification: z.boolean().default(false),
audienceRevealTiming: z
.enum(['real_time', 'after_jury_scores', 'at_deliberation', 'never'])
.default('at_deliberation'),
deliberationEnabled: z.boolean().default(false),
deliberationDurationMinutes: z.number().int().positive().default(30),
showAudienceVotesToJury: z.boolean().default(false),
presentationOrderMode: z
.enum(['manual', 'random', 'score_based'])
.default('manual'),
presentationDurationMinutes: z.number().int().positive().default(15),
qaDurationMinutes: z.number().int().nonnegative().default(5),
revealPolicy: z.enum(['immediate', 'delayed', 'ceremony']).default('ceremony'),
})
export type LiveFinalConfig = z.infer<typeof LiveFinalConfigSchema>
// ─── 7. DeliberationConfig ───────────────────────────────────────────────────
export const DeliberationConfigSchema = z.object({
juryGroupId: z.string(),
mode: z
.enum(['SINGLE_WINNER_VOTE', 'FULL_RANKING'])
.default('SINGLE_WINNER_VOTE'),
showCollectiveRankings: z.boolean().default(false),
showPriorJuryData: z.boolean().default(false),
tieBreakMethod: z
.enum(['RUNOFF', 'ADMIN_DECIDES', 'SCORE_FALLBACK'])
.default('ADMIN_DECIDES'),
votingDuration: z.number().int().positive().default(60),
topN: z.number().int().positive().default(3),
allowAdminOverride: z.boolean().default(true),
})
export type DeliberationConfig = z.infer<typeof DeliberationConfigSchema>
// ============================================================================
// Config Map & Helpers
// ============================================================================
export type RoundConfigMap = {
INTAKE: IntakeConfig
FILTERING: FilteringConfig
EVALUATION: EvaluationConfig
SUBMISSION: SubmissionConfig
MENTORING: MentoringConfig
LIVE_FINAL: LiveFinalConfig
DELIBERATION: DeliberationConfig
}
const schemaMap: Record<RoundType, z.ZodSchema> = {
INTAKE: IntakeConfigSchema,
FILTERING: FilteringConfigSchema,
EVALUATION: EvaluationConfigSchema,
SUBMISSION: SubmissionConfigSchema,
MENTORING: MentoringConfigSchema,
LIVE_FINAL: LiveFinalConfigSchema,
DELIBERATION: DeliberationConfigSchema,
}
/**
* Validates a configJson object against the Zod schema for the given RoundType.
* Returns the parsed (with defaults applied) config or throws a ZodError.
*/
export function validateRoundConfig<T extends RoundType>(
roundType: T,
config: unknown,
): RoundConfigMap[T] {
const schema = schemaMap[roundType]
return schema.parse(config) as RoundConfigMap[T]
}
/**
* Returns safe-parsed result (no throw) for config validation.
*/
export function safeValidateRoundConfig<T extends RoundType>(
roundType: T,
config: unknown,
): z.SafeParseReturnType<unknown, RoundConfigMap[T]> {
const schema = schemaMap[roundType]
return schema.safeParse(config) as z.SafeParseReturnType<
unknown,
RoundConfigMap[T]
>
}
/**
* Returns default config for the given RoundType with all defaults applied.
* For DELIBERATION, a placeholder juryGroupId is used — caller must replace.
*/
export function defaultRoundConfig<T extends RoundType>(
roundType: T,
): RoundConfigMap[T] {
const defaults: Record<RoundType, () => unknown> = {
INTAKE: () => IntakeConfigSchema.parse({}),
FILTERING: () => FilteringConfigSchema.parse({}),
EVALUATION: () => EvaluationConfigSchema.parse({}),
SUBMISSION: () => SubmissionConfigSchema.parse({}),
MENTORING: () => MentoringConfigSchema.parse({}),
LIVE_FINAL: () => LiveFinalConfigSchema.parse({}),
DELIBERATION: () =>
DeliberationConfigSchema.parse({ juryGroupId: 'PLACEHOLDER' }),
}
return defaults[roundType]() as RoundConfigMap[T]
}
// ============================================================================
// Award Config Types
// ============================================================================
// Typed interfaces matching Prisma enums for award configuration.
export const AwardEligibilityModeSchema = z.enum(['SEPARATE_POOL', 'STAY_IN_MAIN'])
export const AwardScoringModeSchema = z.enum(['PICK_WINNER', 'RANKED', 'SCORED'])
export const AwardStatusSchema = z.enum([
'DRAFT',
'NOMINATIONS_OPEN',
'VOTING_OPEN',
'VOTING_CLOSED',
'DECIDED',
'ANNOUNCED',
])
export const AwardDecisionModeSchema = z.enum([
'JURY_VOTE',
'AWARD_MASTER_DECISION',
'ADMIN_DECISION',
])
export const AwardConfigSchema = z.object({
/** How eligible projects relate to the main competition track */
eligibilityMode: AwardEligibilityModeSchema.default('STAY_IN_MAIN'),
/** How the winner is determined */
scoringMode: AwardScoringModeSchema.default('PICK_WINNER'),
/** Who makes the final decision */
decisionMode: AwardDecisionModeSchema.default('JURY_VOTE'),
/** Max ranked picks for RANKED scoring mode */
maxRankedPicks: z.number().int().positive().optional(),
/** Whether AI evaluates eligibility */
useAiEligibility: z.boolean().default(true),
/** Plain-language criteria for AI interpretation */
criteriaText: z.string().optional(),
/** Structured auto-tag rules for deterministic eligibility */
autoTagRules: z
.array(
z.object({
field: z.enum([
'competitionCategory',
'country',
'geographicZone',
'tags',
'oceanIssue',
]),
operator: z.enum(['equals', 'contains', 'in']),
value: z.union([z.string(), z.array(z.string())]),
}),
)
.default([]),
})
export type AwardConfig = z.infer<typeof AwardConfigSchema>

171
src/types/competition.ts Normal file
View File

@@ -0,0 +1,171 @@
import type {
Competition,
Round,
JuryGroup,
JuryGroupMember,
SubmissionWindow,
SubmissionFileRequirement,
AdvancementRule,
RoundSubmissionVisibility,
ProjectRoundState,
DeliberationSession,
ResultLock,
RoundType,
CompetitionStatus,
RoundStatus,
CapMode,
JuryGroupMemberRole,
} from '@prisma/client'
import type { RoundConfigMap } from './competition-configs'
// ============================================================================
// Composite Types (models with nested relations)
// ============================================================================
export type CompetitionWithRounds = Competition & {
rounds: RoundSummary[]
juryGroups: JuryGroupSummary[]
submissionWindows: SubmissionWindowSummary[]
}
export type RoundSummary = Pick<
Round,
'id' | 'name' | 'slug' | 'roundType' | 'status' | 'sortOrder' | 'windowOpenAt' | 'windowCloseAt'
>
export type RoundWithRelations = Round & {
juryGroup: (JuryGroup & { members: JuryGroupMember[] }) | null
submissionWindow: (SubmissionWindow & { fileRequirements: SubmissionFileRequirement[] }) | null
advancementRules: AdvancementRule[]
visibleSubmissionWindows: (RoundSubmissionVisibility & { submissionWindow: SubmissionWindow })[]
_count?: { projectRoundStates: number; assignments: number }
}
export type JuryGroupSummary = Pick<
JuryGroup,
'id' | 'name' | 'slug' | 'sortOrder' | 'defaultMaxAssignments' | 'defaultCapMode'
> & {
_count: { members: number }
}
export type JuryGroupWithMembers = JuryGroup & {
members: (JuryGroupMember & {
user: { id: string; name: string | null; email: string; role: string }
})[]
}
export type SubmissionWindowSummary = Pick<
SubmissionWindow,
'id' | 'name' | 'slug' | 'roundNumber' | 'windowOpenAt' | 'windowCloseAt' | 'isLocked'
> & {
_count: { fileRequirements: number; projectFiles: number }
}
// ============================================================================
// Policy Resolution
// ============================================================================
export type PolicySource =
| 'system'
| 'program'
| 'jury_group'
| 'member'
| 'admin_override'
export type PolicyResolution<T> = {
value: T
source: PolicySource
explanation: string
}
// ============================================================================
// tRPC Input Types
// ============================================================================
export type CreateCompetitionInput = {
programId: string
name: string
slug: string
categoryMode?: string
startupFinalistCount?: number
conceptFinalistCount?: number
notifyOnRoundAdvance?: boolean
notifyOnDeadlineApproach?: boolean
deadlineReminderDays?: number[]
}
export type UpdateCompetitionInput = {
id: string
name?: string
slug?: string
status?: CompetitionStatus
categoryMode?: string
startupFinalistCount?: number
conceptFinalistCount?: number
notifyOnRoundAdvance?: boolean
notifyOnDeadlineApproach?: boolean
deadlineReminderDays?: number[]
}
export type CreateRoundInput = {
competitionId: string
name: string
slug: string
roundType: RoundType
sortOrder: number
configJson?: Record<string, unknown>
windowOpenAt?: Date | null
windowCloseAt?: Date | null
juryGroupId?: string | null
submissionWindowId?: string | null
purposeKey?: string | null
}
export type UpdateRoundInput = {
id: string
name?: string
slug?: string
status?: RoundStatus
configJson?: Record<string, unknown>
windowOpenAt?: Date | null
windowCloseAt?: Date | null
juryGroupId?: string | null
submissionWindowId?: string | null
purposeKey?: string | null
}
export type CreateJuryGroupInput = {
competitionId: string
name: string
slug: string
description?: string
sortOrder?: number
defaultMaxAssignments?: number
defaultCapMode?: CapMode
softCapBuffer?: number
categoryQuotasEnabled?: boolean
defaultCategoryQuotas?: Record<string, { min: number; max: number }>
allowJurorCapAdjustment?: boolean
allowJurorRatioAdjustment?: boolean
}
export type AddJuryGroupMemberInput = {
juryGroupId: string
userId: string
role?: JuryGroupMemberRole
maxAssignmentsOverride?: number | null
capModeOverride?: CapMode | null
categoryQuotasOverride?: Record<string, { min: number; max: number }> | null
preferredStartupRatio?: number | null
availabilityNotes?: string | null
}
export type UpdateJuryGroupMemberInput = {
id: string
role?: JuryGroupMemberRole
maxAssignmentsOverride?: number | null
capModeOverride?: CapMode | null
categoryQuotasOverride?: Record<string, { min: number; max: number }> | null
preferredStartupRatio?: number | null
availabilityNotes?: string | null
}

View File

@@ -1,137 +0,0 @@
import type { StageType, TrackKind, RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client'
// ============================================================================
// Stage Config Discriminated Unions
// ============================================================================
export type IntakeConfig = {
submissionWindowEnabled: boolean
lateSubmissionPolicy: 'reject' | 'flag' | 'accept'
lateGraceHours: number
fileRequirements: FileRequirementConfig[]
}
export type FileRequirementConfig = {
name: string
description?: string
acceptedMimeTypes: string[]
maxSizeMB?: number
isRequired: boolean
}
export type FilterConfig = {
rules: FilterRuleConfig[]
aiRubricEnabled: boolean
aiCriteriaText: string
aiConfidenceThresholds: {
high: number
medium: number
low: number
}
manualQueueEnabled: boolean
}
export type FilterRuleConfig = {
field: string
operator: string
value: string | number | boolean
weight: number
}
export type EvaluationConfig = {
requiredReviews: number
maxLoadPerJuror: number
minLoadPerJuror: number
availabilityWeighting: boolean
overflowPolicy: 'queue' | 'expand_pool' | 'reduce_reviews'
categoryQuotasEnabled?: boolean
categoryQuotas?: Record<string, { min: number; max: number }>
}
export type SelectionConfig = {
finalistCount?: number
rankingMethod: 'score_average' | 'weighted_criteria' | 'binary_pass'
tieBreaker: 'admin_decides' | 'highest_individual' | 'revote'
categoryQuotasEnabled?: boolean
categoryQuotas?: Record<string, number>
}
export type LiveFinalConfig = {
juryVotingEnabled: boolean
audienceVotingEnabled: boolean
audienceVoteWeight: number
cohortSetupMode: 'auto' | 'manual'
revealPolicy: 'immediate' | 'delayed' | 'ceremony'
}
export type ResultsConfig = {
publicationMode: 'manual' | 'auto_on_close'
showDetailedScores: boolean
showRankings: boolean
}
export type StageConfigMap = {
INTAKE: IntakeConfig
FILTER: FilterConfig
EVALUATION: EvaluationConfig
SELECTION: SelectionConfig
LIVE_FINAL: LiveFinalConfig
RESULTS: ResultsConfig
}
// ============================================================================
// Wizard Stage / Track / State Types
// ============================================================================
export type WizardStageConfig = {
id?: string
name: string
slug: string
stageType: StageType
sortOrder: number
configJson: Record<string, unknown>
windowOpenAt?: Date | null
windowCloseAt?: Date | null
}
export type WizardTrackConfig = {
id?: string
name: string
slug: string
kind: TrackKind
sortOrder: number
routingModeDefault?: RoutingMode
decisionMode?: DecisionMode
stages: WizardStageConfig[]
awardConfig?: {
name: string
description?: string
scoringMode?: AwardScoringMode
}
}
export type WizardState = {
name: string
slug: string
programId: string
settingsJson: Record<string, unknown>
tracks: WizardTrackConfig[]
notificationConfig: Record<string, boolean>
overridePolicy: Record<string, unknown>
}
// ============================================================================
// Validation Result
// ============================================================================
export type ValidationResult = {
valid: boolean
errors: string[]
warnings: string[]
}
export type SectionValidation = {
basics: ValidationResult
tracks: ValidationResult
notifications: ValidationResult
}