Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
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:
49
src/lib/feature-flags.ts
Normal file
49
src/lib/feature-flags.ts
Normal 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}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'] },
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)),
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user