Add dynamic apply wizard customization with admin settings UI

- Create wizard config types, utilities, and defaults (wizard-config.ts)
- Add admin apply settings page with drag-and-drop step ordering, dropdown
  option management, feature toggles, welcome message customization, and
  custom field builder with select/multiselect options editor
- Build dynamic apply wizard component with animated step transitions,
  mobile-first responsive design, and config-driven form validation
- Update step components to accept dynamic config (categories, ocean issues,
  field visibility, feature flags)
- Replace hardcoded enum validation with string-based validation for
  admin-configurable dropdown values, with safe enum casting at storage layer
- Add wizard template system (model, router, admin UI) with built-in
  MOPC Classic preset
- Add program wizard config CRUD procedures to program router
- Update application router getConfig to return wizardConfig, submit handler
  to store custom field data in metadataJson
- Add edition-based apply page, project pool page, and supporting routers
- Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea),
  safe area insets for notched phones, buildStepsArray field visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 13:18:20 +01:00
parent 98fe658c33
commit e7c86a7b1b
40 changed files with 4477 additions and 1045 deletions

View File

@@ -110,12 +110,6 @@ enum SettingCategory {
SECURITY
DEFAULTS
WHATSAPP
DIGEST
ANALYTICS
AUDIT_CONFIG
INTEGRATIONS
LOCALIZATION
COMMUNICATION
}
enum NotificationChannel {
@@ -216,13 +210,6 @@ model User {
notificationPreference NotificationChannel @default(EMAIL)
whatsappOptIn Boolean @default(false)
// Digest preferences (F1)
digestFrequency String @default("none") // none, daily, weekly
// Availability & workload (F2)
availabilityJson Json? @db.JsonB // Array of { start, end } date ranges
preferredWorkload Int? // Preferred number of assignments
// Onboarding (Phase 2B)
onboardingCompletedAt DateTime?
@@ -282,23 +269,8 @@ model User {
// Mentor messages
mentorMessages MentorMessage[] @relation("MentorMessageSender")
// Digest logs (F1)
digestLogs DigestLog[]
// Mentor notes & milestones (F8)
mentorNotesMade MentorNote[] @relation("MentorNoteAuthor")
milestoneCompletions MentorMilestoneCompletion[] @relation("MilestoneCompletedByUser")
// Messages (F9)
sentMessages Message[] @relation("MessageSender")
messageRecipients MessageRecipient[]
// Webhooks (F12)
createdWebhooks Webhook[] @relation("WebhookCreatedBy")
// Discussion comments (F13)
discussionComments DiscussionComment[]
closedDiscussions EvaluationDiscussion[] @relation("DiscussionClosedBy")
// Wizard templates
wizardTemplates WizardTemplate[] @relation("WizardTemplateCreatedBy")
// NextAuth relations
accounts Account[]
@@ -355,6 +327,7 @@ model VerificationToken {
model Program {
id String @id @default(cuid())
name String // e.g., "Monaco Ocean Protection Challenge"
slug String? @unique // URL-friendly identifier for edition-wide applications
year Int // e.g., 2026
status ProgramStatus @default(DRAFT)
description String?
@@ -364,17 +337,35 @@ model Program {
updatedAt DateTime @updatedAt
// Relations
projects Project[]
rounds Round[]
learningResources LearningResource[]
partners Partner[]
specialAwards SpecialAward[]
taggingJobs TaggingJob[]
mentorMilestones MentorMilestone[]
wizardTemplates WizardTemplate[]
@@unique([name, year])
@@index([status])
}
model WizardTemplate {
id String @id @default(cuid())
name String
description String?
config Json @db.JsonB
isGlobal Boolean @default(false)
programId String?
program Program? @relation(fields: [programId], references: [id], onDelete: Cascade)
createdBy String
creator User @relation("WizardTemplateCreatedBy", fields: [createdBy], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([programId])
@@index([isGlobal])
}
model Round {
id String @id @default(cuid())
programId String
@@ -461,7 +452,8 @@ model EvaluationForm {
model Project {
id String @id @default(cuid())
roundId String
programId String
roundId String?
status ProjectStatus @default(SUBMITTED)
// Core fields
@@ -507,11 +499,6 @@ model Project {
logoKey String? // Storage key (e.g., "logos/project456/1234567890.png")
logoProvider String? // Storage provider used: 's3' or 'local'
// Draft saving (F11)
isDraft Boolean @default(false)
draftDataJson Json? @db.JsonB
draftExpiresAt DateTime?
// Flexible fields
tags String[] @default([]) // "Ocean Conservation", "Tech", etc.
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
@@ -521,7 +508,8 @@ model Project {
updatedAt DateTime @updatedAt
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
files ProjectFile[]
assignments Assignment[]
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
@@ -535,9 +523,10 @@ model Project {
statusHistory ProjectStatusHistory[]
mentorMessages MentorMessage[]
evaluationSummaries EvaluationSummary[]
discussions EvaluationDiscussion[]
@@index([programId])
@@index([roundId])
@@index([programId, roundId])
@@index([status])
@@index([tags])
@@index([submissionSource])
@@ -564,10 +553,6 @@ model ProjectFile {
isLate Boolean @default(false) // Uploaded after round deadline
// File versioning (F7)
version Int @default(1)
replacedById String? // Points to newer version of this file
createdAt DateTime @default(now())
// Relations
@@ -717,10 +702,6 @@ model AuditLog {
// Details
detailsJson Json? @db.JsonB // Before/after values, additional context
// Audit enhancements (F14)
sessionId String? // Groups actions in same user session
previousDataJson Json? @db.JsonB // Snapshot of data before change
// Request info
ipAddress String?
userAgent String?
@@ -997,12 +978,6 @@ model LiveVotingSession {
votingEndsAt DateTime?
projectOrderJson Json? @db.JsonB // Array of project IDs in presentation order
// Live voting UX enhancements (F5/F6)
presentationSettingsJson Json? @db.JsonB // theme, auto-advance, branding
allowAudienceVotes Boolean @default(false)
audienceVoteWeight Float @default(0) // 0-1 weight relative to jury
tieBreakerMethod String @default("admin_decides") // admin_decides, highest_individual, revote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1014,13 +989,12 @@ model LiveVotingSession {
}
model LiveVote {
id String @id @default(cuid())
sessionId String
projectId String
userId String
score Int // 1-10
isAudienceVote Boolean @default(false) // F6: audience voting
votedAt DateTime @default(now())
id String @id @default(cuid())
sessionId String
projectId String
userId String
score Int // 1-10
votedAt DateTime @default(now())
// Relations
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@ -1030,7 +1004,6 @@ model LiveVote {
@@index([sessionId])
@@index([projectId])
@@index([userId])
@@index([isAudienceVote])
}
// =============================================================================
@@ -1074,15 +1047,9 @@ model MentorAssignment {
expertiseMatchScore Float?
aiReasoning String? @db.Text
// Mentor dashboard enhancements (F8)
lastViewedAt DateTime?
completionStatus String @default("in_progress") // in_progress, completed, paused
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
mentor User @relation("MentorAssignments", fields: [mentorId], references: [id])
notes MentorNote[]
milestoneCompletions MentorMilestoneCompletion[]
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
mentor User @relation("MentorAssignments", fields: [mentorId], references: [id])
@@index([mentorId])
@@index([method])
@@ -1487,242 +1454,3 @@ model MentorMessage {
@@index([projectId, createdAt])
}
// =============================================================================
// DIGEST LOGS (F1: Email Digest)
// =============================================================================
model DigestLog {
id String @id @default(cuid())
userId String
digestType String // "daily", "weekly"
contentJson Json @db.JsonB
sentAt DateTime @default(now())
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([sentAt])
}
// =============================================================================
// ROUND TEMPLATES (F3)
// =============================================================================
model RoundTemplate {
id String @id @default(cuid())
name String
description String? @db.Text
programId String? // null = global template
roundType RoundType @default(EVALUATION)
criteriaJson Json @db.JsonB
settingsJson Json? @db.JsonB
assignmentConfig Json? @db.JsonB
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([programId])
}
// =============================================================================
// MENTOR NOTES & MILESTONES (F8)
// =============================================================================
model MentorNote {
id String @id @default(cuid())
mentorAssignmentId String
authorId String
content String @db.Text
isVisibleToAdmin Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
author User @relation("MentorNoteAuthor", fields: [authorId], references: [id])
@@index([mentorAssignmentId])
}
model MentorMilestone {
id String @id @default(cuid())
programId String
name String
description String? @db.Text
isRequired Boolean @default(false)
deadlineOffsetDays Int?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
completions MentorMilestoneCompletion[]
@@index([programId])
@@index([sortOrder])
}
model MentorMilestoneCompletion {
id String @id @default(cuid())
milestoneId String
mentorAssignmentId String
completedAt DateTime @default(now())
completedById String
// Relations
milestone MentorMilestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade)
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
completedBy User @relation("MilestoneCompletedByUser", fields: [completedById], references: [id])
@@unique([milestoneId, mentorAssignmentId])
@@index([mentorAssignmentId])
}
// =============================================================================
// COMMUNICATION HUB (F9)
// =============================================================================
model Message {
id String @id @default(cuid())
senderId String
recipientType String // USER, ROLE, ROUND_JURY, PROGRAM_TEAM, ALL
recipientFilter Json? @db.JsonB
roundId String?
templateId String?
subject String
body String @db.Text
deliveryChannels String[]
scheduledAt DateTime?
sentAt DateTime?
metadata Json? @db.JsonB
createdAt DateTime @default(now())
// Relations
sender User @relation("MessageSender", fields: [senderId], references: [id])
template MessageTemplate? @relation(fields: [templateId], references: [id])
recipients MessageRecipient[]
@@index([senderId])
@@index([sentAt])
@@index([scheduledAt])
}
model MessageTemplate {
id String @id @default(cuid())
name String
category String
subject String
body String @db.Text
variables Json? @db.JsonB
isActive Boolean @default(true)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
messages Message[]
@@index([category])
@@index([isActive])
}
model MessageRecipient {
id String @id @default(cuid())
messageId String
userId String
channel String // EMAIL, IN_APP, WHATSAPP
isRead Boolean @default(false)
readAt DateTime?
deliveredAt DateTime?
// Relations
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([messageId])
@@index([userId, isRead])
}
// =============================================================================
// WEBHOOKS (F12)
// =============================================================================
model Webhook {
id String @id @default(cuid())
name String
url String
secret String // HMAC signing key
events String[]
headers Json? @db.JsonB
isActive Boolean @default(true)
maxRetries Int @default(3)
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
createdBy User @relation("WebhookCreatedBy", fields: [createdById], references: [id])
deliveries WebhookDelivery[]
@@index([isActive])
}
model WebhookDelivery {
id String @id @default(cuid())
webhookId String
event String
payload Json @db.JsonB
responseStatus Int?
responseBody String? @db.Text
attempts Int @default(0)
lastAttemptAt DateTime?
status String @default("PENDING") // PENDING, DELIVERED, FAILED
createdAt DateTime @default(now())
// Relations
webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
@@index([webhookId])
@@index([status])
@@index([createdAt])
}
// =============================================================================
// PEER REVIEW / EVALUATION DISCUSSIONS (F13)
// =============================================================================
model EvaluationDiscussion {
id String @id @default(cuid())
projectId String
roundId String
status String @default("open") // open, closed
createdAt DateTime @default(now())
closedAt DateTime?
closedById String?
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
closedBy User? @relation("DiscussionClosedBy", fields: [closedById], references: [id])
comments DiscussionComment[]
@@unique([projectId, roundId])
@@index([roundId])
@@index([status])
}
model DiscussionComment {
id String @id @default(cuid())
discussionId String
userId String
content String @db.Text
createdAt DateTime @default(now())
// Relations
discussion EvaluationDiscussion @relation(fields: [discussionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@index([discussionId, createdAt])
}