diff --git a/package-lock.json b/package-lock.json
index e41491c..67eecb2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -61,6 +61,7 @@
"openai": "^6.16.0",
"papaparse": "^5.4.1",
"react": "^19.0.0",
+ "react-day-picker": "^9.13.0",
"react-dom": "^19.0.0",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.54.2",
@@ -268,6 +269,12 @@
"integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==",
"license": "MIT"
},
+ "node_modules/@date-fns/tz": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
+ "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
+ "license": "MIT"
+ },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -6343,6 +6350,12 @@
"url": "https://github.com/sponsors/kossnocorp"
}
},
+ "node_modules/date-fns-jalali": {
+ "version": "4.1.0-0",
+ "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
+ "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
+ "license": "MIT"
+ },
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
@@ -11638,6 +11651,27 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-day-picker": {
+ "version": "9.13.0",
+ "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.0.tgz",
+ "integrity": "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@date-fns/tz": "^1.4.1",
+ "date-fns": "^4.1.0",
+ "date-fns-jalali": "^4.1.0-0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/gpbl"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
diff --git a/package.json b/package.json
index 0f40791..d7c7674 100644
--- a/package.json
+++ b/package.json
@@ -74,6 +74,7 @@
"openai": "^6.16.0",
"papaparse": "^5.4.1",
"react": "^19.0.0",
+ "react-day-picker": "^9.13.0",
"react-dom": "^19.0.0",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.54.2",
diff --git a/prisma/migrations/20260203200000_add_onboarding_system/migration.sql b/prisma/migrations/20260203200000_add_onboarding_system/migration.sql
new file mode 100644
index 0000000..3c1025c
--- /dev/null
+++ b/prisma/migrations/20260203200000_add_onboarding_system/migration.sql
@@ -0,0 +1,147 @@
+-- Add Onboarding System Schema Changes
+-- This migration adds the onboarding configuration system for the public application wizard
+
+-- CreateEnum: SpecialFieldType
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'SpecialFieldType') THEN
+ CREATE TYPE "SpecialFieldType" AS ENUM (
+ 'TEAM_MEMBERS',
+ 'COMPETITION_CATEGORY',
+ 'OCEAN_ISSUE',
+ 'FILE_UPLOAD',
+ 'GDPR_CONSENT',
+ 'COUNTRY_SELECT'
+ );
+ END IF;
+END $$;
+
+-- CreateTable: OnboardingStep
+CREATE TABLE IF NOT EXISTS "OnboardingStep" (
+ "id" TEXT NOT NULL,
+ "formId" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "description" TEXT,
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
+ "isOptional" BOOLEAN NOT NULL DEFAULT false,
+ "conditionJson" JSONB,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "OnboardingStep_pkey" PRIMARY KEY ("id")
+);
+
+-- Add columns to ApplicationForm
+DO $$
+BEGIN
+ -- roundId column (unique)
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'ApplicationForm' AND column_name = 'roundId'
+ ) THEN
+ ALTER TABLE "ApplicationForm" ADD COLUMN "roundId" TEXT;
+ END IF;
+
+ -- sendConfirmationEmail column
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'ApplicationForm' AND column_name = 'sendConfirmationEmail'
+ ) THEN
+ ALTER TABLE "ApplicationForm" ADD COLUMN "sendConfirmationEmail" BOOLEAN NOT NULL DEFAULT true;
+ END IF;
+
+ -- sendTeamInviteEmails column
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'ApplicationForm' AND column_name = 'sendTeamInviteEmails'
+ ) THEN
+ ALTER TABLE "ApplicationForm" ADD COLUMN "sendTeamInviteEmails" BOOLEAN NOT NULL DEFAULT true;
+ END IF;
+
+ -- confirmationEmailSubject column
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'ApplicationForm' AND column_name = 'confirmationEmailSubject'
+ ) THEN
+ ALTER TABLE "ApplicationForm" ADD COLUMN "confirmationEmailSubject" TEXT;
+ END IF;
+
+ -- confirmationEmailBody column
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'ApplicationForm' AND column_name = 'confirmationEmailBody'
+ ) THEN
+ ALTER TABLE "ApplicationForm" ADD COLUMN "confirmationEmailBody" TEXT;
+ END IF;
+END $$;
+
+-- Add columns to ApplicationFormField
+DO $$
+BEGIN
+ -- stepId column
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'ApplicationFormField' AND column_name = 'stepId'
+ ) THEN
+ ALTER TABLE "ApplicationFormField" ADD COLUMN "stepId" TEXT;
+ END IF;
+
+ -- projectMapping column
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'ApplicationFormField' AND column_name = 'projectMapping'
+ ) THEN
+ ALTER TABLE "ApplicationFormField" ADD COLUMN "projectMapping" TEXT;
+ END IF;
+
+ -- specialType column
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'ApplicationFormField' AND column_name = 'specialType'
+ ) THEN
+ ALTER TABLE "ApplicationFormField" ADD COLUMN "specialType" "SpecialFieldType";
+ END IF;
+END $$;
+
+-- Create indexes for OnboardingStep
+CREATE INDEX IF NOT EXISTS "OnboardingStep_formId_idx" ON "OnboardingStep"("formId");
+CREATE INDEX IF NOT EXISTS "OnboardingStep_sortOrder_idx" ON "OnboardingStep"("sortOrder");
+
+-- Create index for ApplicationForm.roundId
+CREATE UNIQUE INDEX IF NOT EXISTS "ApplicationForm_roundId_key" ON "ApplicationForm"("roundId");
+CREATE INDEX IF NOT EXISTS "ApplicationForm_roundId_idx" ON "ApplicationForm"("roundId");
+
+-- Create index for ApplicationFormField.stepId
+CREATE INDEX IF NOT EXISTS "ApplicationFormField_stepId_idx" ON "ApplicationFormField"("stepId");
+
+-- Add foreign key constraints
+DO $$
+BEGIN
+ -- OnboardingStep -> ApplicationForm
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'OnboardingStep_formId_fkey'
+ ) THEN
+ ALTER TABLE "OnboardingStep" ADD CONSTRAINT "OnboardingStep_formId_fkey"
+ FOREIGN KEY ("formId") REFERENCES "ApplicationForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+
+ -- ApplicationFormField -> OnboardingStep
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'ApplicationFormField_stepId_fkey'
+ ) THEN
+ ALTER TABLE "ApplicationFormField" ADD CONSTRAINT "ApplicationFormField_stepId_fkey"
+ FOREIGN KEY ("stepId") REFERENCES "OnboardingStep"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ END IF;
+
+ -- ApplicationForm -> Round
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'ApplicationForm_roundId_fkey'
+ ) THEN
+ ALTER TABLE "ApplicationForm" ADD CONSTRAINT "ApplicationForm_roundId_fkey"
+ FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ END IF;
+END $$;
diff --git a/prisma/migrations/20260203210000_add_filtering_job/migration.sql b/prisma/migrations/20260203210000_add_filtering_job/migration.sql
new file mode 100644
index 0000000..0725eba
--- /dev/null
+++ b/prisma/migrations/20260203210000_add_filtering_job/migration.sql
@@ -0,0 +1,38 @@
+-- Create FilteringJobStatus enum if not exists
+DO $$ BEGIN
+ CREATE TYPE "FilteringJobStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- Create FilteringJob table if not exists
+CREATE TABLE IF NOT EXISTS "FilteringJob" (
+ "id" TEXT NOT NULL,
+ "roundId" TEXT NOT NULL,
+ "status" "FilteringJobStatus" NOT NULL DEFAULT 'PENDING',
+ "totalProjects" INTEGER NOT NULL DEFAULT 0,
+ "totalBatches" INTEGER NOT NULL DEFAULT 0,
+ "currentBatch" INTEGER NOT NULL DEFAULT 0,
+ "processedCount" INTEGER NOT NULL DEFAULT 0,
+ "passedCount" INTEGER NOT NULL DEFAULT 0,
+ "filteredCount" INTEGER NOT NULL DEFAULT 0,
+ "flaggedCount" INTEGER NOT NULL DEFAULT 0,
+ "errorMessage" TEXT,
+ "startedAt" TIMESTAMP(3),
+ "completedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "FilteringJob_pkey" PRIMARY KEY ("id")
+);
+
+-- Add indexes
+CREATE INDEX IF NOT EXISTS "FilteringJob_roundId_idx" ON "FilteringJob"("roundId");
+CREATE INDEX IF NOT EXISTS "FilteringJob_status_idx" ON "FilteringJob"("status");
+
+-- Add foreign key if not exists
+DO $$ BEGIN
+ ALTER TABLE "FilteringJob" ADD CONSTRAINT "FilteringJob_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 30a20be..2636a63 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -167,6 +167,15 @@ enum FormFieldType {
INSTRUCTIONS
}
+enum SpecialFieldType {
+ TEAM_MEMBERS // Team member repeater
+ COMPETITION_CATEGORY // Business Concept vs Startup
+ OCEAN_ISSUE // Ocean issue dropdown
+ FILE_UPLOAD // File upload
+ GDPR_CONSENT // GDPR consent checkbox
+ COUNTRY_SELECT // Country dropdown
+}
+
// =============================================================================
// APPLICANT SYSTEM ENUMS
// =============================================================================
@@ -376,14 +385,16 @@ model Round {
updatedAt DateTime @updatedAt
// Relations
- program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
- roundProjects RoundProject[]
- assignments Assignment[]
- evaluationForms EvaluationForm[]
- gracePeriods GracePeriod[]
+ program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
+ roundProjects RoundProject[]
+ assignments Assignment[]
+ evaluationForms EvaluationForm[]
+ gracePeriods GracePeriod[]
liveVotingSession LiveVotingSession?
- filteringRules FilteringRule[]
- filteringResults FilteringResult[]
+ filteringRules FilteringRule[]
+ filteringResults FilteringResult[]
+ filteringJobs FilteringJob[]
+ applicationForm ApplicationForm?
@@index([programId])
@@index([status])
@@ -858,22 +869,35 @@ model ApplicationForm {
confirmationMessage String? @db.Text
+ // Round linking (for onboarding forms that create projects)
+ roundId String? @unique
+
+ // Email settings
+ sendConfirmationEmail Boolean @default(true)
+ sendTeamInviteEmails Boolean @default(true)
+ confirmationEmailSubject String?
+ confirmationEmailBody String? @db.Text
+
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
program Program? @relation(fields: [programId], references: [id], onDelete: SetNull)
+ round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
fields ApplicationFormField[]
+ steps OnboardingStep[]
submissions ApplicationFormSubmission[]
@@index([programId])
@@index([status])
@@index([isPublic])
+ @@index([roundId])
}
model ApplicationFormField {
id String @id @default(cuid())
formId String
+ stepId String? // Which step this field belongs to (for onboarding)
fieldType FormFieldType
name String // Internal name (e.g., "project_title")
label String // Display label (e.g., "Project Title")
@@ -889,6 +913,10 @@ model ApplicationFormField {
optionsJson Json? @db.JsonB // For select/radio: [{ value, label }]
conditionJson Json? @db.JsonB // Conditional logic: { fieldId, operator, value }
+ // Onboarding-specific fields
+ projectMapping String? // Maps to Project column: "title", "description", etc.
+ specialType SpecialFieldType? // Special handling for complex fields
+
sortOrder Int @default(0)
width String @default("full") // full, half
@@ -896,7 +924,30 @@ model ApplicationFormField {
updatedAt DateTime @updatedAt
// Relations
- form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
+ form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
+ step OnboardingStep? @relation(fields: [stepId], references: [id], onDelete: SetNull)
+
+ @@index([formId])
+ @@index([stepId])
+ @@index([sortOrder])
+}
+
+model OnboardingStep {
+ id String @id @default(cuid())
+ formId String
+ name String // Internal identifier (e.g., "category", "contact")
+ title String // Display title (e.g., "Category", "Contact Information")
+ description String? @db.Text
+ sortOrder Int @default(0)
+ isOptional Boolean @default(false)
+ conditionJson Json? @db.JsonB // Conditional visibility: { fieldId, operator, value }
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Relations
+ form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
+ fields ApplicationFormField[]
@@index([formId])
@@index([sortOrder])
@@ -1117,6 +1168,39 @@ model FilteringResult {
@@index([outcome])
}
+// Tracks progress of long-running filtering jobs
+model FilteringJob {
+ id String @id @default(cuid())
+ roundId String
+ status FilteringJobStatus @default(PENDING)
+ totalProjects Int @default(0)
+ totalBatches Int @default(0)
+ currentBatch Int @default(0)
+ processedCount Int @default(0)
+ passedCount Int @default(0)
+ filteredCount Int @default(0)
+ flaggedCount Int @default(0)
+ errorMessage String? @db.Text
+ startedAt DateTime?
+ completedAt DateTime?
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Relations
+ round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
+
+ @@index([roundId])
+ @@index([status])
+}
+
+enum FilteringJobStatus {
+ PENDING
+ RUNNING
+ COMPLETED
+ FAILED
+}
+
// =============================================================================
// SPECIAL AWARDS SYSTEM
// =============================================================================
diff --git a/src/app/(admin)/admin/onboarding/[id]/page.tsx b/src/app/(admin)/admin/onboarding/[id]/page.tsx
new file mode 100644
index 0000000..d3e517d
--- /dev/null
+++ b/src/app/(admin)/admin/onboarding/[id]/page.tsx
@@ -0,0 +1,686 @@
+'use client'
+
+import { Suspense, use, useState } from 'react'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { trpc } from '@/lib/trpc/client'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from '@/components/ui/tabs'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ ArrowLeft,
+ Loader2,
+ Save,
+ Plus,
+ GripVertical,
+ Trash2,
+ Settings,
+ Eye,
+ Mail,
+ Link2,
+} from 'lucide-react'
+import { toast } from 'sonner'
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from '@dnd-kit/core'
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
+import { cn } from '@/lib/utils'
+
+interface PageProps {
+ params: Promise<{ id: string }>
+}
+
+// Sortable step item component
+function SortableStep({
+ step,
+ isSelected,
+ onSelect,
+ onDelete,
+}: {
+ step: { id: string; name: string; title: string; fields: unknown[] }
+ isSelected: boolean
+ onSelect: () => void
+ onDelete: () => void
+}) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: step.id })
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ }
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
+
{step.title}
+
+ {(step.fields as unknown[]).length} fields
+
+
+
{
+ e.stopPropagation()
+ onDelete()
+ }}
+ >
+
+
+
+ )
+}
+
+function OnboardingFormEditor({ formId }: { formId: string }) {
+ const router = useRouter()
+ const utils = trpc.useUtils()
+
+ // Fetch form data with steps
+ const { data: form, isLoading } = trpc.applicationForm.getForBuilder.useQuery(
+ { id: formId },
+ { refetchOnWindowFocus: false }
+ )
+
+ // Local state for editing
+ const [selectedStepId, setSelectedStepId] = useState(null)
+ const [isSaving, setIsSaving] = useState(false)
+
+ // Mutations
+ const updateForm = trpc.applicationForm.update.useMutation({
+ onSuccess: () => {
+ utils.applicationForm.getForBuilder.invalidate({ id: formId })
+ toast.success('Form updated')
+ },
+ onError: (error) => {
+ toast.error(error.message)
+ },
+ })
+
+ const createStep = trpc.applicationForm.createStep.useMutation({
+ onSuccess: (data) => {
+ utils.applicationForm.getForBuilder.invalidate({ id: formId })
+ setSelectedStepId(data.id)
+ toast.success('Step created')
+ },
+ })
+
+ const updateStep = trpc.applicationForm.updateStep.useMutation({
+ onSuccess: () => {
+ utils.applicationForm.getForBuilder.invalidate({ id: formId })
+ toast.success('Step updated')
+ },
+ })
+
+ const deleteStep = trpc.applicationForm.deleteStep.useMutation({
+ onSuccess: () => {
+ utils.applicationForm.getForBuilder.invalidate({ id: formId })
+ setSelectedStepId(null)
+ toast.success('Step deleted')
+ },
+ })
+
+ const reorderSteps = trpc.applicationForm.reorderSteps.useMutation({
+ onSuccess: () => {
+ utils.applicationForm.getForBuilder.invalidate({ id: formId })
+ },
+ })
+
+ const updateEmailSettings = trpc.applicationForm.updateEmailSettings.useMutation({
+ onSuccess: () => {
+ utils.applicationForm.getForBuilder.invalidate({ id: formId })
+ toast.success('Email settings updated')
+ },
+ })
+
+ const linkToRound = trpc.applicationForm.linkToRound.useMutation({
+ onSuccess: () => {
+ utils.applicationForm.getForBuilder.invalidate({ id: formId })
+ toast.success('Round linked')
+ },
+ })
+
+ // Fetch available rounds
+ const { data: availableRounds } = trpc.applicationForm.getAvailableRounds.useQuery({
+ programId: form?.programId || undefined,
+ })
+
+ // DnD sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ )
+
+ const handleStepDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event
+ if (!over || active.id === over.id || !form) return
+
+ const oldIndex = form.steps.findIndex((s) => s.id === active.id)
+ const newIndex = form.steps.findIndex((s) => s.id === over.id)
+
+ const newOrder = arrayMove(form.steps, oldIndex, newIndex)
+ reorderSteps.mutate({
+ formId,
+ stepIds: newOrder.map((s) => s.id),
+ })
+ }
+
+ const handleAddStep = () => {
+ const stepNumber = (form?.steps.length || 0) + 1
+ createStep.mutate({
+ formId,
+ step: {
+ name: `step_${stepNumber}`,
+ title: `Step ${stepNumber}`,
+ },
+ })
+ }
+
+ const selectedStep = form?.steps.find((s) => s.id === selectedStepId)
+
+ if (isLoading) {
+ return
+ }
+
+ if (!form) {
+ return (
+
+
Form not found
+
+
+ Back to Onboarding
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
{form.name}
+
+
+ {form.status}
+
+ {form.round && (
+
+
+ {form.round.name}
+
+ )}
+
+
+
+
+
+ {/* Tabs */}
+
+
+ Steps & Fields
+ Settings
+ Emails
+
+
+ {/* Steps Tab */}
+
+
+ {/* Steps List */}
+
+
+ Steps
+
+ Drag to reorder wizard steps
+
+
+
+
+ s.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {form.steps.map((step) => (
+ setSelectedStepId(step.id)}
+ onDelete={() => {
+ if (confirm('Delete this step? Fields will be unassigned.')) {
+ deleteStep.mutate({ id: step.id })
+ }
+ }}
+ />
+ ))}
+
+
+
+
+
+ {createStep.isPending ? (
+
+ ) : (
+
+ )}
+ Add Step
+
+
+
+
+ {/* Step Editor */}
+
+
+
+ {selectedStep ? `Edit: ${selectedStep.title}` : 'Select a Step'}
+
+
+
+ {selectedStep ? (
+
+
+
+
+ Description (optional)
+
+
+
+
Fields in this step
+ {selectedStep.fields.length === 0 ? (
+
+ No fields yet. Use the existing form editor to add fields.
+
+ ) : (
+
+ {selectedStep.fields.map((field) => (
+
+
+
+
{field.label}
+
+ {field.fieldType} {field.required && '(required)'}
+
+
+
+ ))}
+
+ )}
+
+
+ Edit Fields in Form Editor
+
+
+
+
+ ) : (
+
+ Select a step from the list to edit it
+
+ )}
+
+
+
+
+
+ {/* Settings Tab */}
+
+
+
+ General Settings
+
+
+
+ Form Name
+ {
+ updateForm.mutate({ id: formId, name: e.target.value })
+ }}
+ />
+
+
+
+ Description
+
+
+
+
Public URL Slug
+
+ /apply/
+ {
+ updateForm.mutate({ id: formId, publicSlug: e.target.value || null })
+ }}
+ placeholder="your-form-slug"
+ />
+
+
+
+
+
+
Public Access
+
+ Allow public submissions to this form
+
+
+
{
+ updateForm.mutate({ id: formId, isPublic: checked })
+ }}
+ />
+
+
+
+ Status
+ {
+ updateForm.mutate({ id: formId, status: value as 'DRAFT' | 'PUBLISHED' | 'CLOSED' })
+ }}
+ >
+
+
+
+
+ Draft
+ Published
+ Closed
+
+
+
+
+
+
+
+
+ Round Linking
+
+ Link this form to a round to create projects on submission
+
+
+
+
+ Linked Round
+ {
+ linkToRound.mutate({
+ formId,
+ roundId: value === 'none' ? null : value,
+ })
+ }}
+ >
+
+
+
+
+ No round linked
+ {form.round && (
+
+ {form.round.name} (current)
+
+ )}
+ {availableRounds?.map((round) => (
+
+ {round.program?.name} {round.program?.year} - {round.name}
+
+ ))}
+
+
+
+
+
+
+
+ {/* Emails Tab */}
+
+
+
+ Email Notifications
+
+ Configure emails sent when applications are submitted
+
+
+
+
+
+
Confirmation Email
+
+ Send a confirmation email to the applicant
+
+
+
{
+ updateEmailSettings.mutate({
+ formId,
+ sendConfirmationEmail: checked,
+ })
+ }}
+ />
+
+
+
+
+
Team Invite Emails
+
+ Send invite emails to team members
+
+
+
{
+ updateEmailSettings.mutate({
+ formId,
+ sendTeamInviteEmails: checked,
+ })
+ }}
+ />
+
+
+ {form.sendConfirmationEmail && (
+ <>
+
+ Custom Email Subject (optional)
+ {
+ updateEmailSettings.mutate({
+ formId,
+ confirmationEmailSubject: e.target.value || null,
+ })
+ }}
+ placeholder="Application Received - {projectName}"
+ />
+
+
+
+ Custom Email Message (optional)
+
+ >
+ )}
+
+
+
+
+
+ )
+}
+
+function FormEditorSkeleton() {
+ return (
+
+ )
+}
+
+export default function OnboardingFormPage({ params }: PageProps) {
+ const { id } = use(params)
+
+ return (
+ }>
+
+
+ )
+}
diff --git a/src/app/(admin)/admin/onboarding/new/page.tsx b/src/app/(admin)/admin/onboarding/new/page.tsx
new file mode 100644
index 0000000..9bf5602
--- /dev/null
+++ b/src/app/(admin)/admin/onboarding/new/page.tsx
@@ -0,0 +1,171 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import Link from 'next/link'
+import { trpc } from '@/lib/trpc/client'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { ArrowLeft, Loader2 } from 'lucide-react'
+import { toast } from 'sonner'
+
+export default function NewOnboardingFormPage() {
+ const router = useRouter()
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [selectedProgramId, setSelectedProgramId] = useState('')
+
+ // Fetch programs for selection
+ const { data: programs } = trpc.program.list.useQuery({})
+
+ // Fetch available rounds for the selected program
+ const { data: availableRounds } = trpc.applicationForm.getAvailableRounds.useQuery(
+ { programId: selectedProgramId || undefined },
+ { enabled: true }
+ )
+
+ const createForm = trpc.applicationForm.create.useMutation({
+ onSuccess: (data) => {
+ toast.success('Onboarding form created successfully')
+ router.push(`/admin/onboarding/${data.id}`)
+ },
+ onError: (error) => {
+ toast.error(error.message || 'Failed to create form')
+ setIsSubmitting(false)
+ },
+ })
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setIsSubmitting(true)
+
+ const formData = new FormData(e.currentTarget)
+ const name = formData.get('name') as string
+ const description = formData.get('description') as string
+ const publicSlug = formData.get('publicSlug') as string
+
+ createForm.mutate({
+ programId: selectedProgramId || null,
+ name,
+ description: description || undefined,
+ publicSlug: publicSlug || undefined,
+ })
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
Create Onboarding Form
+
+ Set up a new application wizard for project submissions
+
+
+
+
+
+
+ Form Details
+
+ Configure the basic settings for your onboarding form
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(admin)/admin/onboarding/page.tsx b/src/app/(admin)/admin/onboarding/page.tsx
new file mode 100644
index 0000000..865f3c4
--- /dev/null
+++ b/src/app/(admin)/admin/onboarding/page.tsx
@@ -0,0 +1,153 @@
+import { Suspense } from 'react'
+import Link from 'next/link'
+import { api } from '@/lib/trpc/server'
+import { Button } from '@/components/ui/button'
+import {
+ Card,
+ CardContent,
+} from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Plus,
+ Pencil,
+ FileText,
+ ExternalLink,
+ Inbox,
+ Link2,
+} from 'lucide-react'
+
+const statusColors = {
+ DRAFT: 'bg-gray-100 text-gray-800',
+ PUBLISHED: 'bg-green-100 text-green-800',
+ CLOSED: 'bg-red-100 text-red-800',
+}
+
+async function OnboardingFormsList() {
+ const caller = await api()
+ const { data: forms } = await caller.applicationForm.list({
+ perPage: 50,
+ })
+
+ if (forms.length === 0) {
+ return (
+
+
+
+ No onboarding forms yet
+
+ Create your first application wizard to accept project submissions
+
+
+
+
+ Create Onboarding Form
+
+
+
+
+ )
+ }
+
+ return (
+
+ {forms.map((form) => (
+
+
+
+
+
+
+
+
{form.name}
+
+ {form.status}
+
+ {form.program && (
+ {form.program.name} {form.program.year}
+ )}
+
+
+ {form._count.fields} fields
+ -
+ {form._count.submissions} submissions
+ {form.publicSlug && (
+ <>
+ -
+ /apply/{form.publicSlug}
+ >
+ )}
+
+
+
+ {form.publicSlug && form.status === 'PUBLISHED' && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )
+}
+
+function LoadingSkeleton() {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )
+}
+
+export default function OnboardingPage() {
+ return (
+
+
+
+
Onboarding
+
+ Configure application wizards for project submissions
+
+
+
+
+
+ Create Form
+
+
+
+
+
}>
+
+
+
+ )
+}
diff --git a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx
index 2cd7484..0177fc7 100644
--- a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx
+++ b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx
@@ -33,7 +33,7 @@ import {
} from '@/components/forms/evaluation-form-builder'
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle } from 'lucide-react'
-import { format } from 'date-fns'
+import { DateTimePicker } from '@/components/ui/datetime-picker'
interface PageProps {
params: Promise<{ id: string }>
@@ -43,13 +43,13 @@ const updateRoundSchema = z
.object({
name: z.string().min(1, 'Name is required').max(255),
requiredReviews: z.number().int().min(1).max(10),
- votingStartAt: z.string().optional(),
- votingEndAt: z.string().optional(),
+ votingStartAt: z.date().nullable().optional(),
+ votingEndAt: z.date().nullable().optional(),
})
.refine(
(data) => {
if (data.votingStartAt && data.votingEndAt) {
- return new Date(data.votingEndAt) > new Date(data.votingStartAt)
+ return data.votingEndAt > data.votingStartAt
}
return true
},
@@ -61,25 +61,19 @@ const updateRoundSchema = z
type UpdateRoundForm = z.infer
-// Convert ISO date to datetime-local format
-function toDatetimeLocal(date: Date | string | null | undefined): string {
- if (!date) return ''
- const d = new Date(date)
- // Format: YYYY-MM-DDTHH:mm
- return format(d, "yyyy-MM-dd'T'HH:mm")
-}
-
function EditRoundContent({ roundId }: { roundId: string }) {
const router = useRouter()
const [criteria, setCriteria] = useState([])
const [criteriaInitialized, setCriteriaInitialized] = useState(false)
+ const [formInitialized, setFormInitialized] = useState(false)
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
const [roundSettings, setRoundSettings] = useState>({})
- // Fetch round data
- const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({
- id: roundId,
- })
+ // Fetch round data - disable refetch on focus to prevent overwriting user's edits
+ const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery(
+ { id: roundId },
+ { refetchOnWindowFocus: false }
+ )
// Fetch evaluation form
const { data: evaluationForm, isLoading: loadingForm } =
@@ -110,25 +104,26 @@ function EditRoundContent({ roundId }: { roundId: string }) {
defaultValues: {
name: '',
requiredReviews: 3,
- votingStartAt: '',
- votingEndAt: '',
+ votingStartAt: null,
+ votingEndAt: null,
},
})
- // Update form when round data loads
+ // Update form when round data loads - only initialize once
useEffect(() => {
- if (round) {
+ if (round && !formInitialized) {
form.reset({
name: round.name,
requiredReviews: round.requiredReviews,
- votingStartAt: toDatetimeLocal(round.votingStartAt),
- votingEndAt: toDatetimeLocal(round.votingEndAt),
+ votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null,
+ votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null,
})
// Set round type and settings
setRoundType((round.roundType as typeof roundType) || 'EVALUATION')
setRoundSettings((round.settingsJson as Record) || {})
+ setFormInitialized(true)
}
- }, [round, form])
+ }, [round, form, formInitialized])
// Initialize criteria from evaluation form
useEffect(() => {
@@ -151,8 +146,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {
requiredReviews: data.requiredReviews,
roundType,
settingsJson: roundSettings,
- votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : null,
- votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : null,
+ votingStartAt: data.votingStartAt ?? null,
+ votingEndAt: data.votingEndAt ?? null,
})
// Update evaluation form if criteria changed and no evaluations exist
@@ -303,7 +298,11 @@ function EditRoundContent({ roundId }: { roundId: string }) {
Start Date & Time
-
+
@@ -317,7 +316,11 @@ function EditRoundContent({ roundId }: { roundId: string }) {
End Date & Time
-
+
@@ -326,7 +329,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
- Leave empty to disable the voting window enforcement.
+ Leave empty to disable the voting window enforcement. Past dates are allowed.
diff --git a/src/app/(admin)/admin/rounds/[id]/filtering/page.tsx b/src/app/(admin)/admin/rounds/[id]/filtering/page.tsx
index 5f2fd07..5fc70e5 100644
--- a/src/app/(admin)/admin/rounds/[id]/filtering/page.tsx
+++ b/src/app/(admin)/admin/rounds/[id]/filtering/page.tsx
@@ -1,286 +1,24 @@
'use client'
-import { use } from 'react'
-import Link from 'next/link'
-import { trpc } from '@/lib/trpc/client'
-import { Button } from '@/components/ui/button'
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card'
-import { Badge } from '@/components/ui/badge'
-import { Skeleton } from '@/components/ui/skeleton'
-import { toast } from 'sonner'
-import {
- ArrowLeft,
- Filter,
- ListChecks,
- ClipboardCheck,
- Play,
- Loader2,
- CheckCircle2,
- XCircle,
- AlertTriangle,
-} from 'lucide-react'
+import { use, useEffect } from 'react'
+import { useRouter } from 'next/navigation'
+// Redirect to round details page - filtering is now integrated there
export default function FilteringDashboardPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: roundId } = use(params)
+ const router = useRouter()
- const { data: round, isLoading: roundLoading } =
- trpc.round.get.useQuery({ id: roundId })
- const { data: stats, isLoading: statsLoading, refetch: refetchStats } =
- trpc.filtering.getResultStats.useQuery({ roundId })
- const { data: rules } = trpc.filtering.getRules.useQuery({ roundId })
-
- const utils = trpc.useUtils()
- const executeRules = trpc.filtering.executeRules.useMutation()
- const finalizeResults = trpc.filtering.finalizeResults.useMutation()
-
- const handleExecute = async () => {
- try {
- const result = await executeRules.mutateAsync({ roundId })
- toast.success(
- `Filtering complete: ${result.passed} passed, ${result.filteredOut} filtered out, ${result.flagged} flagged`
- )
- refetchStats()
- } catch (error) {
- toast.error(
- error instanceof Error ? error.message : 'Failed to execute filtering'
- )
- }
- }
-
- const handleFinalize = async () => {
- try {
- const result = await finalizeResults.mutateAsync({ roundId })
- toast.success(
- `Finalized: ${result.passed} passed, ${result.filteredOut} filtered out`
- )
- refetchStats()
- utils.project.list.invalidate()
- utils.round.get.invalidate({ id: roundId })
- } catch (error) {
- toast.error(
- error instanceof Error ? error.message : 'Failed to finalize'
- )
- }
- }
-
- if (roundLoading) {
- return (
-
-
-
-
- )
- }
+ useEffect(() => {
+ router.replace(`/admin/rounds/${roundId}`)
+ }, [router, roundId])
return (
-
- {/* Header */}
-
-
-
-
- Back to Round
-
-
-
-
-
-
-
- Filtering — {round?.name}
-
-
- Configure and run automated project screening
-
-
-
-
- {executeRules.isPending ? (
-
- ) : (
-
- )}
- Run Filtering
-
-
-
-
- {/* Stats Cards */}
- {statsLoading ? (
-
- {[...Array(4)].map((_, i) => (
-
- ))}
-
- ) : stats && stats.total > 0 ? (
-
-
-
-
-
-
-
-
-
{stats.total}
-
Total
-
-
-
-
-
-
-
-
-
-
-
-
- {stats.passed}
-
-
Passed
-
-
-
-
-
-
-
-
-
-
-
-
- {stats.filteredOut}
-
-
Filtered Out
-
-
-
-
-
-
-
-
-
-
- {stats.flagged}
-
-
Flagged
-
-
-
-
-
- ) : (
-
-
-
- No filtering results yet
-
- Configure rules and run filtering to screen projects
-
-
-
- )}
-
- {/* Quick Links */}
-
-
-
-
-
-
- Filtering Rules
-
-
- Configure field-based, document, and AI screening rules
-
-
-
-
- {rules?.length || 0} rule{(rules?.length || 0) !== 1 ? 's' : ''}{' '}
- configured
-
-
-
-
-
-
-
-
-
-
- Review Results
-
-
- Review outcomes, override decisions, and finalize filtering
-
-
-
- {stats && stats.total > 0 ? (
-
-
- {stats.passed} passed
-
-
- {stats.filteredOut} filtered
-
-
- {stats.flagged} flagged
-
-
- ) : (
- No results yet
- )}
-
-
-
-
-
- {/* Finalize */}
- {stats && stats.total > 0 && (
-
-
- Finalize Filtering
-
- Apply filtering outcomes to project statuses. Passed projects become
- Eligible. Filtered-out projects are set aside (not deleted) and can
- be reinstated at any time.
-
-
-
-
- {finalizeResults.isPending ? (
-
- ) : (
-
- )}
- Finalize Results
-
-
-
- )}
+
+
Redirecting to round details...
)
}
diff --git a/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx b/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx
index 2b92c0b..685ba4e 100644
--- a/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx
+++ b/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx
@@ -159,9 +159,9 @@ export default function FilteringResultsPage({
{/* Header */}
-
+
- Back to Filtering
+ Back to Round
@@ -208,9 +208,8 @@ export default function FilteringResultsPage({
Project
Category
- Country
Outcome
- Override
+ AI Reason
Actions
@@ -221,6 +220,17 @@ export default function FilteringResultsPage({
result.finalOutcome || result.outcome
const badge = OUTCOME_BADGES[effectiveOutcome]
+ // Extract AI reasoning from aiScreeningJson
+ const aiScreening = result.aiScreeningJson as Record
| null
+ const firstAiResult = aiScreening ? Object.values(aiScreening)[0] : null
+ const aiReasoning = firstAiResult?.reasoning
+
return (
<>
{result.project.teamName}
+ {result.project.country && ` · ${result.project.country}`}
@@ -251,26 +262,42 @@ export default function FilteringResultsPage({
)}
- {result.project.country || '-'}
+
+
+ {badge?.icon}
+ {badge?.label || effectiveOutcome}
+
+ {result.overriddenByUser && (
+
+ Overridden by {result.overriddenByUser.name || result.overriddenByUser.email}
+
+ )}
+
-
- {badge?.icon}
- {badge?.label || effectiveOutcome}
-
-
-
- {result.overriddenByUser ? (
-
-
- {result.overriddenByUser.name || result.overriddenByUser.email}
-
-
- {result.overrideReason}
+ {aiReasoning ? (
+
+
+ {aiReasoning}
+ {firstAiResult && (
+
+ {firstAiResult.confidence !== undefined && (
+ Confidence: {Math.round(firstAiResult.confidence * 100)}%
+ )}
+ {firstAiResult.qualityScore !== undefined && (
+ Quality: {firstAiResult.qualityScore}/10
+ )}
+ {firstAiResult.spamRisk && (
+ Spam Risk
+ )}
+
+ )}
) : (
- '-'
+
+ No AI screening
+
)}
@@ -310,67 +337,121 @@ export default function FilteringResultsPage({
{isExpanded && (
-
-
-
- Rule Results
-
- {result.ruleResultsJson &&
- Array.isArray(result.ruleResultsJson) ? (
-
- {(
- result.ruleResultsJson as Array<{
- ruleName: string
- ruleType: string
- passed: boolean
- action: string
- reasoning?: string
- }>
- ).map((rr, i) => (
-
- {rr.passed ? (
-
- ) : (
-
- )}
-
- {rr.ruleName}
-
-
- {rr.ruleType}
-
- {rr.reasoning && (
-
- — {rr.reasoning}
-
- )}
-
- ))}
-
- ) : (
-
- No detailed rule results available
+
+
+ {/* Rule Results */}
+
+
+ Rule Results
+ {result.ruleResultsJson &&
+ Array.isArray(result.ruleResultsJson) ? (
+
+ {(
+ result.ruleResultsJson as Array<{
+ ruleName: string
+ ruleType: string
+ passed: boolean
+ action: string
+ reasoning?: string
+ }>
+ ).map((rr, i) => (
+
+ {rr.passed ? (
+
+ ) : (
+
+ )}
+
+
+
+ {rr.ruleName}
+
+
+ {rr.ruleType.replace('_', ' ')}
+
+
+ {rr.reasoning && (
+
+ {rr.reasoning}
+
+ )}
+
+
+ ))}
+
+ ) : (
+
+ No detailed rule results available
+
+ )}
+
+
+ {/* AI Screening Details */}
+ {aiScreening && Object.keys(aiScreening).length > 0 && (
+
+
+ AI Screening Analysis
+
+
+ {Object.entries(aiScreening).map(([ruleId, screening]) => (
+
+
+ {screening.meetsCriteria ? (
+
+ ) : (
+
+ )}
+
+ {screening.meetsCriteria ? 'Meets Criteria' : 'Does Not Meet Criteria'}
+
+ {screening.spamRisk && (
+
+
+ Spam Risk
+
+ )}
+
+ {screening.reasoning && (
+
+ {screening.reasoning}
+
+ )}
+
+ {screening.confidence !== undefined && (
+
+ Confidence: {Math.round(screening.confidence * 100)}%
+
+ )}
+ {screening.qualityScore !== undefined && (
+
+ Quality Score: {screening.qualityScore}/10
+
+ )}
+
+
+ ))}
+
+
)}
- {result.aiScreeningJson && (
-
-
- AI Screening Details
-
-
-
-
- {JSON.stringify(
- result.aiScreeningJson,
- null,
- 2
- )}
-
-
-
+
+ {/* Override Info */}
+ {result.overriddenByUser && (
+
+
Manual Override
+
+ Overridden to {result.finalOutcome} by{' '}
+ {result.overriddenByUser.name || result.overriddenByUser.email}
+
+ {result.overrideReason && (
+
+ Reason: {result.overrideReason}
+
+ )}
+
)}
diff --git a/src/app/(admin)/admin/rounds/[id]/page.tsx b/src/app/(admin)/admin/rounds/[id]/page.tsx
index f52f2fe..8568a43 100644
--- a/src/app/(admin)/admin/rounds/[id]/page.tsx
+++ b/src/app/(admin)/admin/rounds/[id]/page.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Suspense, use, useState } from 'react'
+import { Suspense, use, useState, useEffect } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
@@ -32,7 +32,6 @@ import {
Edit,
Users,
FileText,
- Calendar,
CheckCircle2,
Clock,
AlertCircle,
@@ -56,7 +55,7 @@ import { toast } from 'sonner'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
-import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns'
+import { format, formatDistanceToNow, isFuture } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
@@ -67,14 +66,15 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const [assignOpen, setAssignOpen] = useState(false)
const [advanceOpen, setAdvanceOpen] = useState(false)
const [removeOpen, setRemoveOpen] = useState(false)
+ const [activeJobId, setActiveJobId] = useState(null)
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
- // Filtering queries (only fetch for FILTERING rounds)
- const roundType = (round?.settingsJson as { roundType?: string } | null)?.roundType
- const isFilteringRound = roundType === 'FILTERING'
+ // Check if this is a filtering round - roundType is stored directly on the round
+ const isFilteringRound = round?.roundType === 'FILTERING'
+ // Filtering queries (only fetch for FILTERING rounds)
const { data: filteringStats, refetch: refetchFilteringStats } =
trpc.filtering.getResultStats.useQuery(
{ roundId },
@@ -88,6 +88,20 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
{ roundId },
{ enabled: isFilteringRound }
)
+ const { data: latestJob, refetch: refetchLatestJob } =
+ trpc.filtering.getLatestJob.useQuery(
+ { roundId },
+ { enabled: isFilteringRound }
+ )
+
+ // Poll for job status when there's an active job
+ const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
+ { jobId: activeJobId! },
+ {
+ enabled: !!activeJobId,
+ refetchInterval: activeJobId ? 2000 : false,
+ }
+ )
const utils = trpc.useUtils()
const updateStatus = trpc.round.updateStatus.useMutation({
@@ -108,19 +122,40 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
})
// Filtering mutations
- const executeRules = trpc.filtering.executeRules.useMutation()
+ const startJob = trpc.filtering.startJob.useMutation()
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
- const handleExecuteFiltering = async () => {
- try {
- const result = await executeRules.mutateAsync({ roundId })
+ // Set active job from latest job on load
+ useEffect(() => {
+ if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
+ setActiveJobId(latestJob.id)
+ }
+ }, [latestJob])
+
+ // Handle job completion
+ useEffect(() => {
+ if (jobStatus?.status === 'COMPLETED') {
toast.success(
- `Filtering complete: ${result.passed} passed, ${result.filteredOut} filtered out, ${result.flagged} flagged`
+ `Filtering complete: ${jobStatus.passedCount} passed, ${jobStatus.filteredCount} filtered out, ${jobStatus.flaggedCount} flagged`
)
+ setActiveJobId(null)
refetchFilteringStats()
+ refetchLatestJob()
+ } else if (jobStatus?.status === 'FAILED') {
+ toast.error(`Filtering failed: ${jobStatus.errorMessage || 'Unknown error'}`)
+ setActiveJobId(null)
+ refetchLatestJob()
+ }
+ }, [jobStatus?.status, jobStatus?.passedCount, jobStatus?.filteredCount, jobStatus?.flaggedCount, jobStatus?.errorMessage, refetchFilteringStats, refetchLatestJob])
+
+ const handleStartFiltering = async () => {
+ try {
+ const result = await startJob.mutateAsync({ roundId })
+ setActiveJobId(result.jobId)
+ toast.info('Filtering job started. Progress will update automatically.')
} catch (error) {
toast.error(
- error instanceof Error ? error.message : 'Failed to execute filtering'
+ error instanceof Error ? error.message : 'Failed to start filtering'
)
}
}
@@ -141,6 +176,11 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
}
}
+ const isJobRunning = jobStatus?.status === 'RUNNING' || jobStatus?.status === 'PENDING'
+ const progressPercent = jobStatus?.totalBatches
+ ? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
+ : 0
+
if (isLoading) {
return
}
@@ -475,20 +515,54 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
- {executeRules.isPending ? (
+ {startJob.isPending || isJobRunning ? (
) : (
)}
- Run Filtering
+ {isJobRunning ? 'Running...' : 'Run Filtering'}
+ {/* Progress Card (when job is running) */}
+ {isJobRunning && jobStatus && (
+
+
+
+
+
+
+ AI Filtering in Progress
+
+
+ Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches
+
+
+
+
+ Batch {jobStatus.currentBatch} of {jobStatus.totalBatches}
+
+
+
+
+
+ {jobStatus.processedCount} of {jobStatus.totalProjects} projects processed
+
+
+ {progressPercent}%
+
+
+
+
+
+
+ )}
+
{/* AI Status Warning */}
{aiStatus?.hasAIRules && !aiStatus?.configured && (
@@ -551,7 +625,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
- ) : (
+ ) : !isJobRunning && (
No filtering results yet
@@ -581,7 +655,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
{filteringStats && filteringStats.total > 0 && (
{finalizeResults.isPending ? (
@@ -644,14 +718,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Jury Assignments
- {!isFilteringRound && (
-
-
-
- Filtering
-
-
- )}
diff --git a/src/app/(admin)/admin/rounds/new/page.tsx b/src/app/(admin)/admin/rounds/new/page.tsx
index b6ae9e5..e4264ae 100644
--- a/src/app/(admin)/admin/rounds/new/page.tsx
+++ b/src/app/(admin)/admin/rounds/new/page.tsx
@@ -35,16 +35,17 @@ import {
} from '@/components/ui/form'
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react'
+import { DateTimePicker } from '@/components/ui/datetime-picker'
const createRoundSchema = z.object({
programId: z.string().min(1, 'Please select a program'),
name: z.string().min(1, 'Name is required').max(255),
requiredReviews: z.number().int().min(1).max(10),
- votingStartAt: z.string().optional(),
- votingEndAt: z.string().optional(),
+ votingStartAt: z.date().nullable().optional(),
+ votingEndAt: z.date().nullable().optional(),
}).refine((data) => {
if (data.votingStartAt && data.votingEndAt) {
- return new Date(data.votingEndAt) > new Date(data.votingStartAt)
+ return data.votingEndAt > data.votingStartAt
}
return true
}, {
@@ -75,8 +76,8 @@ function CreateRoundContent() {
programId: programIdParam || '',
name: '',
requiredReviews: 3,
- votingStartAt: '',
- votingEndAt: '',
+ votingStartAt: null,
+ votingEndAt: null,
},
})
@@ -87,8 +88,8 @@ function CreateRoundContent() {
roundType,
requiredReviews: data.requiredReviews,
settingsJson: roundSettings,
- votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : undefined,
- votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : undefined,
+ votingStartAt: data.votingStartAt ?? undefined,
+ votingEndAt: data.votingEndAt ?? undefined,
})
}
@@ -246,7 +247,11 @@ function CreateRoundContent() {
Start Date & Time
-
+
@@ -260,7 +265,11 @@ function CreateRoundContent() {
End Date & Time
-
+
@@ -269,8 +278,7 @@ function CreateRoundContent() {
- Leave empty to set the voting window later. The round will need to be
- activated before jury members can submit evaluations.
+ Leave empty to set the voting window later. Past dates are allowed.
diff --git a/src/app/(public)/apply/[slug]/wizard/page.tsx b/src/app/(public)/apply/[slug]/wizard/page.tsx
new file mode 100644
index 0000000..d081e75
--- /dev/null
+++ b/src/app/(public)/apply/[slug]/wizard/page.tsx
@@ -0,0 +1,676 @@
+'use client'
+
+import { useState, useEffect, use } from 'react'
+import { useRouter } from 'next/navigation'
+import { useForm, Controller } from 'react-hook-form'
+import { trpc } from '@/lib/trpc/client'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ CardFooter,
+} from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Checkbox } from '@/components/ui/checkbox'
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Progress } from '@/components/ui/progress'
+import { toast } from 'sonner'
+import { CheckCircle, AlertCircle, Loader2, ChevronLeft, ChevronRight, Check } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { Logo } from '@/components/shared/logo'
+
+// Country list for country select special field
+const COUNTRIES = [
+ 'Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola', 'Argentina', 'Armenia', 'Australia',
+ 'Austria', 'Azerbaijan', 'Bahamas', 'Bahrain', 'Bangladesh', 'Barbados', 'Belarus', 'Belgium',
+ 'Belize', 'Benin', 'Bhutan', 'Bolivia', 'Bosnia and Herzegovina', 'Botswana', 'Brazil', 'Brunei',
+ 'Bulgaria', 'Burkina Faso', 'Burundi', 'Cambodia', 'Cameroon', 'Canada', 'Cape Verde',
+ 'Central African Republic', 'Chad', 'Chile', 'China', 'Colombia', 'Comoros', 'Congo', 'Costa Rica',
+ 'Croatia', 'Cuba', 'Cyprus', 'Czech Republic', 'Denmark', 'Djibouti', 'Dominica', 'Dominican Republic',
+ 'Ecuador', 'Egypt', 'El Salvador', 'Equatorial Guinea', 'Eritrea', 'Estonia', 'Eswatini', 'Ethiopia',
+ 'Fiji', 'Finland', 'France', 'Gabon', 'Gambia', 'Georgia', 'Germany', 'Ghana', 'Greece', 'Grenada',
+ 'Guatemala', 'Guinea', 'Guinea-Bissau', 'Guyana', 'Haiti', 'Honduras', 'Hungary', 'Iceland', 'India',
+ 'Indonesia', 'Iran', 'Iraq', 'Ireland', 'Israel', 'Italy', 'Jamaica', 'Japan', 'Jordan', 'Kazakhstan',
+ 'Kenya', 'Kiribati', 'Kuwait', 'Kyrgyzstan', 'Laos', 'Latvia', 'Lebanon', 'Lesotho', 'Liberia',
+ 'Libya', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Madagascar', 'Malawi', 'Malaysia', 'Maldives',
+ 'Mali', 'Malta', 'Marshall Islands', 'Mauritania', 'Mauritius', 'Mexico', 'Micronesia', 'Moldova',
+ 'Monaco', 'Mongolia', 'Montenegro', 'Morocco', 'Mozambique', 'Myanmar', 'Namibia', 'Nauru', 'Nepal',
+ 'Netherlands', 'New Zealand', 'Nicaragua', 'Niger', 'Nigeria', 'North Korea', 'North Macedonia',
+ 'Norway', 'Oman', 'Pakistan', 'Palau', 'Palestine', 'Panama', 'Papua New Guinea', 'Paraguay', 'Peru',
+ 'Philippines', 'Poland', 'Portugal', 'Qatar', 'Romania', 'Russia', 'Rwanda', 'Saint Kitts and Nevis',
+ 'Saint Lucia', 'Saint Vincent and the Grenadines', 'Samoa', 'San Marino', 'Sao Tome and Principe',
+ 'Saudi Arabia', 'Senegal', 'Serbia', 'Seychelles', 'Sierra Leone', 'Singapore', 'Slovakia', 'Slovenia',
+ 'Solomon Islands', 'Somalia', 'South Africa', 'South Korea', 'South Sudan', 'Spain', 'Sri Lanka',
+ 'Sudan', 'Suriname', 'Sweden', 'Switzerland', 'Syria', 'Taiwan', 'Tajikistan', 'Tanzania', 'Thailand',
+ 'Timor-Leste', 'Togo', 'Tonga', 'Trinidad and Tobago', 'Tunisia', 'Turkey', 'Turkmenistan', 'Tuvalu',
+ 'Uganda', 'Ukraine', 'United Arab Emirates', 'United Kingdom', 'United States', 'Uruguay', 'Uzbekistan',
+ 'Vanuatu', 'Vatican City', 'Venezuela', 'Vietnam', 'Yemen', 'Zambia', 'Zimbabwe',
+]
+
+// Ocean issues for special field
+const OCEAN_ISSUES = [
+ { value: 'POLLUTION_REDUCTION', label: 'Pollution Reduction' },
+ { value: 'CLIMATE_MITIGATION', label: 'Climate Mitigation' },
+ { value: 'TECHNOLOGY_INNOVATION', label: 'Technology Innovation' },
+ { value: 'SUSTAINABLE_SHIPPING', label: 'Sustainable Shipping' },
+ { value: 'BLUE_CARBON', label: 'Blue Carbon' },
+ { value: 'HABITAT_RESTORATION', label: 'Habitat Restoration' },
+ { value: 'COMMUNITY_CAPACITY', label: 'Community Capacity Building' },
+ { value: 'SUSTAINABLE_FISHING', label: 'Sustainable Fishing' },
+ { value: 'CONSUMER_AWARENESS', label: 'Consumer Awareness' },
+ { value: 'OCEAN_ACIDIFICATION', label: 'Ocean Acidification' },
+ { value: 'OTHER', label: 'Other' },
+]
+
+// Competition categories for special field
+const COMPETITION_CATEGORIES = [
+ { value: 'STARTUP', label: 'Startup - Existing company with traction' },
+ { value: 'BUSINESS_CONCEPT', label: 'Business Concept - Student/graduate project' },
+]
+
+interface PageProps {
+ params: Promise<{ slug: string }>
+}
+
+type FieldType = {
+ id: string
+ fieldType: string
+ name: string
+ label: string
+ description?: string | null
+ placeholder?: string | null
+ required: boolean
+ minLength?: number | null
+ maxLength?: number | null
+ minValue?: number | null
+ maxValue?: number | null
+ optionsJson: unknown
+ conditionJson: unknown
+ width: string
+ specialType?: string | null
+ projectMapping?: string | null
+}
+
+type StepType = {
+ id: string
+ name: string
+ title: string
+ description?: string | null
+ isOptional: boolean
+ fields: FieldType[]
+}
+
+export default function OnboardingWizardPage({ params }: PageProps) {
+ const { slug } = use(params)
+ const router = useRouter()
+ const [currentStepIndex, setCurrentStepIndex] = useState(0)
+ const [submitted, setSubmitted] = useState(false)
+ const [confirmationMessage, setConfirmationMessage] = useState(null)
+
+ // Fetch onboarding config
+ const { data: config, isLoading, error } = trpc.onboarding.getConfig.useQuery(
+ { slug },
+ { retry: false }
+ )
+
+ // Form state
+ const { control, handleSubmit, watch, setValue, formState: { errors }, trigger } = useForm({
+ mode: 'onChange',
+ })
+
+ const watchedValues = watch()
+
+ // Submit mutation
+ const submitMutation = trpc.onboarding.submit.useMutation({
+ onSuccess: (result) => {
+ setSubmitted(true)
+ setConfirmationMessage(result.confirmationMessage || null)
+ },
+ onError: (err) => {
+ toast.error(err.message || 'Submission failed')
+ },
+ })
+
+ const steps = config?.steps || []
+ const currentStep = steps[currentStepIndex]
+ const isLastStep = currentStepIndex === steps.length - 1
+ const progress = ((currentStepIndex + 1) / steps.length) * 100
+
+ // Navigate between steps
+ const goToNextStep = async () => {
+ // Validate current step fields
+ const currentFields = currentStep?.fields || []
+ const fieldNames = currentFields.map((f) => f.name)
+ const isValid = await trigger(fieldNames)
+
+ if (!isValid) {
+ toast.error('Please fill in all required fields')
+ return
+ }
+
+ if (isLastStep) {
+ // Submit the form
+ const allData = watchedValues
+ await submitMutation.mutateAsync({
+ formId: config!.form.id,
+ contactName: allData.contactName || allData.name || '',
+ contactEmail: allData.contactEmail || allData.email || '',
+ contactPhone: allData.contactPhone || allData.phone,
+ projectName: allData.projectName || allData.title || '',
+ description: allData.description,
+ competitionCategory: allData.competitionCategory,
+ oceanIssue: allData.oceanIssue,
+ country: allData.country,
+ institution: allData.institution,
+ teamName: allData.teamName,
+ wantsMentorship: allData.wantsMentorship,
+ referralSource: allData.referralSource,
+ foundedAt: allData.foundedAt,
+ teamMembers: allData.teamMembers,
+ metadata: allData,
+ gdprConsent: allData.gdprConsent || false,
+ })
+ } else {
+ setCurrentStepIndex((prev) => prev + 1)
+ }
+ }
+
+ const goToPrevStep = () => {
+ setCurrentStepIndex((prev) => Math.max(0, prev - 1))
+ }
+
+ // Render field based on type and special type
+ const renderField = (field: FieldType) => {
+ const errorMessage = errors[field.name]?.message as string | undefined
+
+ // Handle special field types
+ if (field.specialType) {
+ switch (field.specialType) {
+ case 'COMPETITION_CATEGORY':
+ return (
+
+
+ Competition Category
+ {field.required && * }
+
+
(
+
+ {COMPETITION_CATEGORIES.map((cat) => (
+ f.onChange(cat.value)}
+ >
+
+
+ {cat.label}
+
+
+ ))}
+
+ )}
+ />
+ {errorMessage && {errorMessage}
}
+
+ )
+
+ case 'OCEAN_ISSUE':
+ return (
+
+
+ Ocean Issue Focus
+ {field.required && * }
+
+
(
+
+
+
+
+
+ {OCEAN_ISSUES.map((issue) => (
+
+ {issue.label}
+
+ ))}
+
+
+ )}
+ />
+ {errorMessage && {errorMessage}
}
+
+ )
+
+ case 'COUNTRY_SELECT':
+ return (
+
+
+ {field.label || 'Country'}
+ {field.required && * }
+
+
(
+
+
+
+
+
+ {COUNTRIES.map((country) => (
+
+ {country}
+
+ ))}
+
+
+ )}
+ />
+ {errorMessage && {errorMessage}
}
+
+ )
+
+ case 'GDPR_CONSENT':
+ return (
+
+
+
Terms & Conditions
+
+ By submitting this application, you agree to our terms of service and privacy policy.
+ Your data will be processed in accordance with GDPR regulations.
+
+
+
value === true || 'You must accept the terms and conditions'
+ }}
+ render={({ field: f }) => (
+
+
+
+ I accept the terms and conditions and consent to the processing of my data
+ *
+
+
+ )}
+ />
+ {errorMessage && {errorMessage}
}
+
+ )
+
+ default:
+ break
+ }
+ }
+
+ // Standard field types
+ switch (field.fieldType) {
+ case 'TEXT':
+ case 'EMAIL':
+ case 'PHONE':
+ case 'URL':
+ return (
+
+
+ {field.label}
+ {field.required && * }
+
+ {field.description && (
+
{field.description}
+ )}
+
(
+
+ )}
+ />
+ {errorMessage && {errorMessage}
}
+
+ )
+
+ case 'TEXTAREA':
+ return (
+
+
+ {field.label}
+ {field.required && * }
+
+ {field.description && (
+
{field.description}
+ )}
+
(
+
+ )}
+ />
+ {errorMessage && {errorMessage}
}
+
+ )
+
+ case 'SELECT':
+ const options = (field.optionsJson as Array<{ value: string; label: string }>) || []
+ return (
+
+
+ {field.label}
+ {field.required && * }
+
+
(
+
+
+
+
+
+ {options.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ )}
+ />
+ {errorMessage && {errorMessage}
}
+
+ )
+
+ case 'CHECKBOX':
+ return (
+
+
value === true || `${field.label} is required`
+ : undefined,
+ }}
+ render={({ field: f }) => (
+
+
+
+
+ {field.label}
+ {field.required && * }
+
+ {field.description && (
+
{field.description}
+ )}
+
+
+ )}
+ />
+ {errorMessage && {errorMessage}
}
+
+ )
+
+ case 'DATE':
+ return (
+
+
+ {field.label}
+ {field.required && * }
+
+
(
+
+ )}
+ />
+ {errorMessage && {errorMessage}
}
+
+ )
+
+ default:
+ return null
+ }
+ }
+
+ // Loading state
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+ ))}
+
+
+
+
+ )
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+
+
+
+ Application Not Available
+
+ {error.message}
+
+
+
+
+ )
+ }
+
+ // Success state
+ if (submitted) {
+ return (
+
+
+
+
+
+
+ Application Submitted!
+
+ {confirmationMessage || 'Thank you for your submission. We will review your application and get back to you soon.'}
+
+
+
+
+ )
+ }
+
+ if (!config || steps.length === 0) {
+ return (
+
+
+
+
+ Form Not Configured
+
+ This application form has not been configured yet.
+
+
+
+
+ )
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ {/* Progress */}
+
+
+ Step {currentStepIndex + 1} of {steps.length}
+ {Math.round(progress)}% complete
+
+
+
+ {/* Step indicators */}
+
+ {steps.map((step, index) => (
+
+ {index < currentStepIndex ? : index + 1}
+
+ ))}
+
+
+
+ {/* Form Card */}
+
+
+ {currentStep?.title}
+ {currentStep?.description && (
+ {currentStep.description}
+ )}
+
+
+ { e.preventDefault(); goToNextStep(); }}>
+
+ {currentStep?.fields.map((field) => (
+
+ {renderField(field)}
+
+ ))}
+
+
+
+
+
+
+ Previous
+
+
+ {submitMutation.isPending ? (
+ <>
+
+ Submitting...
+ >
+ ) : isLastStep ? (
+ <>
+ Submit Application
+
+ >
+ ) : (
+ <>
+ Next
+
+ >
+ )}
+
+
+
+
+ {/* Footer */}
+
+ {config.program?.name} {config.program?.year && `${config.program.year}`}
+
+
+
+ )
+}
diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts
index 370a711..989af07 100644
--- a/src/app/api/trpc/[trpc]/route.ts
+++ b/src/app/api/trpc/[trpc]/route.ts
@@ -3,6 +3,10 @@ import { appRouter } from '@/server/routers/_app'
import { createContext } from '@/server/context'
import { checkRateLimit } from '@/lib/rate-limit'
+// Allow long-running operations (AI filtering, bulk imports)
+// This affects Next.js serverless functions; for self-hosted, Nginx timeout also matters
+export const maxDuration = 300 // 5 minutes
+
const RATE_LIMIT = 100 // requests per window
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
diff --git a/src/components/layouts/admin-sidebar.tsx b/src/components/layouts/admin-sidebar.tsx
index dd512bd..59d671a 100644
--- a/src/components/layouts/admin-sidebar.tsx
+++ b/src/components/layouts/admin-sidebar.tsx
@@ -91,8 +91,8 @@ const navigation = [
icon: Handshake,
},
{
- name: 'Forms',
- href: '/admin/forms' as const,
+ name: 'Onboarding',
+ href: '/admin/onboarding' as const,
icon: FileText,
},
]
diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx
new file mode 100644
index 0000000..536ee05
--- /dev/null
+++ b/src/components/ui/calendar.tsx
@@ -0,0 +1,72 @@
+'use client'
+
+import * as React from 'react'
+import { ChevronLeft, ChevronRight } from 'lucide-react'
+import { DayPicker } from 'react-day-picker'
+
+import { cn } from '@/lib/utils'
+import { buttonVariants } from '@/components/ui/button'
+
+export type CalendarProps = React.ComponentProps
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+
+ orientation === 'left' ? (
+
+ ) : (
+
+ ),
+ }}
+ {...props}
+ />
+ )
+}
+Calendar.displayName = 'Calendar'
+
+export { Calendar }
diff --git a/src/components/ui/datetime-picker.tsx b/src/components/ui/datetime-picker.tsx
new file mode 100644
index 0000000..38f7520
--- /dev/null
+++ b/src/components/ui/datetime-picker.tsx
@@ -0,0 +1,192 @@
+'use client'
+
+import * as React from 'react'
+import { format, setHours, setMinutes } from 'date-fns'
+import { CalendarIcon, Clock } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import { Calendar } from '@/components/ui/calendar'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+
+interface DateTimePickerProps {
+ value?: Date | null
+ onChange?: (date: Date | null) => void
+ placeholder?: string
+ disabled?: boolean
+ className?: string
+ /** If true, only shows date picker without time */
+ dateOnly?: boolean
+ /** If true, allows clearing the value */
+ clearable?: boolean
+}
+
+// Generate hour options (00-23)
+const hours = Array.from({ length: 24 }, (_, i) => i)
+
+// Generate minute options (00, 15, 30, 45) for easier selection
+const minutes = [0, 15, 30, 45]
+
+export function DateTimePicker({
+ value,
+ onChange,
+ placeholder = 'Select date and time',
+ disabled = false,
+ className,
+ dateOnly = false,
+ clearable = true,
+}: DateTimePickerProps) {
+ const [open, setOpen] = React.useState(false)
+ const [selectedDate, setSelectedDate] = React.useState(
+ value ?? undefined
+ )
+
+ // Sync internal state with external value
+ React.useEffect(() => {
+ setSelectedDate(value ?? undefined)
+ }, [value])
+
+ const handleDateSelect = (date: Date | undefined) => {
+ if (!date) {
+ setSelectedDate(undefined)
+ onChange?.(null)
+ return
+ }
+
+ // Preserve time from previous selection or use noon as default
+ const newDate = selectedDate
+ ? setHours(setMinutes(date, selectedDate.getMinutes()), selectedDate.getHours())
+ : setHours(setMinutes(date, 0), 12) // Default to noon
+
+ setSelectedDate(newDate)
+ onChange?.(newDate)
+ }
+
+ const handleTimeChange = (type: 'hour' | 'minute', valueStr: string) => {
+ if (!selectedDate) return
+
+ const numValue = parseInt(valueStr, 10)
+ let newDate: Date
+
+ if (type === 'hour') {
+ newDate = setHours(selectedDate, numValue)
+ } else {
+ newDate = setMinutes(selectedDate, numValue)
+ }
+
+ setSelectedDate(newDate)
+ onChange?.(newDate)
+ }
+
+ const handleClear = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ setSelectedDate(undefined)
+ onChange?.(null)
+ }
+
+ const formatDisplayDate = (date: Date) => {
+ if (dateOnly) {
+ return format(date, 'MMM d, yyyy')
+ }
+ return format(date, 'MMM d, yyyy HH:mm')
+ }
+
+ return (
+
+
+
+
+ {selectedDate ? formatDisplayDate(selectedDate) : placeholder}
+ {clearable && selectedDate && (
+ e.key === 'Enter' && handleClear(e as unknown as React.MouseEvent)}
+ >
+ ×
+
+ )}
+
+
+
+
+
+
+ {!dateOnly && (
+
+
+
+ Time:
+ handleTimeChange('hour', v)}
+ disabled={!selectedDate}
+ >
+
+
+
+
+ {hours.map((hour) => (
+
+ {String(hour).padStart(2, '0')}
+
+ ))}
+
+
+ :
+ handleTimeChange('minute', v)}
+ disabled={!selectedDate}
+ >
+
+
+
+
+ {minutes.map((minute) => (
+
+ {String(minute).padStart(2, '0')}
+
+ ))}
+
+
+
+ {selectedDate && (
+
+ Selected: {format(selectedDate, 'EEEE, MMMM d, yyyy')} at{' '}
+ {format(selectedDate, 'HH:mm')}
+
+ )}
+
+ )}
+
+
+
+ )
+}
diff --git a/src/lib/email.ts b/src/lib/email.ts
index eecb3b9..e88a743 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -470,6 +470,98 @@ Together for a healthier ocean.
}
}
+/**
+ * Generate application confirmation email template
+ */
+function getApplicationConfirmationTemplate(
+ name: string,
+ projectName: string,
+ programName: string,
+ customMessage?: string
+): EmailTemplate {
+ const greeting = name ? `Hello ${name},` : 'Hello,'
+
+ const customMessageHtml = customMessage
+ ? `${customMessage.replace(/\n/g, ' ')}
`
+ : ''
+
+ const content = `
+ ${sectionTitle(greeting)}
+ ${paragraph(`Thank you for submitting your application to ${programName} !`)}
+ ${infoBox(`Your project "${projectName} " has been successfully received.`, 'success')}
+ ${customMessageHtml}
+ ${paragraph('Our team will review your submission and get back to you soon. In the meantime, if you have any questions, please don\'t hesitate to reach out.')}
+
+ You will receive email updates about your application status.
+
+ `
+
+ return {
+ subject: `Application Received - ${projectName}`,
+ html: getEmailWrapper(content),
+ text: `
+${greeting}
+
+Thank you for submitting your application to ${programName}!
+
+Your project "${projectName}" has been successfully received.
+
+${customMessage || ''}
+
+Our team will review your submission and get back to you soon. In the meantime, if you have any questions, please don't hesitate to reach out.
+
+---
+Monaco Ocean Protection Challenge
+Together for a healthier ocean.
+ `,
+ }
+}
+
+/**
+ * Generate team member invite email template
+ */
+function getTeamMemberInviteTemplate(
+ name: string,
+ projectName: string,
+ teamLeadName: string,
+ inviteUrl: string
+): EmailTemplate {
+ const greeting = name ? `Hello ${name},` : 'Hello,'
+
+ const content = `
+ ${sectionTitle(greeting)}
+ ${paragraph(`${teamLeadName} has invited you to join their team for the project "${projectName} " on the Monaco Ocean Protection Challenge platform.`)}
+ ${paragraph('Click the button below to accept the invitation and set up your account.')}
+ ${ctaButton(inviteUrl, 'Accept Invitation')}
+ ${infoBox('This invitation link will expire in 30 days.', 'info')}
+
+ If you weren't expecting this invitation, you can safely ignore this email.
+
+ `
+
+ return {
+ subject: `You've been invited to join "${projectName}"`,
+ html: getEmailWrapper(content),
+ text: `
+${greeting}
+
+${teamLeadName} has invited you to join their team for the project "${projectName}" on the Monaco Ocean Protection Challenge platform.
+
+Click the link below to accept the invitation and set up your account:
+
+${inviteUrl}
+
+This invitation link will expire in 30 days.
+
+If you weren't expecting this invitation, you can safely ignore this email.
+
+---
+Monaco Ocean Protection Challenge
+Together for a healthier ocean.
+ `,
+ }
+}
+
// =============================================================================
// Email Sending Functions
// =============================================================================
@@ -634,3 +726,57 @@ export async function verifyEmailConnection(): Promise {
return false
}
}
+
+/**
+ * Send application confirmation email to applicant
+ */
+export async function sendApplicationConfirmationEmail(
+ email: string,
+ applicantName: string,
+ projectName: string,
+ programName: string,
+ customMessage?: string
+): Promise {
+ const template = getApplicationConfirmationTemplate(
+ applicantName,
+ projectName,
+ programName,
+ customMessage
+ )
+ const { transporter, from } = await getTransporter()
+
+ await transporter.sendMail({
+ from,
+ to: email,
+ subject: template.subject,
+ text: template.text,
+ html: template.html,
+ })
+}
+
+/**
+ * Send team member invite email
+ */
+export async function sendTeamMemberInviteEmail(
+ email: string,
+ memberName: string,
+ projectName: string,
+ teamLeadName: string,
+ inviteUrl: string
+): Promise {
+ const template = getTeamMemberInviteTemplate(
+ memberName,
+ projectName,
+ teamLeadName,
+ inviteUrl
+ )
+ const { transporter, from } = await getTransporter()
+
+ await transporter.sendMail({
+ from,
+ to: email,
+ subject: template.subject,
+ text: template.text,
+ html: template.html,
+ })
+}
diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts
index fe85f72..f6d81ea 100644
--- a/src/server/routers/_app.ts
+++ b/src/server/routers/_app.ts
@@ -16,6 +16,7 @@ import { partnerRouter } from './partner'
import { notionImportRouter } from './notion-import'
import { typeformImportRouter } from './typeform-import'
import { applicationFormRouter } from './applicationForm'
+import { onboardingRouter } from './onboarding'
// Phase 2B routers
import { tagRouter } from './tag'
import { applicantRouter } from './applicant'
@@ -51,6 +52,7 @@ export const appRouter = router({
notionImport: notionImportRouter,
typeformImport: typeformImportRouter,
applicationForm: applicationFormRouter,
+ onboarding: onboardingRouter,
// Phase 2B routers
tag: tagRouter,
applicant: applicantRouter,
diff --git a/src/server/routers/applicationForm.ts b/src/server/routers/applicationForm.ts
index 6bc3af2..eb1fca4 100644
--- a/src/server/routers/applicationForm.ts
+++ b/src/server/routers/applicationForm.ts
@@ -28,6 +28,16 @@ const fieldTypeEnum = z.enum([
'INSTRUCTIONS',
])
+// Special field type enum
+const specialFieldTypeEnum = z.enum([
+ 'TEAM_MEMBERS',
+ 'COMPETITION_CATEGORY',
+ 'OCEAN_ISSUE',
+ 'FILE_UPLOAD',
+ 'GDPR_CONSENT',
+ 'COUNTRY_SELECT',
+])
+
// Field input schema
const fieldInputSchema = z.object({
fieldType: fieldTypeEnum,
@@ -52,6 +62,25 @@ const fieldInputSchema = z.object({
.optional(),
sortOrder: z.number().int().default(0),
width: z.enum(['full', 'half']).default('full'),
+ // Onboarding-specific fields
+ stepId: z.string().optional(),
+ projectMapping: z.string().optional(),
+ specialType: specialFieldTypeEnum.optional(),
+})
+
+// Step input schema
+const stepInputSchema = z.object({
+ name: z.string().min(1).max(100),
+ title: z.string().min(1).max(255),
+ description: z.string().optional(),
+ isOptional: z.boolean().default(false),
+ conditionJson: z
+ .object({
+ fieldId: z.string(),
+ operator: z.enum(['equals', 'not_equals', 'contains', 'not_empty']),
+ value: z.string().optional(),
+ })
+ .optional(),
})
export const applicationFormRouter = router({
@@ -101,7 +130,7 @@ export const applicationFormRouter = router({
}),
/**
- * Get a single form by ID (admin view with all fields)
+ * Get a single form by ID (admin view with all fields and steps)
*/
get: adminProcedure
.input(z.object({ id: z.string() }))
@@ -110,7 +139,12 @@ export const applicationFormRouter = router({
where: { id: input.id },
include: {
program: { select: { id: true, name: true, year: true } },
+ round: { select: { id: true, name: true, slug: true } },
fields: { orderBy: { sortOrder: 'asc' } },
+ steps: {
+ orderBy: { sortOrder: 'asc' },
+ include: { fields: { orderBy: { sortOrder: 'asc' } } },
+ },
_count: { select: { submissions: true } },
},
})
@@ -315,7 +349,7 @@ export const applicationFormRouter = router({
}),
/**
- * Add a field to a form
+ * Add a field to a form (or step)
*/
addField: adminProcedure
.input(
@@ -325,19 +359,28 @@ export const applicationFormRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
- // Get max sort order
+ // Get max sort order (within the step if specified, otherwise form-wide)
+ const whereClause = input.field.stepId
+ ? { stepId: input.field.stepId }
+ : { formId: input.formId, stepId: null }
+
const maxOrder = await ctx.prisma.applicationFormField.aggregate({
- where: { formId: input.formId },
+ where: whereClause,
_max: { sortOrder: true },
})
+ const { stepId, projectMapping, specialType, ...restField } = input.field
+
const field = await ctx.prisma.applicationFormField.create({
data: {
formId: input.formId,
- ...input.field,
- sortOrder: input.field.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 1,
- optionsJson: input.field.optionsJson ?? undefined,
- conditionJson: input.field.conditionJson ?? undefined,
+ ...restField,
+ sortOrder: restField.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 1,
+ optionsJson: restField.optionsJson ?? undefined,
+ conditionJson: restField.conditionJson ?? undefined,
+ stepId: stepId ?? undefined,
+ projectMapping: projectMapping ?? undefined,
+ specialType: specialType ?? undefined,
},
})
@@ -355,12 +398,18 @@ export const applicationFormRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
+ const { stepId, projectMapping, specialType, ...restField } = input.field
+
const field = await ctx.prisma.applicationFormField.update({
where: { id: input.id },
data: {
- ...input.field,
- optionsJson: input.field.optionsJson ?? undefined,
- conditionJson: input.field.conditionJson ?? undefined,
+ ...restField,
+ optionsJson: restField.optionsJson ?? undefined,
+ conditionJson: restField.conditionJson ?? undefined,
+ // Handle nullable fields explicitly
+ stepId: stepId === undefined ? undefined : stepId,
+ projectMapping: projectMapping === undefined ? undefined : projectMapping,
+ specialType: specialType === undefined ? undefined : specialType,
},
})
@@ -733,6 +782,248 @@ export const applicationFormRouter = router({
return newForm
}),
+ // ===========================================================================
+ // ONBOARDING STEP MANAGEMENT
+ // ===========================================================================
+
+ /**
+ * Create a new step in a form
+ */
+ createStep: adminProcedure
+ .input(
+ z.object({
+ formId: z.string(),
+ step: stepInputSchema,
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ // Get max sort order
+ const maxOrder = await ctx.prisma.onboardingStep.aggregate({
+ where: { formId: input.formId },
+ _max: { sortOrder: true },
+ })
+
+ const step = await ctx.prisma.onboardingStep.create({
+ data: {
+ formId: input.formId,
+ ...input.step,
+ sortOrder: (maxOrder._max.sortOrder ?? -1) + 1,
+ conditionJson: input.step.conditionJson ?? undefined,
+ },
+ })
+
+ return step
+ }),
+
+ /**
+ * Update a step
+ */
+ updateStep: adminProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ step: stepInputSchema.partial(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const step = await ctx.prisma.onboardingStep.update({
+ where: { id: input.id },
+ data: {
+ ...input.step,
+ conditionJson: input.step.conditionJson ?? undefined,
+ },
+ })
+
+ return step
+ }),
+
+ /**
+ * Delete a step (fields will have stepId set to null)
+ */
+ deleteStep: adminProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ return ctx.prisma.onboardingStep.delete({
+ where: { id: input.id },
+ })
+ }),
+
+ /**
+ * Reorder steps
+ */
+ reorderSteps: adminProcedure
+ .input(
+ z.object({
+ formId: z.string(),
+ stepIds: z.array(z.string()),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ await ctx.prisma.$transaction(
+ input.stepIds.map((id, index) =>
+ ctx.prisma.onboardingStep.update({
+ where: { id },
+ data: { sortOrder: index },
+ })
+ )
+ )
+
+ return { success: true }
+ }),
+
+ /**
+ * Move a field to a different step
+ */
+ moveFieldToStep: adminProcedure
+ .input(
+ z.object({
+ fieldId: z.string(),
+ stepId: z.string().nullable(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const field = await ctx.prisma.applicationFormField.update({
+ where: { id: input.fieldId },
+ data: { stepId: input.stepId },
+ })
+
+ return field
+ }),
+
+ /**
+ * Update email settings for a form
+ */
+ updateEmailSettings: adminProcedure
+ .input(
+ z.object({
+ formId: z.string(),
+ sendConfirmationEmail: z.boolean().optional(),
+ sendTeamInviteEmails: z.boolean().optional(),
+ confirmationEmailSubject: z.string().optional().nullable(),
+ confirmationEmailBody: z.string().optional().nullable(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { formId, ...data } = input
+
+ const form = await ctx.prisma.applicationForm.update({
+ where: { id: formId },
+ data,
+ })
+
+ // Audit log
+ await ctx.prisma.auditLog.create({
+ data: {
+ userId: ctx.user.id,
+ action: 'UPDATE_EMAIL_SETTINGS',
+ entityType: 'ApplicationForm',
+ entityId: formId,
+ detailsJson: data,
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ },
+ })
+
+ return form
+ }),
+
+ /**
+ * Link a form to a round (for onboarding forms that create projects)
+ */
+ linkToRound: adminProcedure
+ .input(
+ z.object({
+ formId: z.string(),
+ roundId: z.string().nullable(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ // Check if another form is already linked to this round
+ if (input.roundId) {
+ const existing = await ctx.prisma.applicationForm.findFirst({
+ where: {
+ roundId: input.roundId,
+ NOT: { id: input.formId },
+ },
+ })
+ if (existing) {
+ throw new TRPCError({
+ code: 'CONFLICT',
+ message: 'Another form is already linked to this round',
+ })
+ }
+ }
+
+ const form = await ctx.prisma.applicationForm.update({
+ where: { id: input.formId },
+ data: { roundId: input.roundId },
+ })
+
+ // Audit log
+ await ctx.prisma.auditLog.create({
+ data: {
+ userId: ctx.user.id,
+ action: 'LINK_TO_ROUND',
+ entityType: 'ApplicationForm',
+ entityId: input.formId,
+ detailsJson: { roundId: input.roundId },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ },
+ })
+
+ return form
+ }),
+
+ /**
+ * Get form with steps for onboarding builder
+ */
+ getForBuilder: adminProcedure
+ .input(z.object({ id: z.string() }))
+ .query(async ({ ctx, input }) => {
+ return ctx.prisma.applicationForm.findUniqueOrThrow({
+ where: { id: input.id },
+ include: {
+ program: { select: { id: true, name: true, year: true } },
+ round: { select: { id: true, name: true, slug: true, programId: true } },
+ steps: {
+ orderBy: { sortOrder: 'asc' },
+ include: {
+ fields: { orderBy: { sortOrder: 'asc' } },
+ },
+ },
+ fields: {
+ where: { stepId: null },
+ orderBy: { sortOrder: 'asc' },
+ },
+ _count: { select: { submissions: true } },
+ },
+ })
+ }),
+
+ /**
+ * Get available rounds for linking
+ */
+ getAvailableRounds: adminProcedure
+ .input(z.object({ programId: z.string().optional() }))
+ .query(async ({ ctx, input }) => {
+ // Get rounds that don't have a linked form yet
+ return ctx.prisma.round.findMany({
+ where: {
+ ...(input.programId ? { programId: input.programId } : {}),
+ applicationForm: null,
+ },
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ status: true,
+ program: { select: { id: true, name: true, year: true } },
+ },
+ orderBy: [{ program: { year: 'desc' } }, { sortOrder: 'asc' }],
+ })
+ }),
+
/**
* Get form statistics
*/
diff --git a/src/server/routers/filtering.ts b/src/server/routers/filtering.ts
index e2c9888..5a15226 100644
--- a/src/server/routers/filtering.ts
+++ b/src/server/routers/filtering.ts
@@ -1,10 +1,140 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
-import { Prisma } from '@prisma/client'
+import { Prisma, PrismaClient } from '@prisma/client'
import { router, adminProcedure, protectedProcedure } from '../trpc'
-import { executeFilteringRules } from '../services/ai-filtering'
+import { executeFilteringRules, type ProgressCallback } from '../services/ai-filtering'
import { logAudit } from '../utils/audit'
import { isOpenAIConfigured, testOpenAIConnection } from '@/lib/openai'
+import { prisma } from '@/lib/prisma'
+
+// Background job execution function
+async function runFilteringJob(jobId: string, roundId: string, userId: string) {
+ try {
+ // Update job to running
+ await prisma.filteringJob.update({
+ where: { id: jobId },
+ data: { status: 'RUNNING', startedAt: new Date() },
+ })
+
+ // Get rules
+ const rules = await prisma.filteringRule.findMany({
+ where: { roundId },
+ orderBy: { priority: 'asc' },
+ })
+
+ // Get projects
+ const roundProjectEntries = await prisma.roundProject.findMany({
+ where: { roundId },
+ include: {
+ project: {
+ include: {
+ files: {
+ select: { id: true, fileName: true, fileType: true },
+ },
+ },
+ },
+ },
+ })
+ const projects = roundProjectEntries.map((rp) => rp.project)
+
+ // Calculate batch info
+ const BATCH_SIZE = 20
+ const totalBatches = Math.ceil(projects.length / BATCH_SIZE)
+
+ await prisma.filteringJob.update({
+ where: { id: jobId },
+ data: { totalProjects: projects.length, totalBatches },
+ })
+
+ // Progress callback
+ const onProgress: ProgressCallback = async (progress) => {
+ await prisma.filteringJob.update({
+ where: { id: jobId },
+ data: {
+ currentBatch: progress.currentBatch,
+ processedCount: progress.processedCount,
+ },
+ })
+ }
+
+ // Execute rules
+ const results = await executeFilteringRules(rules, projects, userId, roundId, onProgress)
+
+ // Count outcomes
+ const passedCount = results.filter((r) => r.outcome === 'PASSED').length
+ const filteredCount = results.filter((r) => r.outcome === 'FILTERED_OUT').length
+ const flaggedCount = results.filter((r) => r.outcome === 'FLAGGED').length
+
+ // Upsert results
+ await prisma.$transaction(
+ results.map((r) =>
+ prisma.filteringResult.upsert({
+ where: {
+ roundId_projectId: {
+ roundId,
+ projectId: r.projectId,
+ },
+ },
+ create: {
+ roundId,
+ projectId: r.projectId,
+ outcome: r.outcome,
+ ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
+ aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
+ },
+ update: {
+ outcome: r.outcome,
+ ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
+ aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
+ overriddenBy: null,
+ overriddenAt: null,
+ overrideReason: null,
+ finalOutcome: null,
+ },
+ })
+ )
+ )
+
+ // Mark job as completed
+ await prisma.filteringJob.update({
+ where: { id: jobId },
+ data: {
+ status: 'COMPLETED',
+ completedAt: new Date(),
+ processedCount: projects.length,
+ passedCount,
+ filteredCount,
+ flaggedCount,
+ },
+ })
+
+ // Audit log
+ await logAudit({
+ userId,
+ action: 'UPDATE',
+ entityType: 'Round',
+ entityId: roundId,
+ detailsJson: {
+ action: 'EXECUTE_FILTERING',
+ jobId,
+ projectCount: projects.length,
+ passed: passedCount,
+ filteredOut: filteredCount,
+ flagged: flaggedCount,
+ },
+ })
+ } catch (error) {
+ console.error('[Filtering Job] Error:', error)
+ await prisma.filteringJob.update({
+ where: { id: jobId },
+ data: {
+ status: 'FAILED',
+ completedAt: new Date(),
+ errorMessage: error instanceof Error ? error.message : 'Unknown error',
+ },
+ })
+ }
+}
export const filteringRouter = router({
/**
@@ -168,7 +298,112 @@ export const filteringRouter = router({
}),
/**
- * Execute all filtering rules against projects in a round
+ * Start a filtering job (runs in background)
+ */
+ startJob: adminProcedure
+ .input(z.object({ roundId: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ // Check if there's already a running job
+ const existingJob = await ctx.prisma.filteringJob.findFirst({
+ where: { roundId: input.roundId, status: 'RUNNING' },
+ })
+ if (existingJob) {
+ throw new TRPCError({
+ code: 'CONFLICT',
+ message: 'A filtering job is already running for this round',
+ })
+ }
+
+ // Get rules
+ const rules = await ctx.prisma.filteringRule.findMany({
+ where: { roundId: input.roundId },
+ orderBy: { priority: 'asc' },
+ })
+
+ if (rules.length === 0) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'No filtering rules configured for this round',
+ })
+ }
+
+ // Check AI config if needed
+ const hasAIRules = rules.some((r) => r.ruleType === 'AI_SCREENING' && r.isActive)
+ if (hasAIRules) {
+ const aiConfigured = await isOpenAIConfigured()
+ if (!aiConfigured) {
+ throw new TRPCError({
+ code: 'PRECONDITION_FAILED',
+ message:
+ 'AI screening rules require OpenAI to be configured. Go to Settings → AI to configure your API key.',
+ })
+ }
+ const testResult = await testOpenAIConnection()
+ if (!testResult.success) {
+ throw new TRPCError({
+ code: 'PRECONDITION_FAILED',
+ message: `AI configuration error: ${testResult.error}. Go to Settings → AI to fix.`,
+ })
+ }
+ }
+
+ // Count projects
+ const projectCount = await ctx.prisma.roundProject.count({
+ where: { roundId: input.roundId },
+ })
+ if (projectCount === 0) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'No projects found in this round',
+ })
+ }
+
+ // Create job
+ const job = await ctx.prisma.filteringJob.create({
+ data: {
+ roundId: input.roundId,
+ status: 'PENDING',
+ totalProjects: projectCount,
+ },
+ })
+
+ // Start background execution (non-blocking)
+ setImmediate(() => {
+ runFilteringJob(job.id, input.roundId, ctx.user.id).catch(console.error)
+ })
+
+ return { jobId: job.id, message: 'Filtering job started' }
+ }),
+
+ /**
+ * Get current job status
+ */
+ getJobStatus: protectedProcedure
+ .input(z.object({ jobId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const job = await ctx.prisma.filteringJob.findUnique({
+ where: { id: input.jobId },
+ })
+ if (!job) {
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' })
+ }
+ return job
+ }),
+
+ /**
+ * Get latest job for a round
+ */
+ getLatestJob: protectedProcedure
+ .input(z.object({ roundId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ return ctx.prisma.filteringJob.findFirst({
+ where: { roundId: input.roundId },
+ orderBy: { createdAt: 'desc' },
+ })
+ }),
+
+ /**
+ * Execute all filtering rules against projects in a round (synchronous, legacy)
*/
executeRules: adminProcedure
.input(z.object({ roundId: z.string() }))
diff --git a/src/server/routers/onboarding.ts b/src/server/routers/onboarding.ts
new file mode 100644
index 0000000..2c825bd
--- /dev/null
+++ b/src/server/routers/onboarding.ts
@@ -0,0 +1,398 @@
+import { z } from 'zod'
+import { TRPCError } from '@trpc/server'
+import { Prisma } from '@prisma/client'
+import { router, publicProcedure } from '../trpc'
+import { sendApplicationConfirmationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
+import { nanoid } from 'nanoid'
+
+// Team member input for submission
+const teamMemberInputSchema = z.object({
+ name: z.string().min(1),
+ email: z.string().email().optional(),
+ title: z.string().optional(),
+ role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']).default('MEMBER'),
+})
+
+export const onboardingRouter = router({
+ /**
+ * Get onboarding form configuration for public wizard
+ * Returns form + steps + fields + program info
+ */
+ getConfig: publicProcedure
+ .input(
+ z.object({
+ slug: z.string(), // Round slug or form publicSlug
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ // Try to find by round slug first
+ let form = await ctx.prisma.applicationForm.findFirst({
+ where: {
+ round: { slug: input.slug },
+ status: 'PUBLISHED',
+ isPublic: true,
+ },
+ include: {
+ program: { select: { id: true, name: true, year: true } },
+ round: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ submissionStartDate: true,
+ submissionEndDate: true,
+ submissionDeadline: true,
+ },
+ },
+ steps: {
+ orderBy: { sortOrder: 'asc' },
+ include: {
+ fields: { orderBy: { sortOrder: 'asc' } },
+ },
+ },
+ fields: {
+ where: { stepId: null },
+ orderBy: { sortOrder: 'asc' },
+ },
+ },
+ })
+
+ // If not found by round slug, try form publicSlug
+ if (!form) {
+ form = await ctx.prisma.applicationForm.findFirst({
+ where: {
+ publicSlug: input.slug,
+ status: 'PUBLISHED',
+ isPublic: true,
+ },
+ include: {
+ program: { select: { id: true, name: true, year: true } },
+ round: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ submissionStartDate: true,
+ submissionEndDate: true,
+ submissionDeadline: true,
+ },
+ },
+ steps: {
+ orderBy: { sortOrder: 'asc' },
+ include: {
+ fields: { orderBy: { sortOrder: 'asc' } },
+ },
+ },
+ fields: {
+ where: { stepId: null },
+ orderBy: { sortOrder: 'asc' },
+ },
+ },
+ })
+ }
+
+ if (!form) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Application form not found or not accepting submissions',
+ })
+ }
+
+ // Check submission window
+ const now = new Date()
+ const startDate = form.round?.submissionStartDate || form.opensAt
+ const endDate = form.round?.submissionEndDate || form.round?.submissionDeadline || form.closesAt
+
+ if (startDate && now < startDate) {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ message: 'Applications are not yet open',
+ })
+ }
+
+ if (endDate && now > endDate) {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ message: 'Applications have closed',
+ })
+ }
+
+ // Check submission limit
+ if (form.submissionLimit) {
+ const count = await ctx.prisma.applicationFormSubmission.count({
+ where: { formId: form.id },
+ })
+ if (count >= form.submissionLimit) {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ message: 'This form has reached its submission limit',
+ })
+ }
+ }
+
+ return {
+ form: {
+ id: form.id,
+ name: form.name,
+ description: form.description,
+ confirmationMessage: form.confirmationMessage,
+ },
+ program: form.program,
+ round: form.round,
+ steps: form.steps,
+ orphanFields: form.fields, // Fields not assigned to any step
+ }
+ }),
+
+ /**
+ * Submit an application through the onboarding wizard
+ * Creates Project, TeamMembers, and sends emails
+ */
+ submit: publicProcedure
+ .input(
+ z.object({
+ formId: z.string(),
+ // Contact info
+ contactName: z.string().min(1),
+ contactEmail: z.string().email(),
+ contactPhone: z.string().optional(),
+ // Project info
+ projectName: z.string().min(1),
+ description: z.string().optional(),
+ competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
+ oceanIssue: z
+ .enum([
+ 'POLLUTION_REDUCTION',
+ 'CLIMATE_MITIGATION',
+ 'TECHNOLOGY_INNOVATION',
+ 'SUSTAINABLE_SHIPPING',
+ 'BLUE_CARBON',
+ 'HABITAT_RESTORATION',
+ 'COMMUNITY_CAPACITY',
+ 'SUSTAINABLE_FISHING',
+ 'CONSUMER_AWARENESS',
+ 'OCEAN_ACIDIFICATION',
+ 'OTHER',
+ ])
+ .optional(),
+ country: z.string().optional(),
+ institution: z.string().optional(),
+ teamName: z.string().optional(),
+ wantsMentorship: z.boolean().optional(),
+ referralSource: z.string().optional(),
+ foundedAt: z.string().datetime().optional(),
+ // Team members
+ teamMembers: z.array(teamMemberInputSchema).optional(),
+ // Additional metadata (unmapped fields)
+ metadata: z.record(z.unknown()).optional(),
+ // GDPR consent
+ gdprConsent: z.boolean(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ if (!input.gdprConsent) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'You must accept the terms and conditions to submit',
+ })
+ }
+
+ // Get form with round info
+ const form = await ctx.prisma.applicationForm.findUniqueOrThrow({
+ where: { id: input.formId },
+ include: {
+ round: true,
+ program: true,
+ },
+ })
+
+ // Verify form is accepting submissions
+ if (!form.isPublic || form.status !== 'PUBLISHED') {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ message: 'This form is not accepting submissions',
+ })
+ }
+
+ // Check if we need a round/program for project creation
+ const programId = form.round?.programId || form.programId
+ if (!programId) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'This form is not linked to a program',
+ })
+ }
+
+ // Create or find user for contact email
+ let contactUser = await ctx.prisma.user.findUnique({
+ where: { email: input.contactEmail },
+ })
+
+ if (!contactUser) {
+ contactUser = await ctx.prisma.user.create({
+ data: {
+ email: input.contactEmail,
+ name: input.contactName,
+ role: 'APPLICANT',
+ status: 'ACTIVE',
+ },
+ })
+ }
+
+ // Create project
+ const project = await ctx.prisma.project.create({
+ data: {
+ programId,
+ title: input.projectName,
+ description: input.description,
+ teamName: input.teamName || input.projectName,
+ competitionCategory: input.competitionCategory,
+ oceanIssue: input.oceanIssue,
+ country: input.country,
+ institution: input.institution,
+ wantsMentorship: input.wantsMentorship ?? false,
+ referralSource: input.referralSource,
+ foundedAt: input.foundedAt ? new Date(input.foundedAt) : null,
+ submissionSource: 'PUBLIC_FORM',
+ submittedByEmail: input.contactEmail,
+ submittedByUserId: contactUser.id,
+ submittedAt: new Date(),
+ metadataJson: input.metadata as Prisma.InputJsonValue ?? {},
+ },
+ })
+
+ // Create RoundProject entry if form is linked to a round
+ if (form.roundId) {
+ await ctx.prisma.roundProject.create({
+ data: {
+ roundId: form.roundId,
+ projectId: project.id,
+ status: 'SUBMITTED',
+ },
+ })
+ }
+
+ // Create TeamMember for contact as LEAD
+ await ctx.prisma.teamMember.create({
+ data: {
+ projectId: project.id,
+ userId: contactUser.id,
+ role: 'LEAD',
+ title: 'Team Lead',
+ },
+ })
+
+ // Process additional team members
+ const invitePromises: Promise[] = []
+
+ if (input.teamMembers && input.teamMembers.length > 0) {
+ for (const member of input.teamMembers) {
+ // Skip if same email as contact
+ if (member.email === input.contactEmail) continue
+
+ let memberUser = member.email
+ ? await ctx.prisma.user.findUnique({ where: { email: member.email } })
+ : null
+
+ if (member.email && !memberUser) {
+ // Create user with invite token
+ const inviteToken = nanoid(32)
+ const inviteTokenExpiresAt = new Date()
+ inviteTokenExpiresAt.setDate(inviteTokenExpiresAt.getDate() + 30) // 30 days
+
+ memberUser = await ctx.prisma.user.create({
+ data: {
+ email: member.email,
+ name: member.name,
+ role: 'APPLICANT',
+ status: 'INVITED',
+ inviteToken,
+ inviteTokenExpiresAt,
+ },
+ })
+
+ // Queue invite email
+ if (form.sendTeamInviteEmails) {
+ const inviteUrl = `${process.env.NEXTAUTH_URL || ''}/accept-invite?token=${inviteToken}`
+ invitePromises.push(
+ sendTeamMemberInviteEmail(
+ member.email,
+ member.name,
+ input.projectName,
+ input.contactName,
+ inviteUrl
+ ).catch((err) => {
+ console.error(`Failed to send invite email to ${member.email}:`, err)
+ })
+ )
+ }
+ }
+
+ // Create team member if we have a user
+ if (memberUser) {
+ await ctx.prisma.teamMember.create({
+ data: {
+ projectId: project.id,
+ userId: memberUser.id,
+ role: member.role,
+ title: member.title,
+ },
+ })
+ }
+ }
+ }
+
+ // Create form submission record
+ await ctx.prisma.applicationFormSubmission.create({
+ data: {
+ formId: input.formId,
+ email: input.contactEmail,
+ name: input.contactName,
+ dataJson: input as unknown as Prisma.InputJsonValue,
+ status: 'SUBMITTED',
+ },
+ })
+
+ // Send confirmation email
+ if (form.sendConfirmationEmail) {
+ const programName = form.program?.name || form.round?.name || 'the program'
+ try {
+ await sendApplicationConfirmationEmail(
+ input.contactEmail,
+ input.contactName,
+ input.projectName,
+ programName,
+ form.confirmationEmailBody || form.confirmationMessage || undefined
+ )
+ } catch (err) {
+ console.error('Failed to send confirmation email:', err)
+ }
+ }
+
+ // Wait for invite emails (don't block on failure)
+ await Promise.allSettled(invitePromises)
+
+ // Audit log
+ await ctx.prisma.auditLog.create({
+ data: {
+ userId: contactUser.id,
+ action: 'SUBMIT_APPLICATION',
+ entityType: 'Project',
+ entityId: project.id,
+ detailsJson: {
+ formId: input.formId,
+ projectName: input.projectName,
+ teamMemberCount: (input.teamMembers?.length || 0) + 1,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ },
+ })
+
+ return {
+ success: true,
+ projectId: project.id,
+ confirmationMessage: form.confirmationMessage,
+ }
+ }),
+})
diff --git a/src/server/services/ai-filtering.ts b/src/server/services/ai-filtering.ts
index 0c17950..de468b1 100644
--- a/src/server/services/ai-filtering.ts
+++ b/src/server/services/ai-filtering.ts
@@ -22,7 +22,16 @@ import {
type AnonymizedProjectForAI,
type ProjectAIMapping,
} from './anonymization'
-import type { Prisma, FileType, SubmissionSource } from '@prisma/client'
+import type { Prisma, FileType, SubmissionSource, PrismaClient } from '@prisma/client'
+
+// ─── Progress Callback Type ─────────────────────────────────────────────────
+
+export type ProgressCallback = (progress: {
+ currentBatch: number
+ totalBatches: number
+ processedCount: number
+ tokensUsed?: number
+}) => Promise
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -410,7 +419,8 @@ export async function executeAIScreening(
config: AIScreeningConfig,
projects: ProjectForFiltering[],
userId?: string,
- entityId?: string
+ entityId?: string,
+ onProgress?: ProgressCallback
): Promise> {
const results = new Map()
@@ -444,13 +454,15 @@ export async function executeAIScreening(
}
let totalTokens = 0
+ const totalBatches = Math.ceil(anonymized.length / BATCH_SIZE)
// Process in batches
for (let i = 0; i < anonymized.length; i += BATCH_SIZE) {
const batchAnon = anonymized.slice(i, i + BATCH_SIZE)
const batchMappings = mappings.slice(i, i + BATCH_SIZE)
+ const currentBatch = Math.floor(i / BATCH_SIZE) + 1
- console.log(`[AI Filtering] Processing batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(anonymized.length / BATCH_SIZE)}`)
+ console.log(`[AI Filtering] Processing batch ${currentBatch}/${totalBatches}`)
const { results: batchResults, tokensUsed } = await processAIBatch(
openai,
@@ -468,6 +480,16 @@ export async function executeAIScreening(
for (const [id, result] of batchResults) {
results.set(id, result)
}
+
+ // Report progress
+ if (onProgress) {
+ await onProgress({
+ currentBatch,
+ totalBatches,
+ processedCount: Math.min((currentBatch) * BATCH_SIZE, anonymized.length),
+ tokensUsed: totalTokens,
+ })
+ }
}
console.log(`[AI Filtering] Completed. Total tokens: ${totalTokens}`)
@@ -513,7 +535,8 @@ export async function executeFilteringRules(
rules: FilteringRuleInput[],
projects: ProjectForFiltering[],
userId?: string,
- roundId?: string
+ roundId?: string,
+ onProgress?: ProgressCallback
): Promise {
const activeRules = rules
.filter((r) => r.isActive)
@@ -528,7 +551,7 @@ export async function executeFilteringRules(
for (const aiRule of aiRules) {
const config = aiRule.configJson as unknown as AIScreeningConfig
- const screeningResults = await executeAIScreening(config, projects, userId, roundId)
+ const screeningResults = await executeAIScreening(config, projects, userId, roundId, onProgress)
aiResults.set(aiRule.id, screeningResults)
}