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

@@ -23,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { ExpertiseSelect } from '@/components/shared/expertise-select'
@@ -40,10 +41,21 @@ import {
Camera,
Globe,
FileText,
Scale,
} from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'preferences' | 'complete'
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'jury' | 'preferences' | 'complete'
type JuryPref = {
juryGroupMemberId: string
juryGroupName: string
currentCap: number
allowCapAdjustment: boolean
allowRatioAdjustment: boolean
selfServiceCap: number | null
selfServiceRatio: number | null
}
export default function OnboardingPage() {
const router = useRouter()
@@ -62,6 +74,7 @@ export default function OnboardingPage() {
const [notificationPreference, setNotificationPreference] = useState<
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
>('EMAIL')
const [juryPrefs, setJuryPrefs] = useState<Map<string, { cap?: number; ratio?: number }>>(new Map())
// Fetch current user data only after session is hydrated
const { data: userData, isLoading: userLoading, refetch: refetchUser } = trpc.user.me.useQuery(
@@ -105,6 +118,14 @@ export default function OnboardingPage() {
}
}, [userData, initialized])
// Fetch jury onboarding context
const { data: onboardingCtx } = trpc.user.getOnboardingContext.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const juryMemberships: JuryPref[] = onboardingCtx?.memberships ?? []
const hasJuryStep = onboardingCtx?.hasSelfServiceOptions ?? false
// Fetch feature flags only after session is hydrated
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery(
undefined,
@@ -117,14 +138,15 @@ export default function OnboardingPage() {
onSuccess: () => utils.user.me.invalidate(),
})
// Dynamic steps based on WhatsApp availability
// Dynamic steps based on WhatsApp availability and jury self-service
const steps: Step[] = useMemo(() => {
if (whatsappEnabled) {
return ['name', 'photo', 'country', 'bio', 'phone', 'tags', 'preferences', 'complete']
}
// Skip phone step if WhatsApp is disabled
return ['name', 'photo', 'country', 'bio', 'tags', 'preferences', 'complete']
}, [whatsappEnabled])
const base: Step[] = ['name', 'photo', 'country', 'bio']
if (whatsappEnabled) base.push('phone')
base.push('tags')
if (hasJuryStep) base.push('jury')
base.push('preferences', 'complete')
return base
}, [whatsappEnabled, hasJuryStep])
const currentIndex = steps.indexOf(step)
const totalVisibleSteps = steps.length - 1 // Exclude 'complete' from count
@@ -149,6 +171,23 @@ export default function OnboardingPage() {
const handleComplete = async () => {
try {
// Build jury preferences from state
const juryPreferences = juryMemberships
.map((m) => {
const pref = juryPrefs.get(m.juryGroupMemberId)
if (!pref) return null
return {
juryGroupMemberId: m.juryGroupMemberId,
selfServiceCap: pref.cap,
selfServiceRatio: pref.ratio,
}
})
.filter(Boolean) as Array<{
juryGroupMemberId: string
selfServiceCap?: number
selfServiceRatio?: number
}>
await completeOnboarding.mutateAsync({
name,
country: country || undefined,
@@ -156,6 +195,7 @@ export default function OnboardingPage() {
phoneNumber: phoneNumber || undefined,
expertiseTags,
notificationPreference,
juryPreferences: juryPreferences.length > 0 ? juryPreferences : undefined,
})
setStep('complete')
toast.success('Welcome to MOPC!')
@@ -227,6 +267,7 @@ export default function OnboardingPage() {
bio: 'About',
phone: 'Phone',
tags: 'Expertise',
jury: 'Jury',
preferences: 'Settings',
}
return (
@@ -473,7 +514,95 @@ export default function OnboardingPage() {
</>
)}
{/* Step 7: Preferences */}
{/* Jury Preferences Step (conditional) */}
{step === 'jury' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Scale className="h-5 w-5 text-primary" />
Jury Preferences
</CardTitle>
<CardDescription>
Customize your assignment preferences for each jury panel you belong to.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{juryMemberships.map((m) => {
const pref = juryPrefs.get(m.juryGroupMemberId) ?? {}
const capValue = pref.cap ?? m.selfServiceCap ?? m.currentCap
const ratioValue = pref.ratio ?? m.selfServiceRatio ?? 0.5
return (
<div key={m.juryGroupMemberId} className="rounded-lg border p-4 space-y-4">
<h4 className="font-medium text-sm">{m.juryGroupName}</h4>
{m.allowCapAdjustment && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Maximum assignments: {capValue}
</Label>
<Slider
value={[capValue]}
onValueChange={([v]) =>
setJuryPrefs((prev) => {
const next = new Map(prev)
next.set(m.juryGroupMemberId, { ...pref, cap: v })
return next
})
}
min={1}
max={m.currentCap}
step={1}
/>
<p className="text-xs text-muted-foreground">
Admin default: {m.currentCap}. You may reduce this to match your availability.
</p>
</div>
)}
{m.allowRatioAdjustment && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Startup vs Business Concept ratio: {Math.round(ratioValue * 100)}% / {Math.round((1 - ratioValue) * 100)}%
</Label>
<Slider
value={[ratioValue * 100]}
onValueChange={([v]) =>
setJuryPrefs((prev) => {
const next = new Map(prev)
next.set(m.juryGroupMemberId, { ...pref, ratio: v / 100 })
return next
})
}
min={0}
max={100}
step={5}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>More Business Concepts</span>
<span>More Startups</span>
</div>
</div>
)}
</div>
)
})}
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Notification Preferences */}
{step === 'preferences' && (
<>
<CardHeader>