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

49
src/lib/feature-flags.ts Normal file
View File

@@ -0,0 +1,49 @@
import { prisma } from '@/lib/prisma'
/**
* Feature flag keys — used to control progressive rollout of new architecture.
* Stored as SystemSetting records with category FEATURE_FLAGS.
*/
export const FEATURE_FLAGS = {
/** Use Competition/Round model instead of Pipeline/Track/Stage */
USE_COMPETITION_MODEL: 'feature.useCompetitionModel',
} as const
type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]
/**
* Check if a feature flag is enabled (server-side).
* Returns false if the flag doesn't exist in the database.
*/
export async function isFeatureEnabled(flag: FeatureFlagKey): Promise<boolean> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: flag },
})
// Default to true for competition model (legacy Pipeline system removed)
if (!setting) return flag === FEATURE_FLAGS.USE_COMPETITION_MODEL ? true : false
return setting.value === 'true'
} catch {
return flag === FEATURE_FLAGS.USE_COMPETITION_MODEL ? true : false
}
}
/**
* Set a feature flag value (server-side, admin only).
*/
export async function setFeatureFlag(
flag: FeatureFlagKey,
enabled: boolean,
): Promise<void> {
await prisma.systemSettings.upsert({
where: { key: flag },
update: { value: String(enabled) },
create: {
key: flag,
value: String(enabled),
type: 'BOOLEAN',
category: 'FEATURE_FLAGS',
description: `Feature flag: ${flag}`,
},
})
}

View File

@@ -1,67 +0,0 @@
import { normalizeStageConfig } from '@/lib/stage-config-schema'
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
type TrackInput = {
id: string
name: string
slug: string
kind: 'MAIN' | 'AWARD' | 'SHOWCASE'
sortOrder: number
routingMode: 'SHARED' | 'EXCLUSIVE' | null
decisionMode:
| 'JURY_VOTE'
| 'AWARD_MASTER_DECISION'
| 'ADMIN_DECISION'
| null
stages: Array<{
id: string
name: string
slug: string
stageType:
| 'INTAKE'
| 'FILTER'
| 'EVALUATION'
| 'SELECTION'
| 'LIVE_FINAL'
| 'RESULTS'
sortOrder: number
configJson: unknown
}>
specialAward?: {
name: string
description: string | null
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
} | null
}
export function toWizardTrackConfig(track: TrackInput): WizardTrackConfig {
return {
id: track.id,
name: track.name,
slug: track.slug,
kind: track.kind,
sortOrder: track.sortOrder,
routingModeDefault: track.routingMode ?? undefined,
decisionMode: track.decisionMode ?? undefined,
stages: track.stages
.map((stage) => ({
id: stage.id,
name: stage.name,
slug: stage.slug,
stageType: stage.stageType,
sortOrder: stage.sortOrder,
configJson: normalizeStageConfig(
stage.stageType,
stage.configJson as Record<string, unknown> | null
),
}))
.sort((a, b) => a.sortOrder - b.sortOrder),
awardConfig: track.specialAward
? {
name: track.specialAward.name,
description: track.specialAward.description ?? undefined,
scoringMode: track.specialAward.scoringMode,
}
: undefined,
}
}

View File

@@ -1,145 +0,0 @@
import type {
IntakeConfig,
FilterConfig,
EvaluationConfig,
SelectionConfig,
LiveFinalConfig,
ResultsConfig,
WizardStageConfig,
WizardTrackConfig,
WizardState,
} from '@/types/pipeline-wizard'
export function defaultIntakeConfig(): IntakeConfig {
return {
submissionWindowEnabled: true,
lateSubmissionPolicy: 'flag',
lateGraceHours: 24,
fileRequirements: [
{
name: 'Executive Summary',
description: 'A PDF executive summary of your project',
acceptedMimeTypes: ['application/pdf'],
maxSizeMB: 50,
isRequired: true,
},
],
}
}
export function defaultFilterConfig(): FilterConfig {
return {
rules: [],
aiRubricEnabled: false,
aiCriteriaText: '',
aiConfidenceThresholds: { high: 0.85, medium: 0.6, low: 0.4 },
manualQueueEnabled: true,
}
}
export function defaultEvaluationConfig(): EvaluationConfig {
return {
requiredReviews: 3,
maxLoadPerJuror: 20,
minLoadPerJuror: 5,
availabilityWeighting: true,
overflowPolicy: 'queue',
}
}
export function defaultSelectionConfig(): SelectionConfig {
return {
finalistCount: undefined,
rankingMethod: 'score_average',
tieBreaker: 'admin_decides',
categoryQuotasEnabled: false,
categoryQuotas: { STARTUP: 3, BUSINESS_CONCEPT: 3 },
}
}
export function defaultLiveConfig(): LiveFinalConfig {
return {
juryVotingEnabled: true,
audienceVotingEnabled: false,
audienceVoteWeight: 0,
cohortSetupMode: 'manual',
revealPolicy: 'ceremony',
}
}
export function defaultResultsConfig(): ResultsConfig {
return {
publicationMode: 'manual',
showDetailedScores: false,
showRankings: true,
}
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function defaultMainTrackStages(): WizardStageConfig[] {
return [
{ name: 'Intake', slug: 'intake', stageType: 'INTAKE', sortOrder: 0, configJson: defaultIntakeConfig() as unknown as Record<string, unknown> },
{ name: 'Filtering', slug: 'filtering', stageType: 'FILTER', sortOrder: 1, configJson: defaultFilterConfig() as unknown as Record<string, unknown> },
{ name: 'Evaluation', slug: 'evaluation', stageType: 'EVALUATION', sortOrder: 2, configJson: defaultEvaluationConfig() as unknown as Record<string, unknown> },
{ name: 'Selection', slug: 'selection', stageType: 'SELECTION', sortOrder: 3, configJson: defaultSelectionConfig() as unknown as Record<string, unknown> },
{ name: 'Live Finals', slug: 'live-finals', stageType: 'LIVE_FINAL', sortOrder: 4, configJson: defaultLiveConfig() as unknown as Record<string, unknown> },
{ name: 'Results', slug: 'results', stageType: 'RESULTS', sortOrder: 5, configJson: defaultResultsConfig() as unknown as Record<string, unknown> },
]
}
export function defaultMainTrack(): WizardTrackConfig {
return {
name: 'Main Competition',
slug: 'main-competition',
kind: 'MAIN',
sortOrder: 0,
stages: defaultMainTrackStages(),
}
}
export function defaultAwardTrack(index: number): WizardTrackConfig {
const name = `Award ${index + 1}`
return {
name,
slug: slugify(name),
kind: 'AWARD',
sortOrder: index + 1,
routingModeDefault: 'SHARED',
decisionMode: 'JURY_VOTE',
stages: [
{ name: 'Evaluation', slug: 'evaluation', stageType: 'EVALUATION', sortOrder: 0, configJson: defaultEvaluationConfig() as unknown as Record<string, unknown> },
{ name: 'Results', slug: 'results', stageType: 'RESULTS', sortOrder: 1, configJson: defaultResultsConfig() as unknown as Record<string, unknown> },
],
awardConfig: { name, scoringMode: 'PICK_WINNER' },
}
}
export function defaultNotificationConfig(): Record<string, boolean> {
return {
'stage.transitioned': true,
'filtering.completed': true,
'assignment.generated': true,
'live.cursor.updated': true,
'cohort.window.changed': true,
'decision.overridden': true,
'award.winner.finalized': true,
}
}
export function defaultWizardState(programId: string): WizardState {
return {
name: '',
slug: '',
programId,
settingsJson: {},
tracks: [defaultMainTrack()],
notificationConfig: defaultNotificationConfig(),
overridePolicy: { allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
}
}

View File

@@ -1,149 +0,0 @@
import type { ValidationResult, WizardState, WizardTrackConfig, WizardStageConfig } from '@/types/pipeline-wizard'
import { parseAndValidateStageConfig } from '@/lib/stage-config-schema'
function ok(): ValidationResult {
return { valid: true, errors: [], warnings: [] }
}
function fail(errors: string[], warnings: string[] = []): ValidationResult {
return { valid: false, errors, warnings }
}
export function validateBasics(state: WizardState): ValidationResult {
const errors: string[] = []
if (!state.name.trim()) errors.push('Pipeline name is required')
if (!state.slug.trim()) errors.push('Pipeline slug is required')
else if (!/^[a-z0-9-]+$/.test(state.slug)) errors.push('Slug must be lowercase alphanumeric with hyphens only')
if (!state.programId) errors.push('Program must be selected')
return errors.length ? fail(errors) : ok()
}
export function validateStage(stage: WizardStageConfig): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
if (!stage.name.trim()) errors.push(`Stage name is required`)
if (!stage.slug.trim()) errors.push(`Stage slug is required`)
else if (!/^[a-z0-9-]+$/.test(stage.slug)) errors.push(`Stage slug "${stage.slug}" is invalid`)
try {
parseAndValidateStageConfig(stage.stageType, stage.configJson, {
strictUnknownKeys: true,
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid stage config'
errors.push(`Stage "${stage.name || stage.slug}" config invalid: ${message}`)
}
if (stage.windowOpenAt && stage.windowCloseAt && stage.windowCloseAt <= stage.windowOpenAt) {
errors.push(`Stage "${stage.name || stage.slug}" close window must be after open window`)
}
if (stage.stageType === 'SELECTION') {
const config = stage.configJson as Record<string, unknown>
if (config.finalistCount == null) {
warnings.push(`Selection stage "${stage.name || stage.slug}" has no finalist target`)
}
}
return errors.length ? fail(errors, warnings) : { valid: true, errors: [], warnings }
}
export function validateTrack(track: WizardTrackConfig): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
if (!track.name.trim()) errors.push('Track name is required')
if (!track.slug.trim()) errors.push('Track slug is required')
if (track.stages.length === 0) errors.push(`Track "${track.name}" must have at least one stage`)
// Check for duplicate slugs within track
const slugs = new Set<string>()
for (const stage of track.stages) {
if (slugs.has(stage.slug)) {
errors.push(`Duplicate stage slug "${stage.slug}" in track "${track.name}"`)
}
slugs.add(stage.slug)
const stageResult = validateStage(stage)
errors.push(...stageResult.errors)
}
// MAIN track should ideally have at least INTAKE and one other stage
if (track.kind === 'MAIN' && track.stages.length < 2) {
warnings.push('Main track should have at least 2 stages')
}
if (track.kind === 'MAIN') {
const stageTypes = new Set(track.stages.map((s) => s.stageType))
const requiredStageTypes: Array<WizardStageConfig['stageType']> = [
'INTAKE',
'FILTER',
'EVALUATION',
]
for (const stageType of requiredStageTypes) {
if (!stageTypes.has(stageType)) {
warnings.push(`Main track is missing recommended ${stageType} stage`)
}
}
}
// AWARD tracks need awardConfig
if (track.kind === 'AWARD' && !track.awardConfig?.name) {
errors.push(`Award track "${track.name}" requires an award name`)
}
return errors.length ? fail(errors, warnings) : { valid: true, errors: [], warnings }
}
export function validateTracks(tracks: WizardTrackConfig[]): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
if (tracks.length === 0) {
errors.push('At least one track is required')
return fail(errors)
}
const mainTracks = tracks.filter((t) => t.kind === 'MAIN')
if (mainTracks.length === 0) {
errors.push('At least one MAIN track is required')
} else if (mainTracks.length > 1) {
warnings.push('Multiple MAIN tracks detected — typically only one is needed')
}
// Check for duplicate track slugs
const trackSlugs = new Set<string>()
for (const track of tracks) {
if (trackSlugs.has(track.slug)) {
errors.push(`Duplicate track slug "${track.slug}"`)
}
trackSlugs.add(track.slug)
const trackResult = validateTrack(track)
errors.push(...trackResult.errors)
warnings.push(...trackResult.warnings)
}
return errors.length ? fail(errors, warnings) : { valid: true, errors: [], warnings }
}
export function validateNotifications(config: Record<string, boolean>): ValidationResult {
// Notifications are optional — just validate structure
return ok()
}
export function validateAll(state: WizardState): {
valid: boolean
sections: {
basics: ValidationResult
tracks: ValidationResult
notifications: ValidationResult
}
} {
const basics = validateBasics(state)
const tracks = validateTracks(state.tracks)
const notifications = validateNotifications(state.notificationConfig)
return {
valid: basics.valid && tracks.valid && notifications.valid,
sections: { basics, tracks, notifications },
}
}

View File

@@ -1,457 +0,0 @@
import { z } from 'zod'
import type { StageType } from '@prisma/client'
const STAGE_TYPES = [
'INTAKE',
'FILTER',
'EVALUATION',
'SELECTION',
'LIVE_FINAL',
'RESULTS',
] as const
type StageTypeKey = (typeof STAGE_TYPES)[number]
type JsonObject = Record<string, unknown>
const fileRequirementSchema = z
.object({
name: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
acceptedMimeTypes: z.array(z.string()).default([]),
maxSizeMB: z.number().int().min(1).max(5000).optional(),
isRequired: z.boolean().default(false),
})
.strict()
const intakeSchema = z
.object({
submissionWindowEnabled: z.boolean().default(true),
lateSubmissionPolicy: z.enum(['reject', 'flag', 'accept']).default('flag'),
lateGraceHours: z.number().int().min(0).max(168).default(24),
fileRequirements: z.array(fileRequirementSchema).default([]),
})
.strict()
const filterRuleSchema = z
.object({
field: z.string().min(1),
operator: z.string().min(1),
value: z.union([z.string(), z.number(), z.boolean()]),
weight: z.number().min(0).max(1).default(1),
})
.strict()
const filterSchema = z
.object({
rules: z.array(filterRuleSchema).default([]),
aiRubricEnabled: z.boolean().default(false),
aiCriteriaText: z.string().default(''),
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),
})
.strict()
.default({ high: 0.85, medium: 0.6, low: 0.4 }),
manualQueueEnabled: z.boolean().default(true),
})
.strict()
const evaluationSchema = z
.object({
requiredReviews: z.number().int().min(1).max(20).default(3),
maxLoadPerJuror: z.number().int().min(1).max(100).default(20),
minLoadPerJuror: z.number().int().min(0).max(50).default(5),
availabilityWeighting: z.boolean().default(true),
overflowPolicy: z
.enum(['queue', 'expand_pool', 'reduce_reviews'])
.default('queue'),
})
.strict()
.superRefine((value, ctx) => {
if (value.minLoadPerJuror > value.maxLoadPerJuror) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'minLoadPerJuror cannot exceed maxLoadPerJuror',
path: ['minLoadPerJuror'],
})
}
})
const selectionSchema = z
.object({
finalistCount: z.number().int().min(1).max(500).optional(),
rankingMethod: z
.enum(['score_average', 'weighted_criteria', 'binary_pass'])
.default('score_average'),
tieBreaker: z
.enum(['admin_decides', 'highest_individual', 'revote'])
.default('admin_decides'),
})
.strict()
const liveFinalSchema = z
.object({
juryVotingEnabled: z.boolean().default(true),
audienceVotingEnabled: z.boolean().default(false),
audienceVoteWeight: z.number().min(0).max(1).default(0),
cohortSetupMode: z.enum(['auto', 'manual']).default('manual'),
revealPolicy: z
.enum(['immediate', 'delayed', 'ceremony'])
.default('ceremony'),
})
.strict()
const resultsSchema = z
.object({
publicationMode: z.enum(['manual', 'auto_on_close']).default('manual'),
showDetailedScores: z.boolean().default(false),
showRankings: z.boolean().default(true),
})
.strict()
export const stageConfigSchemas: Record<
StageTypeKey,
z.ZodType<Record<string, unknown>>
> = {
INTAKE: intakeSchema,
FILTER: filterSchema,
EVALUATION: evaluationSchema,
SELECTION: selectionSchema,
LIVE_FINAL: liveFinalSchema,
RESULTS: resultsSchema,
}
const CANONICAL_KEYS: Record<StageTypeKey, string[]> = {
INTAKE: [
'submissionWindowEnabled',
'lateSubmissionPolicy',
'lateGraceHours',
'fileRequirements',
],
FILTER: [
'rules',
'aiRubricEnabled',
'aiCriteriaText',
'aiConfidenceThresholds',
'manualQueueEnabled',
],
EVALUATION: [
'requiredReviews',
'maxLoadPerJuror',
'minLoadPerJuror',
'availabilityWeighting',
'overflowPolicy',
],
SELECTION: ['finalistCount', 'rankingMethod', 'tieBreaker'],
LIVE_FINAL: [
'juryVotingEnabled',
'audienceVotingEnabled',
'audienceVoteWeight',
'cohortSetupMode',
'revealPolicy',
],
RESULTS: ['publicationMode', 'showDetailedScores', 'showRankings'],
}
const LEGACY_ALIAS_KEYS: Record<StageTypeKey, string[]> = {
INTAKE: ['lateSubmissionGrace', 'deadline', 'maxSubmissions'],
FILTER: ['deterministic', 'ai', 'confidenceBands'],
EVALUATION: [
'minAssignmentsPerJuror',
'maxAssignmentsPerJuror',
'criteriaVersion',
'assignmentStrategy',
],
SELECTION: ['finalistTarget', 'selectionMethod', 'rankingSource'],
LIVE_FINAL: [
'votingEnabled',
'audienceVoting',
'sessionMode',
'presentationDurationMinutes',
'qaDurationMinutes',
'votingMode',
'maxFavorites',
'requireIdentification',
'votingDurationMinutes',
],
RESULTS: ['publicationPolicy', 'rankingWeights', 'announcementDate'],
}
function isRecord(value: unknown): value is JsonObject {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function asRecord(value: unknown): JsonObject {
return isRecord(value) ? value : {}
}
function toStringSafe(value: unknown, fallback: string): string {
return typeof value === 'string' ? value : fallback
}
function toBool(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback
}
function toInt(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value)
? Math.trunc(value)
: fallback
}
function toFloat(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function mapLegacyMimeType(type: string | undefined): string[] {
switch ((type ?? '').toUpperCase()) {
case 'PDF':
return ['application/pdf']
case 'VIDEO':
return ['video/*']
case 'IMAGE':
return ['image/*']
case 'DOC':
case 'DOCX':
return [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]
case 'PPT':
case 'PPTX':
return [
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
]
default:
return []
}
}
function normalizeIntakeConfig(raw: JsonObject): JsonObject {
const rawRequirements = Array.isArray(raw.fileRequirements)
? raw.fileRequirements
: []
const fileRequirements = rawRequirements
.map((item) => {
const req = asRecord(item)
const acceptedMimeTypes = Array.isArray(req.acceptedMimeTypes)
? req.acceptedMimeTypes.filter((mime) => typeof mime === 'string')
: mapLegacyMimeType(
typeof req.type === 'string' ? req.type : undefined
)
return {
name: toStringSafe(req.name, '').trim(),
description: toStringSafe(req.description, ''),
acceptedMimeTypes,
maxSizeMB:
typeof req.maxSizeMB === 'number' && Number.isFinite(req.maxSizeMB)
? Math.trunc(req.maxSizeMB)
: undefined,
isRequired: toBool(req.isRequired, toBool(req.required, false)),
}
})
.filter((req) => req.name.length > 0)
return {
submissionWindowEnabled: toBool(raw.submissionWindowEnabled, true),
lateSubmissionPolicy: toStringSafe(raw.lateSubmissionPolicy, 'flag'),
lateGraceHours: toInt(
raw.lateGraceHours ?? raw.lateSubmissionGrace,
24
),
fileRequirements,
}
}
function normalizeFilterConfig(raw: JsonObject): JsonObject {
const deterministic = asRecord(raw.deterministic)
const aiLegacy = asRecord(raw.ai)
const confidenceBands = asRecord(raw.confidenceBands)
const highBand = asRecord(confidenceBands.high)
const mediumBand = asRecord(confidenceBands.medium)
const lowBand = asRecord(confidenceBands.low)
const sourceRules = Array.isArray(raw.rules)
? raw.rules
: Array.isArray(deterministic.rules)
? deterministic.rules
: []
const rules = sourceRules
.map((item) => {
const rule = asRecord(item)
const value =
typeof rule.value === 'string' ||
typeof rule.value === 'number' ||
typeof rule.value === 'boolean'
? rule.value
: ''
return {
field: toStringSafe(rule.field, '').trim(),
operator: toStringSafe(rule.operator, 'equals'),
value,
weight: toFloat(rule.weight, 1),
}
})
.filter((rule) => rule.field.length > 0)
return {
rules,
aiRubricEnabled: toBool(raw.aiRubricEnabled, Object.keys(aiLegacy).length > 0),
aiCriteriaText: toStringSafe(
raw.aiCriteriaText ?? aiLegacy.criteriaText,
''
),
aiConfidenceThresholds: {
high: toFloat(
asRecord(raw.aiConfidenceThresholds).high ?? highBand.threshold,
0.85
),
medium: toFloat(
asRecord(raw.aiConfidenceThresholds).medium ?? mediumBand.threshold,
0.6
),
low: toFloat(
asRecord(raw.aiConfidenceThresholds).low ?? lowBand.threshold,
0.4
),
},
manualQueueEnabled: toBool(raw.manualQueueEnabled, true),
}
}
function normalizeEvaluationConfig(raw: JsonObject): JsonObject {
return {
requiredReviews: toInt(raw.requiredReviews, 3),
maxLoadPerJuror: toInt(
raw.maxLoadPerJuror ?? raw.maxAssignmentsPerJuror,
20
),
minLoadPerJuror: toInt(
raw.minLoadPerJuror ?? raw.minAssignmentsPerJuror,
5
),
availabilityWeighting: toBool(raw.availabilityWeighting, true),
overflowPolicy: toStringSafe(raw.overflowPolicy, 'queue'),
}
}
function normalizeSelectionConfig(raw: JsonObject): JsonObject {
const selectionMethod = toStringSafe(raw.selectionMethod, '')
const inferredRankingMethod =
selectionMethod === 'binary_pass'
? 'binary_pass'
: selectionMethod === 'weighted_criteria'
? 'weighted_criteria'
: 'score_average'
return {
finalistCount:
typeof raw.finalistCount === 'number'
? Math.trunc(raw.finalistCount)
: typeof raw.finalistTarget === 'number'
? Math.trunc(raw.finalistTarget)
: undefined,
rankingMethod: toStringSafe(raw.rankingMethod, inferredRankingMethod),
tieBreaker: toStringSafe(raw.tieBreaker, 'admin_decides'),
}
}
function normalizeLiveFinalConfig(raw: JsonObject): JsonObject {
return {
juryVotingEnabled: toBool(raw.juryVotingEnabled ?? raw.votingEnabled, true),
audienceVotingEnabled: toBool(
raw.audienceVotingEnabled ?? raw.audienceVoting,
false
),
audienceVoteWeight: toFloat(raw.audienceVoteWeight, 0),
cohortSetupMode: toStringSafe(raw.cohortSetupMode, 'manual'),
revealPolicy: toStringSafe(raw.revealPolicy, 'ceremony'),
}
}
function normalizeResultsConfig(raw: JsonObject): JsonObject {
const publicationModeRaw = toStringSafe(
raw.publicationMode ?? raw.publicationPolicy,
'manual'
)
const publicationMode =
publicationModeRaw === 'auto_on_close' ? 'auto_on_close' : 'manual'
return {
publicationMode,
showDetailedScores: toBool(raw.showDetailedScores, false),
showRankings: toBool(raw.showRankings, true),
}
}
export function normalizeStageConfig(
stageType: StageType | StageTypeKey,
rawInput: unknown
): JsonObject {
const raw = asRecord(rawInput)
switch (stageType) {
case 'INTAKE':
return normalizeIntakeConfig(raw)
case 'FILTER':
return normalizeFilterConfig(raw)
case 'EVALUATION':
return normalizeEvaluationConfig(raw)
case 'SELECTION':
return normalizeSelectionConfig(raw)
case 'LIVE_FINAL':
return normalizeLiveFinalConfig(raw)
case 'RESULTS':
return normalizeResultsConfig(raw)
default:
return raw
}
}
function getUnknownRootKeys(
stageType: StageTypeKey,
rawInput: unknown
): string[] {
const raw = asRecord(rawInput)
const allowed = new Set([
...CANONICAL_KEYS[stageType],
...LEGACY_ALIAS_KEYS[stageType],
])
return Object.keys(raw).filter((key) => !allowed.has(key))
}
export type ParseStageConfigResult = {
config: JsonObject
normalized: JsonObject
}
export function parseAndValidateStageConfig(
stageType: StageType | StageTypeKey,
rawInput: unknown,
options?: { strictUnknownKeys?: boolean }
): ParseStageConfigResult {
const strictUnknownKeys = options?.strictUnknownKeys ?? true
const stageTypeKey = stageType as StageTypeKey
if (!STAGE_TYPES.includes(stageTypeKey)) {
throw new Error(`Unsupported stage type: ${String(stageType)}`)
}
if (strictUnknownKeys) {
const unknownKeys = getUnknownRootKeys(stageTypeKey, rawInput)
if (unknownKeys.length > 0) {
throw new Error(
`Unknown config keys for ${stageTypeKey}: ${unknownKeys.join(', ')}`
)
}
}
const normalized = normalizeStageConfig(stageTypeKey, rawInput)
const config = stageConfigSchemas[stageTypeKey].parse(normalized)
return { config, normalized }
}

View File

@@ -1,137 +1,126 @@
import {
type WizardConfig,
type WizardStep,
type WizardFieldConfig,
type WizardStepId,
DEFAULT_WIZARD_CONFIG,
wizardConfigSchema,
} from '@/types/wizard-config'
import type { WizardConfig, WizardStepId, CustomField, WizardFieldConfig } from '@/types/wizard-config'
/**
* Parse wizard config from Program.settingsJson with fallback to defaults.
* Used by both backend (application router) and frontend (apply pages).
* Check if a field is visible based on the wizard configuration
*/
export function parseWizardConfig(settingsJson: unknown): WizardConfig {
if (!settingsJson || typeof settingsJson !== 'object') {
return DEFAULT_WIZARD_CONFIG
}
const settings = settingsJson as Record<string, unknown>
if (!settings.wizardConfig) {
return DEFAULT_WIZARD_CONFIG
}
try {
const parsed = wizardConfigSchema.parse(settings.wizardConfig)
return mergeWizardConfig(parsed)
} catch {
console.error('[WizardConfig] Invalid config, using defaults')
return DEFAULT_WIZARD_CONFIG
}
export function isFieldVisible(config: WizardConfig, fieldName: string): boolean {
const fieldConfig = config.fields?.[fieldName]
if (!fieldConfig) return true // Default visible if not configured
return fieldConfig.visible !== false
}
/**
* Get enabled steps sorted by order.
* Check if a field is required based on the wizard configuration
*/
export function getActiveSteps(config: WizardConfig): WizardStep[] {
return config.steps.filter((step) => step.enabled).sort((a, b) => a.order - b.order)
export function isFieldRequired(config: WizardConfig, fieldName: string): boolean {
const fieldConfig = config.fields?.[fieldName]
if (!fieldConfig) return false // Default not required if not configured
return fieldConfig.required === true
}
/**
* Evaluate conditional step visibility based on current form values.
* Returns only steps whose conditions are met (or have no condition).
* Get field configuration for a specific field
*/
export function getVisibleSteps(
config: WizardConfig,
formValues: Record<string, unknown>
): WizardStep[] {
return getActiveSteps(config).filter((step) => {
if (!step.conditionalOn) return true
const { field, operator, value } = step.conditionalOn
const fieldValue = formValues[field]
switch (operator) {
case 'equals':
return fieldValue === value
case 'notEquals':
return fieldValue !== value
case 'in':
return Array.isArray(value) && value.includes(String(fieldValue))
case 'notIn':
return Array.isArray(value) && !value.includes(String(fieldValue))
default:
return true
export function getFieldConfig(config: WizardConfig, fieldName: string): WizardFieldConfig | undefined {
return config.fields?.[fieldName]
}
/**
* Get visible steps based on configuration and form values
*/
export function getVisibleSteps(config: WizardConfig, formValues: Record<string, unknown>) {
const steps = config.steps || []
return steps
.filter((step) => step.enabled !== false)
.filter((step) => {
// Check conditional visibility
if (!step.conditionalOn) return true
const { field, operator, value } = step.conditionalOn
const fieldValue = formValues[field]
switch (operator) {
case 'equals':
return fieldValue === value
case 'notEquals':
return fieldValue !== value
case 'in':
return Array.isArray(value) && value.includes(fieldValue as string)
case 'notIn':
return Array.isArray(value) && !value.includes(fieldValue as string)
default:
return true
}
})
.sort((a, b) => a.order - b.order)
}
/**
* Build steps array with field mappings for validation
*/
export function buildStepsArray(config: WizardConfig) {
const baseSteps = [
{ id: 'welcome', title: 'Category', fields: ['competitionCategory'] },
{ id: 'contact', title: 'Contact', fields: ['contactName', 'contactEmail', 'contactPhone', 'country', 'city'] },
{ id: 'project', title: 'Project', fields: ['projectName', 'teamName', 'description', 'oceanIssue'] },
{ id: 'team', title: 'Team', fields: ['teamMembers'] },
{ id: 'additional', title: 'Details', fields: ['institution', 'startupCreatedDate', 'wantsMentorship', 'referralSource'] },
{ id: 'review', title: 'Review', fields: ['gdprConsent'] },
]
// Apply config overrides
return baseSteps.map((step) => {
const configStep = config.steps?.find((s) => s.id === step.id)
return {
...step,
title: configStep?.title || step.title,
enabled: configStep?.enabled !== false,
}
})
}
/**
* Get field configuration with sensible defaults.
* Get custom fields for a specific step
*/
export function getFieldConfig(config: WizardConfig, fieldName: string): WizardFieldConfig {
return config.fields[fieldName] ?? { required: true, visible: true }
}
/**
* Check if a specific field should be visible based on config.
*/
export function isFieldVisible(config: WizardConfig, fieldName: string): boolean {
const fieldConfig = config.fields[fieldName]
return fieldConfig?.visible !== false
}
/**
* Check if a specific field is required based on config.
*/
export function isFieldRequired(config: WizardConfig, fieldName: string): boolean {
const fieldConfig = config.fields[fieldName]
return fieldConfig?.required !== false
}
/**
* Get custom fields assigned to a specific step, sorted by order.
*/
export function getCustomFieldsForStep(
config: WizardConfig,
stepId: WizardStepId
): NonNullable<WizardConfig['customFields']> {
return (config.customFields ?? [])
.filter((f) => f.stepId === stepId)
export function getCustomFieldsForStep(config: WizardConfig, stepId: WizardStepId): CustomField[] {
const customFields = config.customFields || []
return customFields
.filter((field) => field.stepId === stepId)
.sort((a, b) => a.order - b.order)
}
/**
* Merge partial config with defaults. Ensures all arrays/objects exist.
* Parse wizard config from JSON (settingsJson) and merge with defaults
*/
export function mergeWizardConfig(partial: Partial<WizardConfig>): WizardConfig {
export function parseWizardConfig(settingsJson: unknown): WizardConfig {
const DEFAULT_WIZARD_CONFIG: WizardConfig = {
steps: [
{ id: 'welcome', enabled: true, order: 0, title: 'Category' },
{ id: 'contact', enabled: true, order: 1, title: 'Contact' },
{ id: 'project', enabled: true, order: 2, title: 'Project' },
{ id: 'team', enabled: true, order: 3, title: 'Team' },
{ id: 'additional', enabled: true, order: 4, title: 'Details' },
{ id: 'review', enabled: true, order: 5, title: 'Review' },
],
fields: {},
competitionCategories: [],
oceanIssues: [],
features: {
enableWhatsApp: false,
enableMentorship: true,
enableTeamMembers: true,
requireInstitution: false,
},
customFields: [],
}
if (!settingsJson || typeof settingsJson !== 'object') {
return DEFAULT_WIZARD_CONFIG
}
return {
steps: partial.steps?.length ? partial.steps : DEFAULT_WIZARD_CONFIG.steps,
fields: partial.fields ?? DEFAULT_WIZARD_CONFIG.fields,
competitionCategories:
partial.competitionCategories ?? DEFAULT_WIZARD_CONFIG.competitionCategories,
oceanIssues: partial.oceanIssues ?? DEFAULT_WIZARD_CONFIG.oceanIssues,
features: { ...DEFAULT_WIZARD_CONFIG.features, ...partial.features },
welcomeMessage: partial.welcomeMessage,
customFields: partial.customFields ?? [],
...DEFAULT_WIZARD_CONFIG,
...(settingsJson as Partial<WizardConfig>),
}
}
/**
* Build the STEPS array for the wizard from config (format used by apply pages).
* Maps step IDs to their validation fields for per-step validation.
*/
export function buildStepsArray(
config: WizardConfig
): Array<{ id: string; title: string; fields: string[] }> {
const STEP_FIELDS_MAP: Record<string, string[]> = {
welcome: ['competitionCategory'],
contact: ['contactName', 'contactEmail', 'contactPhone', 'country'],
project: ['projectName', 'description', 'oceanIssue'],
team: [],
additional: [],
review: ['gdprConsent'],
}
return getActiveSteps(config).map((step) => ({
id: step.id,
title: step.title ?? step.id.charAt(0).toUpperCase() + step.id.slice(1),
fields: (STEP_FIELDS_MAP[step.id] ?? []).filter((f) => isFieldVisible(config, f)),
}))
}