@@ -338,6 +429,11 @@ export default function OnboardingPage() {
Name: {name}
+ {country && (
+
+ Country: {country}
+
+ )}
{whatsappEnabled && phoneNumber && (
Phone: {' '}
@@ -375,7 +471,7 @@ export default function OnboardingPage() {
>
)}
- {/* Step 5: Complete */}
+ {/* Step 7: Complete */}
{step === 'complete' && (
diff --git a/src/app/(mentor)/mentor/page.tsx b/src/app/(mentor)/mentor/page.tsx
index bf7135b..68951e1 100644
--- a/src/app/(mentor)/mentor/page.tsx
+++ b/src/app/(mentor)/mentor/page.tsx
@@ -149,22 +149,22 @@ export default function MentorDashboard() {
- {project.program.year} Edition
+ {project.round?.program?.year} Edition
- {project.roundProjects?.[0]?.round && (
+ {project.round && (
<>
•
- {project.roundProjects[0].round.name}
+ {project.round.name}
>
)}
{project.title}
- {project.roundProjects?.[0]?.status && (
+ {project.status && (
- {project.roundProjects[0].status.replace('_', ' ')}
+ {project.status.replace('_', ' ')}
)}
diff --git a/src/app/(mentor)/mentor/projects/[id]/page.tsx b/src/app/(mentor)/mentor/projects/[id]/page.tsx
index 3683750..e46ce87 100644
--- a/src/app/(mentor)/mentor/projects/[id]/page.tsx
+++ b/src/app/(mentor)/mentor/projects/[id]/page.tsx
@@ -109,12 +109,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
- {project.program.year} Edition
+ {project.round?.program?.year} Edition
- {project.roundProjects?.[0]?.round && (
+ {project.round && (
<>
•
- {project.roundProjects[0].round.name}
+ {project.round.name}
>
)}
@@ -122,9 +122,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{project.title}
- {project.roundProjects?.[0]?.status && (
-
- {project.roundProjects[0].status.replace('_', ' ')}
+ {project.status && (
+
+ {project.status.replace('_', ' ')}
)}
diff --git a/src/app/(mentor)/mentor/projects/page.tsx b/src/app/(mentor)/mentor/projects/page.tsx
index 25a4222..6096dc5 100644
--- a/src/app/(mentor)/mentor/projects/page.tsx
+++ b/src/app/(mentor)/mentor/projects/page.tsx
@@ -94,20 +94,20 @@ export default function MentorProjectsPage() {
- {project.program.year} Edition
+ {project.round?.program?.year} Edition
- {project.roundProjects?.[0]?.round && (
+ {project.round && (
<>
•
- {project.roundProjects[0].round.name}
+ {project.round.name}
>
)}
{project.title}
- {project.roundProjects?.[0]?.status && (
-
- {project.roundProjects[0].status.replace('_', ' ')}
+ {project.status && (
+
+ {project.status.replace('_', ' ')}
)}
diff --git a/src/app/(observer)/observer/page.tsx b/src/app/(observer)/observer/page.tsx
index 4df8d97..3dfd232 100644
--- a/src/app/(observer)/observer/page.tsx
+++ b/src/app/(observer)/observer/page.tsx
@@ -48,7 +48,7 @@ async function ObserverDashboardContent() {
program: { select: { name: true, year: true } },
_count: {
select: {
- roundProjects: true,
+ projects: true,
assignments: true,
},
},
@@ -176,7 +176,7 @@ async function ObserverDashboardContent() {
-
{round._count.roundProjects} projects
+
{round._count.projects} projects
{round._count.assignments} assignments
diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx
index dd12986..1259943 100644
--- a/src/app/(observer)/observer/reports/page.tsx
+++ b/src/app/(observer)/observer/reports/page.tsx
@@ -34,7 +34,7 @@ async function ReportsContent() {
},
_count: {
select: {
- roundProjects: true,
+ projects: true,
assignments: true,
},
},
@@ -70,7 +70,7 @@ async function ReportsContent() {
})
// Calculate totals
- const totalProjects = roundStats.reduce((acc, r) => acc + r._count.roundProjects, 0)
+ const totalProjects = roundStats.reduce((acc, r) => acc + r._count.projects, 0)
const totalAssignments = roundStats.reduce(
(acc, r) => acc + r.totalAssignments,
0
@@ -176,7 +176,7 @@ async function ReportsContent() {
{round.program.name}
-
{round._count.roundProjects}
+
{round._count.projects}
@@ -237,7 +237,7 @@ async function ReportsContent() {
)}
-
{round._count.roundProjects} projects
+
{round._count.projects} projects
{round.completedEvaluations}/{round.totalAssignments} evaluations
diff --git a/src/app/(public)/apply-wizard/[slug]/page.tsx b/src/app/(public)/apply-wizard/[slug]/page.tsx
deleted file mode 100644
index 7b29e2b..0000000
--- a/src/app/(public)/apply-wizard/[slug]/page.tsx
+++ /dev/null
@@ -1,423 +0,0 @@
-'use client'
-
-import { useState, useEffect } from 'react'
-import { useParams, useRouter } from 'next/navigation'
-import { useForm } from 'react-hook-form'
-import { zodResolver } from '@hookform/resolvers/zod'
-import { z } from 'zod'
-import { motion, AnimatePresence } from 'motion/react'
-import { trpc } from '@/lib/trpc/client'
-import { toast } from 'sonner'
-import {
- Waves,
- AlertCircle,
- Loader2,
- CheckCircle,
- ArrowLeft,
- ArrowRight,
- Clock,
-} from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { Skeleton } from '@/components/ui/skeleton'
-import {
- StepWelcome,
- StepContact,
- StepProject,
- StepTeam,
- StepAdditional,
- StepReview,
-} from '@/components/forms/apply-steps'
-import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
-import { cn } from '@/lib/utils'
-
-// Form validation schema
-const teamMemberSchema = z.object({
- name: z.string().min(1, 'Name is required'),
- email: z.string().email('Invalid email address'),
- role: z.nativeEnum(TeamMemberRole).default('MEMBER'),
- title: z.string().optional(),
-})
-
-const applicationSchema = z.object({
- competitionCategory: z.nativeEnum(CompetitionCategory),
- contactName: z.string().min(2, 'Full name is required'),
- contactEmail: z.string().email('Invalid email address'),
- contactPhone: z.string().min(5, 'Phone number is required'),
- country: z.string().min(2, 'Country is required'),
- city: z.string().optional(),
- projectName: z.string().min(2, 'Project name is required').max(200),
- teamName: z.string().optional(),
- description: z.string().min(20, 'Description must be at least 20 characters'),
- oceanIssue: z.nativeEnum(OceanIssue),
- teamMembers: z.array(teamMemberSchema).optional(),
- institution: z.string().optional(),
- startupCreatedDate: z.string().optional(),
- wantsMentorship: z.boolean().default(false),
- referralSource: z.string().optional(),
- gdprConsent: z.boolean().refine((val) => val === true, {
- message: 'You must agree to the data processing terms',
- }),
-})
-
-type ApplicationFormData = z.infer
-
-const STEPS = [
- { id: 'welcome', title: 'Category', fields: ['competitionCategory'] },
- { id: 'contact', title: 'Contact', fields: ['contactName', 'contactEmail', 'contactPhone', 'country'] },
- { id: 'project', title: 'Project', fields: ['projectName', 'description', 'oceanIssue'] },
- { id: 'team', title: 'Team', fields: [] },
- { id: 'additional', title: 'Details', fields: [] },
- { id: 'review', title: 'Review', fields: ['gdprConsent'] },
-]
-
-export default function ApplyWizardPage() {
- const params = useParams()
- const router = useRouter()
- const slug = params.slug as string
-
- const [currentStep, setCurrentStep] = useState(0)
- const [direction, setDirection] = useState(0)
- const [submitted, setSubmitted] = useState(false)
- const [submissionMessage, setSubmissionMessage] = useState('')
-
- const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
- { roundSlug: slug },
- { retry: false }
- )
-
- const submitMutation = trpc.application.submit.useMutation({
- onSuccess: (result) => {
- setSubmitted(true)
- setSubmissionMessage(result.message)
- },
- onError: (error) => {
- toast.error(error.message)
- },
- })
-
- const form = useForm({
- resolver: zodResolver(applicationSchema),
- defaultValues: {
- competitionCategory: undefined,
- contactName: '',
- contactEmail: '',
- contactPhone: '',
- country: '',
- city: '',
- projectName: '',
- teamName: '',
- description: '',
- oceanIssue: undefined,
- teamMembers: [],
- institution: '',
- startupCreatedDate: '',
- wantsMentorship: false,
- referralSource: '',
- gdprConsent: false,
- },
- mode: 'onChange',
- })
-
- const { watch, trigger, handleSubmit } = form
- const competitionCategory = watch('competitionCategory')
-
- const isBusinessConcept = competitionCategory === 'BUSINESS_CONCEPT'
- const isStartup = competitionCategory === 'STARTUP'
-
- const validateCurrentStep = async () => {
- const currentFields = STEPS[currentStep].fields as (keyof ApplicationFormData)[]
- if (currentFields.length === 0) return true
- return await trigger(currentFields)
- }
-
- const nextStep = async () => {
- const isValid = await validateCurrentStep()
- if (isValid && currentStep < STEPS.length - 1) {
- setDirection(1)
- setCurrentStep((prev) => prev + 1)
- }
- }
-
- const prevStep = () => {
- if (currentStep > 0) {
- setDirection(-1)
- setCurrentStep((prev) => prev - 1)
- }
- }
-
- const onSubmit = async (data: ApplicationFormData) => {
- if (!config) return
- await submitMutation.mutateAsync({
- roundId: config.round.id,
- data,
- })
- }
-
- // Handle keyboard navigation
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey && currentStep < STEPS.length - 1) {
- e.preventDefault()
- nextStep()
- }
- }
-
- window.addEventListener('keydown', handleKeyDown)
- return () => window.removeEventListener('keydown', handleKeyDown)
- }, [currentStep])
-
- // Loading state
- if (isLoading) {
- return (
-
-
-
-
- Loading application...
-
-
-
- )
- }
-
- // Error state
- if (error) {
- return (
-
-
-
-
Application Not Available
-
{error.message}
-
router.push('/')}>
- Return Home
-
-
-
- )
- }
-
- // Applications closed state
- if (config && !config.round.isOpen) {
- return (
-
-
-
-
Applications Closed
-
- The application period for {config.program.name} {config.program.year} has ended.
- {config.round.submissionEndDate && (
-
- Submissions closed on{' '}
- {new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
- dateStyle: 'long',
- })}
-
- )}
-
-
router.push('/')}>
- Return Home
-
-
-
- )
- }
-
- // Success state
- if (submitted) {
- return (
-
-
-
-
-
- Application Submitted!
- {submissionMessage}
- router.push('/')}>
- Return Home
-
-
-
- )
- }
-
- if (!config) return null
-
- const progress = ((currentStep + 1) / STEPS.length) * 100
-
- const variants = {
- enter: (direction: number) => ({
- x: direction > 0 ? 50 : -50,
- opacity: 0,
- }),
- center: {
- x: 0,
- opacity: 1,
- },
- exit: (direction: number) => ({
- x: direction < 0 ? 50 : -50,
- opacity: 0,
- }),
- }
-
- return (
-
- {/* Header */}
-
-
-
-
-
-
-
-
-
{config.program.name}
-
{config.program.year} Application
-
-
-
-
- Step {currentStep + 1} of {STEPS.length}
-
-
-
-
- {/* Progress bar */}
-
-
-
-
- {/* Step indicators */}
-
- {STEPS.map((step, index) => (
- {
- if (index < currentStep) {
- setDirection(index < currentStep ? -1 : 1)
- setCurrentStep(index)
- }
- }}
- disabled={index > currentStep}
- className={cn(
- 'hidden text-xs font-medium transition-colors sm:block',
- index === currentStep && 'text-primary',
- index < currentStep && 'text-muted-foreground hover:text-foreground cursor-pointer',
- index > currentStep && 'text-muted-foreground/50'
- )}
- >
- {step.title}
-
- ))}
-
-
-
-
- {/* Main content */}
-
-
-
-
- {/* Footer with deadline info */}
- {config.round.submissionEndDate && (
-
-
-
- Applications due by{' '}
- {new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
- dateStyle: 'long',
- })}
-
-
- )}
-
- )
-}
diff --git a/src/app/(public)/apply/[slug]/page.tsx b/src/app/(public)/apply/[slug]/page.tsx
index ba6d1ea..7b29e2b 100644
--- a/src/app/(public)/apply/[slug]/page.tsx
+++ b/src/app/(public)/apply/[slug]/page.tsx
@@ -1,430 +1,423 @@
'use client'
import { useState, useEffect } from 'react'
-import { useParams } from 'next/navigation'
+import { useParams, useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
+import { motion, AnimatePresence } from 'motion/react'
import { trpc } from '@/lib/trpc/client'
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} 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 { toast } from 'sonner'
-import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
+import {
+ Waves,
+ AlertCircle,
+ Loader2,
+ CheckCircle,
+ ArrowLeft,
+ ArrowRight,
+ Clock,
+} from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ StepWelcome,
+ StepContact,
+ StepProject,
+ StepTeam,
+ StepAdditional,
+ StepReview,
+} from '@/components/forms/apply-steps'
+import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
+import { cn } from '@/lib/utils'
-type FormField = {
- 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: Array<{ value: string; label: string }> | null
- conditionJson: { fieldId: string; operator: string; value?: string } | null
- width: string
-}
+// Form validation schema
+const teamMemberSchema = z.object({
+ name: z.string().min(1, 'Name is required'),
+ email: z.string().email('Invalid email address'),
+ role: z.nativeEnum(TeamMemberRole).default('MEMBER'),
+ title: z.string().optional(),
+})
-export default function PublicFormPage() {
+const applicationSchema = z.object({
+ competitionCategory: z.nativeEnum(CompetitionCategory),
+ contactName: z.string().min(2, 'Full name is required'),
+ contactEmail: z.string().email('Invalid email address'),
+ contactPhone: z.string().min(5, 'Phone number is required'),
+ country: z.string().min(2, 'Country is required'),
+ city: z.string().optional(),
+ projectName: z.string().min(2, 'Project name is required').max(200),
+ teamName: z.string().optional(),
+ description: z.string().min(20, 'Description must be at least 20 characters'),
+ oceanIssue: z.nativeEnum(OceanIssue),
+ teamMembers: z.array(teamMemberSchema).optional(),
+ institution: z.string().optional(),
+ startupCreatedDate: z.string().optional(),
+ wantsMentorship: z.boolean().default(false),
+ referralSource: z.string().optional(),
+ gdprConsent: z.boolean().refine((val) => val === true, {
+ message: 'You must agree to the data processing terms',
+ }),
+})
+
+type ApplicationFormData = z.infer
+
+const STEPS = [
+ { id: 'welcome', title: 'Category', fields: ['competitionCategory'] },
+ { id: 'contact', title: 'Contact', fields: ['contactName', 'contactEmail', 'contactPhone', 'country'] },
+ { id: 'project', title: 'Project', fields: ['projectName', 'description', 'oceanIssue'] },
+ { id: 'team', title: 'Team', fields: [] },
+ { id: 'additional', title: 'Details', fields: [] },
+ { id: 'review', title: 'Review', fields: ['gdprConsent'] },
+]
+
+export default function ApplyWizardPage() {
const params = useParams()
+ const router = useRouter()
const slug = params.slug as string
- const [submitted, setSubmitted] = useState(false)
- const [confirmationMessage, setConfirmationMessage] = useState(null)
- const { data: form, isLoading, error } = trpc.applicationForm.getBySlug.useQuery(
- { slug },
+ const [currentStep, setCurrentStep] = useState(0)
+ const [direction, setDirection] = useState(0)
+ const [submitted, setSubmitted] = useState(false)
+ const [submissionMessage, setSubmissionMessage] = useState('')
+
+ const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
+ { roundSlug: slug },
{ retry: false }
)
- const submitMutation = trpc.applicationForm.submit.useMutation({
+ const submitMutation = trpc.application.submit.useMutation({
onSuccess: (result) => {
setSubmitted(true)
- setConfirmationMessage(result.confirmationMessage || null)
+ setSubmissionMessage(result.message)
},
onError: (error) => {
toast.error(error.message)
},
})
- const {
- register,
- handleSubmit,
- watch,
- formState: { errors, isSubmitting },
- setValue,
- } = useForm()
+ const form = useForm({
+ resolver: zodResolver(applicationSchema),
+ defaultValues: {
+ competitionCategory: undefined,
+ contactName: '',
+ contactEmail: '',
+ contactPhone: '',
+ country: '',
+ city: '',
+ projectName: '',
+ teamName: '',
+ description: '',
+ oceanIssue: undefined,
+ teamMembers: [],
+ institution: '',
+ startupCreatedDate: '',
+ wantsMentorship: false,
+ referralSource: '',
+ gdprConsent: false,
+ },
+ mode: 'onChange',
+ })
- const watchedValues = watch()
+ const { watch, trigger, handleSubmit } = form
+ const competitionCategory = watch('competitionCategory')
- const onSubmit = async (data: Record) => {
- if (!form) return
+ const isBusinessConcept = competitionCategory === 'BUSINESS_CONCEPT'
+ const isStartup = competitionCategory === 'STARTUP'
- // Extract email and name if present
- const emailField = form.fields.find((f) => f.fieldType === 'EMAIL')
- const email = emailField ? (data[emailField.name] as string) : undefined
+ const validateCurrentStep = async () => {
+ const currentFields = STEPS[currentStep].fields as (keyof ApplicationFormData)[]
+ if (currentFields.length === 0) return true
+ return await trigger(currentFields)
+ }
- // Find a name field (common patterns)
- const nameField = form.fields.find(
- (f) => f.name.toLowerCase().includes('name') && f.fieldType === 'TEXT'
- )
- const name = nameField ? (data[nameField.name] as string) : undefined
+ const nextStep = async () => {
+ const isValid = await validateCurrentStep()
+ if (isValid && currentStep < STEPS.length - 1) {
+ setDirection(1)
+ setCurrentStep((prev) => prev + 1)
+ }
+ }
+ const prevStep = () => {
+ if (currentStep > 0) {
+ setDirection(-1)
+ setCurrentStep((prev) => prev - 1)
+ }
+ }
+
+ const onSubmit = async (data: ApplicationFormData) => {
+ if (!config) return
await submitMutation.mutateAsync({
- formId: form.id,
+ roundId: config.round.id,
data,
- email,
- name,
})
}
+ // Handle keyboard navigation
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey && currentStep < STEPS.length - 1) {
+ e.preventDefault()
+ nextStep()
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [currentStep])
+
+ // Loading state
if (isLoading) {
return (
-
-
-
-
-
-
-
- {[1, 2, 3, 4].map((i) => (
-
-
-
-
- ))}
-
-
+
+
+
+
+ Loading application...
+
+
)
}
+ // Error state
if (error) {
return (
-
-
-
-
- Form Not Available
-
- {error.message}
-
-
-
+
+
+
+
Application Not Available
+
{error.message}
+
router.push('/')}>
+ Return Home
+
+
)
}
+ // Applications closed state
+ if (config && !config.round.isOpen) {
+ return (
+
+
+
+
Applications Closed
+
+ The application period for {config.program.name} {config.program.year} has ended.
+ {config.round.submissionEndDate && (
+
+ Submissions closed on{' '}
+ {new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
+ dateStyle: 'long',
+ })}
+
+ )}
+
+
router.push('/')}>
+ Return Home
+
+
+
+ )
+ }
+
+ // Success state
if (submitted) {
return (
-
-
-
-
- Thank You!
-
- {confirmationMessage || 'Your submission has been received.'}
-
-
-
+
+
+
+
+
+ Application Submitted!
+ {submissionMessage}
+ router.push('/')}>
+ Return Home
+
+
)
}
- if (!form) return null
+ if (!config) return null
- // Check if a field should be visible based on conditions
- const isFieldVisible = (field: FormField): boolean => {
- if (!field.conditionJson) return true
+ const progress = ((currentStep + 1) / STEPS.length) * 100
- const condition = field.conditionJson
- const dependentValue = watchedValues[form.fields.find((f) => f.id === condition.fieldId)?.name || '']
-
- switch (condition.operator) {
- case 'equals':
- return dependentValue === condition.value
- case 'not_equals':
- return dependentValue !== condition.value
- case 'not_empty':
- return !!dependentValue && dependentValue !== ''
- case 'contains':
- return typeof dependentValue === 'string' && dependentValue.includes(condition.value || '')
- default:
- return true
- }
- }
-
- const renderField = (field: FormField) => {
- if (!isFieldVisible(field)) return null
-
- const fieldError = errors[field.name]
- const errorMessage = fieldError?.message as string | undefined
-
- switch (field.fieldType) {
- case 'SECTION':
- return (
-
-
{field.label}
- {field.description && (
-
{field.description}
- )}
-
- )
-
- case 'INSTRUCTIONS':
- return (
-
-
-
{field.description || field.label}
-
-
- )
-
- case 'TEXT':
- case 'EMAIL':
- case 'PHONE':
- case 'URL':
- return (
-
-
- {field.label}
- {field.required && * }
-
- {field.description && (
-
{field.description}
- )}
-
- {errorMessage &&
{errorMessage}
}
-
- )
-
- case 'NUMBER':
- 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 'DATE':
- case 'DATETIME':
- return (
-
-
- {field.label}
- {field.required && * }
-
- {field.description && (
-
{field.description}
- )}
-
- {errorMessage &&
{errorMessage}
}
-
- )
-
- case 'SELECT':
- return (
-
-
- {field.label}
- {field.required && * }
-
- {field.description && (
-
{field.description}
- )}
-
setValue(field.name, value)}
- >
-
-
-
-
- {(field.optionsJson || []).map((option) => (
-
- {option.label}
-
- ))}
-
-
-
- {errorMessage &&
{errorMessage}
}
-
- )
-
- case 'RADIO':
- return (
-
-
- {field.label}
- {field.required && * }
-
- {field.description && (
-
{field.description}
- )}
-
setValue(field.name, value)}
- className="mt-2"
- >
- {(field.optionsJson || []).map((option) => (
-
-
-
- {option.label}
-
-
- ))}
-
-
- {errorMessage &&
{errorMessage}
}
-
- )
-
- case 'CHECKBOX':
- return (
-
-
- setValue(field.name, checked)}
- />
-
- {field.label}
- {field.required && * }
-
-
- {field.description && (
-
{field.description}
- )}
-
value === true || `${field.label} is required` : undefined,
- })}
- />
- {errorMessage &&
{errorMessage}
}
-
- )
-
- default:
- return null
- }
+ const variants = {
+ enter: (direction: number) => ({
+ x: direction > 0 ? 50 : -50,
+ opacity: 0,
+ }),
+ center: {
+ x: 0,
+ opacity: 1,
+ },
+ exit: (direction: number) => ({
+ x: direction < 0 ? 50 : -50,
+ opacity: 0,
+ }),
}
return (
-
-
-
- {form.name}
- {form.description && (
- {form.description}
- )}
-
-
-
diff --git a/src/app/(public)/my-submission/my-submission-client.tsx b/src/app/(public)/my-submission/my-submission-client.tsx
index fcd4b45..218efd6 100644
--- a/src/app/(public)/my-submission/my-submission-client.tsx
+++ b/src/app/(public)/my-submission/my-submission-client.tsx
@@ -132,10 +132,9 @@ export function MySubmissionClient() {
) : (
{submissions.map((project) => {
- const latestRoundProject = project.roundProjects?.[0]
- const projectStatus = latestRoundProject?.status ?? 'SUBMITTED'
- const roundName = latestRoundProject?.round?.name
- const programYear = latestRoundProject?.round?.program?.year
+ const projectStatus = project.status ?? 'SUBMITTED'
+ const roundName = project.round?.name
+ const programYear = project.round?.program?.year
return (
diff --git a/src/components/admin/advance-projects-dialog.tsx b/src/components/admin/advance-projects-dialog.tsx
index 9e94811..4910be4 100644
--- a/src/components/admin/advance-projects-dialog.tsx
+++ b/src/components/admin/advance-projects-dialog.tsx
@@ -240,7 +240,7 @@ export function AdvanceProjectsDialog({
- {(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
+ {(project.status ?? 'SUBMITTED').replace('_', ' ')}
diff --git a/src/components/admin/remove-projects-dialog.tsx b/src/components/admin/remove-projects-dialog.tsx
index 2eb90dd..5462a24 100644
--- a/src/components/admin/remove-projects-dialog.tsx
+++ b/src/components/admin/remove-projects-dialog.tsx
@@ -187,7 +187,7 @@ export function RemoveProjectsDialog({
- {(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
+ {(project.status ?? 'SUBMITTED').replace('_', ' ')}
diff --git a/src/components/layouts/admin-sidebar.tsx b/src/components/layouts/admin-sidebar.tsx
index 573bedc..e203291 100644
--- a/src/components/layouts/admin-sidebar.tsx
+++ b/src/components/layouts/admin-sidebar.tsx
@@ -28,7 +28,6 @@ import {
ChevronRight,
BookOpen,
Handshake,
- FileText,
CircleDot,
History,
Trophy,
@@ -91,11 +90,6 @@ const navigation = [
href: '/admin/partners' as const,
icon: Handshake,
},
- {
- name: 'Onboarding',
- href: '/admin/onboarding' as const,
- icon: FileText,
- },
]
// Admin-only navigation
diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts
index f156e50..94140ed 100644
--- a/src/server/routers/_app.ts
+++ b/src/server/routers/_app.ts
@@ -15,8 +15,6 @@ import { learningResourceRouter } from './learningResource'
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'
@@ -52,8 +50,6 @@ export const appRouter = router({
partner: partnerRouter,
notionImport: notionImportRouter,
typeformImport: typeformImportRouter,
- applicationForm: applicationFormRouter,
- onboarding: onboardingRouter,
// Phase 2B routers
tag: tagRouter,
applicant: applicantRouter,
diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts
index 1974f85..c4c7cd1 100644
--- a/src/server/routers/analytics.ts
+++ b/src/server/routers/analytics.ts
@@ -148,17 +148,13 @@ export const analyticsRouter = router({
getProjectRankings: adminProcedure
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
.query(async ({ ctx, input }) => {
- const roundProjects = await ctx.prisma.roundProject.findMany({
+ const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
include: {
- project: {
+ assignments: {
include: {
- assignments: {
- include: {
- evaluation: {
- select: { criterionScoresJson: true, status: true },
- },
- },
+ evaluation: {
+ select: { criterionScoresJson: true, status: true },
},
},
},
@@ -166,9 +162,8 @@ export const analyticsRouter = router({
})
// Calculate average scores
- const rankings = roundProjects
- .map((rp) => {
- const project = rp.project
+ const rankings = projects
+ .map((project) => {
const allScores: number[] = []
project.assignments.forEach((assignment) => {
@@ -200,7 +195,7 @@ export const analyticsRouter = router({
id: project.id,
title: project.title,
teamName: project.teamName,
- status: rp.status,
+ status: project.status,
averageScore,
evaluationCount: allScores.length,
}
@@ -217,15 +212,15 @@ export const analyticsRouter = router({
getStatusBreakdown: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
- const roundProjects = await ctx.prisma.roundProject.groupBy({
+ const projects = await ctx.prisma.project.groupBy({
by: ['status'],
where: { roundId: input.roundId },
_count: true,
})
- return roundProjects.map((rp) => ({
- status: rp.status,
- count: rp._count,
+ return projects.map((p) => ({
+ status: p.status,
+ count: p._count,
}))
}),
@@ -242,7 +237,7 @@ export const analyticsRouter = router({
jurorCount,
statusCounts,
] = await Promise.all([
- ctx.prisma.roundProject.count({ where: { roundId: input.roundId } }),
+ ctx.prisma.project.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.evaluation.count({
where: {
@@ -254,7 +249,7 @@ export const analyticsRouter = router({
by: ['userId'],
where: { roundId: input.roundId },
}),
- ctx.prisma.roundProject.groupBy({
+ ctx.prisma.project.groupBy({
by: ['status'],
where: { roundId: input.roundId },
_count: true,
@@ -353,7 +348,7 @@ export const analyticsRouter = router({
)
.query(async ({ ctx, input }) => {
const where = input.roundId
- ? { roundProjects: { some: { roundId: input.roundId } } }
+ ? { roundId: input.roundId }
: { programId: input.programId }
const distribution = await ctx.prisma.project.groupBy({
diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts
index 756f762..7c8b2a1 100644
--- a/src/server/routers/applicant.ts
+++ b/src/server/routers/applicant.ts
@@ -62,7 +62,7 @@ export const applicantRouter = router({
const project = await ctx.prisma.project.findFirst({
where: {
- roundProjects: { some: { roundId: input.roundId } },
+ roundId: input.roundId,
OR: [
{ submittedByUserId: ctx.user.id },
{
@@ -74,14 +74,9 @@ export const applicantRouter = router({
},
include: {
files: true,
- roundProjects: {
- where: { roundId: input.roundId },
+ round: {
include: {
- round: {
- include: {
- program: { select: { name: true, year: true } },
- },
- },
+ program: { select: { name: true, year: true } },
},
},
teamMembers: {
@@ -179,10 +174,10 @@ export const applicantRouter = router({
},
})
- // Update RoundProject status if submitting
+ // Update Project status if submitting
if (submit) {
- await ctx.prisma.roundProject.updateMany({
- where: { projectId: projectId },
+ await ctx.prisma.project.update({
+ where: { id: projectId },
data: { status: 'SUBMITTED' },
})
}
@@ -198,21 +193,13 @@ export const applicantRouter = router({
// Create new project
const project = await ctx.prisma.project.create({
data: {
- programId: roundForCreate.programId,
+ roundId,
...data,
metadataJson: metadataJson as unknown ?? undefined,
submittedByUserId: ctx.user.id,
submittedByEmail: ctx.user.email,
submissionSource: 'MANUAL',
submittedAt: submit ? now : null,
- },
- })
-
- // Create RoundProject entry
- await ctx.prisma.roundProject.create({
- data: {
- roundId,
- projectId: project.id,
status: 'SUBMITTED',
},
})
@@ -412,15 +399,10 @@ export const applicantRouter = router({
],
},
include: {
- roundProjects: {
+ round: {
include: {
- round: {
- include: {
- program: { select: { name: true, year: true } },
- },
- },
+ program: { select: { name: true, year: true } },
},
- orderBy: { addedAt: 'desc' },
},
files: true,
teamMembers: {
@@ -440,9 +422,8 @@ export const applicantRouter = router({
})
}
- // Get the latest round project status
- const latestRoundProject = project.roundProjects[0]
- const currentStatus = latestRoundProject?.status ?? 'SUBMITTED'
+ // Get the project status
+ const currentStatus = project.status ?? 'SUBMITTED'
// Build timeline
const timeline = [
@@ -509,15 +490,10 @@ export const applicantRouter = router({
],
},
include: {
- roundProjects: {
+ round: {
include: {
- round: {
- include: {
- program: { select: { name: true, year: true } },
- },
- },
+ program: { select: { name: true, year: true } },
},
- orderBy: { addedAt: 'desc' },
},
files: true,
teamMembers: {
diff --git a/src/server/routers/application.ts b/src/server/routers/application.ts
index 7d4a7e7..74e1bdb 100644
--- a/src/server/routers/application.ts
+++ b/src/server/routers/application.ts
@@ -191,7 +191,7 @@ export const applicationRouter = router({
// Check if email already submitted for this round
const existingProject = await ctx.prisma.project.findFirst({
where: {
- roundProjects: { some: { roundId } },
+ roundId,
submittedByEmail: data.contactEmail,
},
})
@@ -223,7 +223,7 @@ export const applicationRouter = router({
// Create the project
const project = await ctx.prisma.project.create({
data: {
- programId: round.programId,
+ roundId,
title: data.projectName,
teamName: data.teamName,
description: data.description,
@@ -246,15 +246,6 @@ export const applicationRouter = router({
},
})
- // Create RoundProject entry
- await ctx.prisma.roundProject.create({
- data: {
- roundId,
- projectId: project.id,
- status: 'SUBMITTED',
- },
- })
-
// Create team lead membership
await ctx.prisma.teamMember.create({
data: {
@@ -362,7 +353,7 @@ export const applicationRouter = router({
.query(async ({ ctx, input }) => {
const existing = await ctx.prisma.project.findFirst({
where: {
- roundProjects: { some: { roundId: input.roundId } },
+ roundId: input.roundId,
submittedByEmail: input.email,
},
})
diff --git a/src/server/routers/applicationForm.ts b/src/server/routers/applicationForm.ts
deleted file mode 100644
index eb1fca4..0000000
--- a/src/server/routers/applicationForm.ts
+++ /dev/null
@@ -1,1068 +0,0 @@
-import { z } from 'zod'
-import { TRPCError } from '@trpc/server'
-import { Prisma } from '@prisma/client'
-import { router, publicProcedure, adminProcedure } from '../trpc'
-import { getPresignedUrl } from '@/lib/minio'
-
-// Bucket for form submission files
-export const SUBMISSIONS_BUCKET = 'mopc-submissions'
-
-// Field type enum matching Prisma
-const fieldTypeEnum = z.enum([
- 'TEXT',
- 'TEXTAREA',
- 'NUMBER',
- 'EMAIL',
- 'PHONE',
- 'URL',
- 'DATE',
- 'DATETIME',
- 'SELECT',
- 'MULTI_SELECT',
- 'RADIO',
- 'CHECKBOX',
- 'CHECKBOX_GROUP',
- 'FILE',
- 'FILE_MULTIPLE',
- 'SECTION',
- '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,
- name: z.string().min(1).max(100),
- label: z.string().min(1).max(255),
- description: z.string().optional(),
- placeholder: z.string().optional(),
- required: z.boolean().default(false),
- minLength: z.number().int().optional(),
- maxLength: z.number().int().optional(),
- minValue: z.number().optional(),
- maxValue: z.number().optional(),
- optionsJson: z
- .array(z.object({ value: z.string(), label: z.string() }))
- .optional(),
- conditionJson: z
- .object({
- fieldId: z.string(),
- operator: z.enum(['equals', 'not_equals', 'contains', 'not_empty']),
- value: z.string().optional(),
- })
- .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({
- /**
- * List all forms (admin view)
- */
- list: adminProcedure
- .input(
- z.object({
- programId: z.string().optional(),
- status: z.enum(['DRAFT', 'PUBLISHED', 'CLOSED']).optional(),
- page: z.number().int().min(1).default(1),
- perPage: z.number().int().min(1).max(100).default(20),
- })
- )
- .query(async ({ ctx, input }) => {
- const where: Record = {}
-
- if (input.programId !== undefined) {
- where.programId = input.programId
- }
- if (input.status) {
- where.status = input.status
- }
-
- const [data, total] = await Promise.all([
- ctx.prisma.applicationForm.findMany({
- where,
- include: {
- program: { select: { id: true, name: true, year: true } },
- _count: { select: { submissions: true, fields: true } },
- },
- orderBy: { createdAt: 'desc' },
- skip: (input.page - 1) * input.perPage,
- take: input.perPage,
- }),
- ctx.prisma.applicationForm.count({ where }),
- ])
-
- return {
- data,
- total,
- page: input.page,
- perPage: input.perPage,
- totalPages: Math.ceil(total / input.perPage),
- }
- }),
-
- /**
- * Get a single form by ID (admin view with all fields and steps)
- */
- get: 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 } },
- fields: { orderBy: { sortOrder: 'asc' } },
- steps: {
- orderBy: { sortOrder: 'asc' },
- include: { fields: { orderBy: { sortOrder: 'asc' } } },
- },
- _count: { select: { submissions: true } },
- },
- })
- }),
-
- /**
- * Get a public form by slug (for form submission)
- */
- getBySlug: publicProcedure
- .input(z.object({ slug: z.string() }))
- .query(async ({ ctx, input }) => {
- const form = await ctx.prisma.applicationForm.findUnique({
- where: { publicSlug: input.slug },
- include: {
- fields: { orderBy: { sortOrder: 'asc' } },
- },
- })
-
- if (!form) {
- throw new TRPCError({
- code: 'NOT_FOUND',
- message: 'Form not found',
- })
- }
-
- // Check if form is available
- if (!form.isPublic || form.status !== 'PUBLISHED') {
- throw new TRPCError({
- code: 'FORBIDDEN',
- message: 'This form is not currently accepting submissions',
- })
- }
-
- // Check submission window
- const now = new Date()
- if (form.opensAt && now < form.opensAt) {
- throw new TRPCError({
- code: 'FORBIDDEN',
- message: 'This form is not yet open for submissions',
- })
- }
- if (form.closesAt && now > form.closesAt) {
- throw new TRPCError({
- code: 'FORBIDDEN',
- message: 'This form has 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
- }),
-
- /**
- * Create a new form (admin only)
- */
- create: adminProcedure
- .input(
- z.object({
- programId: z.string().nullable(),
- name: z.string().min(1).max(255),
- description: z.string().optional(),
- publicSlug: z.string().min(1).max(100).optional(),
- submissionLimit: z.number().int().optional(),
- opensAt: z.string().datetime().optional(),
- closesAt: z.string().datetime().optional(),
- confirmationMessage: z.string().optional(),
- })
- )
- .mutation(async ({ ctx, input }) => {
- // Check slug uniqueness
- if (input.publicSlug) {
- const existing = await ctx.prisma.applicationForm.findUnique({
- where: { publicSlug: input.publicSlug },
- })
- if (existing) {
- throw new TRPCError({
- code: 'CONFLICT',
- message: 'This URL slug is already in use',
- })
- }
- }
-
- const form = await ctx.prisma.applicationForm.create({
- data: {
- ...input,
- opensAt: input.opensAt ? new Date(input.opensAt) : null,
- closesAt: input.closesAt ? new Date(input.closesAt) : null,
- },
- })
-
- // Audit log
- await ctx.prisma.auditLog.create({
- data: {
- userId: ctx.user.id,
- action: 'CREATE',
- entityType: 'ApplicationForm',
- entityId: form.id,
- detailsJson: { name: input.name },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- },
- })
-
- return form
- }),
-
- /**
- * Update a form (admin only)
- */
- update: adminProcedure
- .input(
- z.object({
- id: z.string(),
- name: z.string().min(1).max(255).optional(),
- description: z.string().optional().nullable(),
- status: z.enum(['DRAFT', 'PUBLISHED', 'CLOSED']).optional(),
- isPublic: z.boolean().optional(),
- publicSlug: z.string().min(1).max(100).optional().nullable(),
- submissionLimit: z.number().int().optional().nullable(),
- opensAt: z.string().datetime().optional().nullable(),
- closesAt: z.string().datetime().optional().nullable(),
- confirmationMessage: z.string().optional().nullable(),
- })
- )
- .mutation(async ({ ctx, input }) => {
- const { id, opensAt, closesAt, ...data } = input
-
- // Check slug uniqueness if changing
- if (data.publicSlug) {
- const existing = await ctx.prisma.applicationForm.findFirst({
- where: { publicSlug: data.publicSlug, NOT: { id } },
- })
- if (existing) {
- throw new TRPCError({
- code: 'CONFLICT',
- message: 'This URL slug is already in use',
- })
- }
- }
-
- const form = await ctx.prisma.applicationForm.update({
- where: { id },
- data: {
- ...data,
- opensAt: opensAt ? new Date(opensAt) : opensAt === null ? null : undefined,
- closesAt: closesAt ? new Date(closesAt) : closesAt === null ? null : undefined,
- },
- })
-
- // Audit log
- await ctx.prisma.auditLog.create({
- data: {
- userId: ctx.user.id,
- action: 'UPDATE',
- entityType: 'ApplicationForm',
- entityId: id,
- detailsJson: data,
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- },
- })
-
- return form
- }),
-
- /**
- * Delete a form (admin only)
- */
- delete: adminProcedure
- .input(z.object({ id: z.string() }))
- .mutation(async ({ ctx, input }) => {
- const form = await ctx.prisma.applicationForm.delete({
- where: { id: input.id },
- })
-
- // Audit log
- await ctx.prisma.auditLog.create({
- data: {
- userId: ctx.user.id,
- action: 'DELETE',
- entityType: 'ApplicationForm',
- entityId: input.id,
- detailsJson: { name: form.name },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- },
- })
-
- return form
- }),
-
- /**
- * Add a field to a form (or step)
- */
- addField: adminProcedure
- .input(
- z.object({
- formId: z.string(),
- field: fieldInputSchema,
- })
- )
- .mutation(async ({ ctx, input }) => {
- // 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: whereClause,
- _max: { sortOrder: true },
- })
-
- const { stepId, projectMapping, specialType, ...restField } = input.field
-
- const field = await ctx.prisma.applicationFormField.create({
- data: {
- formId: input.formId,
- ...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,
- },
- })
-
- return field
- }),
-
- /**
- * Update a field
- */
- updateField: adminProcedure
- .input(
- z.object({
- id: z.string(),
- field: fieldInputSchema.partial(),
- })
- )
- .mutation(async ({ ctx, input }) => {
- const { stepId, projectMapping, specialType, ...restField } = input.field
-
- const field = await ctx.prisma.applicationFormField.update({
- where: { id: input.id },
- data: {
- ...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,
- },
- })
-
- return field
- }),
-
- /**
- * Delete a field
- */
- deleteField: adminProcedure
- .input(z.object({ id: z.string() }))
- .mutation(async ({ ctx, input }) => {
- return ctx.prisma.applicationFormField.delete({
- where: { id: input.id },
- })
- }),
-
- /**
- * Reorder fields
- */
- reorderFields: adminProcedure
- .input(
- z.object({
- items: z.array(
- z.object({
- id: z.string(),
- sortOrder: z.number().int(),
- })
- ),
- })
- )
- .mutation(async ({ ctx, input }) => {
- await ctx.prisma.$transaction(
- input.items.map((item) =>
- ctx.prisma.applicationFormField.update({
- where: { id: item.id },
- data: { sortOrder: item.sortOrder },
- })
- )
- )
-
- return { success: true }
- }),
-
- /**
- * Submit a form (public endpoint)
- */
- submit: publicProcedure
- .input(
- z.object({
- formId: z.string(),
- data: z.record(z.unknown()),
- email: z.string().email().optional(),
- name: z.string().optional(),
- })
- )
- .mutation(async ({ ctx, input }) => {
- // Get form with fields
- const form = await ctx.prisma.applicationForm.findUniqueOrThrow({
- where: { id: input.formId },
- include: { fields: 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 submission window
- const now = new Date()
- if (form.opensAt && now < form.opensAt) {
- throw new TRPCError({
- code: 'FORBIDDEN',
- message: 'This form is not yet open',
- })
- }
- if (form.closesAt && now > form.closesAt) {
- throw new TRPCError({
- code: 'FORBIDDEN',
- message: 'This form has 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',
- })
- }
- }
-
- // Validate required fields
- for (const field of form.fields) {
- if (field.required && field.fieldType !== 'SECTION' && field.fieldType !== 'INSTRUCTIONS') {
- const value = input.data[field.name]
- if (value === undefined || value === null || value === '') {
- throw new TRPCError({
- code: 'BAD_REQUEST',
- message: `${field.label} is required`,
- })
- }
- }
- }
-
- // Create submission
- const submission = await ctx.prisma.applicationFormSubmission.create({
- data: {
- formId: input.formId,
- email: input.email,
- name: input.name,
- dataJson: input.data as Prisma.InputJsonValue,
- },
- })
-
- return {
- success: true,
- submissionId: submission.id,
- confirmationMessage: form.confirmationMessage,
- }
- }),
-
- /**
- * Get upload URL for a submission file
- */
- getSubmissionUploadUrl: publicProcedure
- .input(
- z.object({
- formId: z.string(),
- fieldName: z.string(),
- fileName: z.string(),
- mimeType: z.string(),
- })
- )
- .mutation(async ({ ctx, input }) => {
- // Verify form exists and is accepting submissions
- const form = await ctx.prisma.applicationForm.findUniqueOrThrow({
- where: { id: input.formId },
- })
-
- if (!form.isPublic || form.status !== 'PUBLISHED') {
- throw new TRPCError({
- code: 'FORBIDDEN',
- message: 'This form is not accepting submissions',
- })
- }
-
- const timestamp = Date.now()
- const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
- const objectKey = `forms/${input.formId}/${timestamp}-${sanitizedName}`
-
- const url = await getPresignedUrl(SUBMISSIONS_BUCKET, objectKey, 'PUT', 3600)
-
- return {
- url,
- bucket: SUBMISSIONS_BUCKET,
- objectKey,
- }
- }),
-
- /**
- * List submissions for a form (admin only)
- */
- listSubmissions: adminProcedure
- .input(
- z.object({
- formId: z.string(),
- status: z.enum(['SUBMITTED', 'REVIEWED', 'APPROVED', 'REJECTED']).optional(),
- search: z.string().optional(),
- page: z.number().int().min(1).default(1),
- perPage: z.number().int().min(1).max(100).default(20),
- })
- )
- .query(async ({ ctx, input }) => {
- const where: Record = {
- formId: input.formId,
- }
-
- if (input.status) {
- where.status = input.status
- }
-
- if (input.search) {
- where.OR = [
- { email: { contains: input.search, mode: 'insensitive' } },
- { name: { contains: input.search, mode: 'insensitive' } },
- ]
- }
-
- const [data, total] = await Promise.all([
- ctx.prisma.applicationFormSubmission.findMany({
- where,
- include: {
- files: true,
- },
- orderBy: { createdAt: 'desc' },
- skip: (input.page - 1) * input.perPage,
- take: input.perPage,
- }),
- ctx.prisma.applicationFormSubmission.count({ where }),
- ])
-
- return {
- data,
- total,
- page: input.page,
- perPage: input.perPage,
- totalPages: Math.ceil(total / input.perPage),
- }
- }),
-
- /**
- * Get a single submission
- */
- getSubmission: adminProcedure
- .input(z.object({ id: z.string() }))
- .query(async ({ ctx, input }) => {
- return ctx.prisma.applicationFormSubmission.findUniqueOrThrow({
- where: { id: input.id },
- include: {
- form: {
- include: { fields: { orderBy: { sortOrder: 'asc' } } },
- },
- files: true,
- },
- })
- }),
-
- /**
- * Update submission status
- */
- updateSubmissionStatus: adminProcedure
- .input(
- z.object({
- id: z.string(),
- status: z.enum(['SUBMITTED', 'REVIEWED', 'APPROVED', 'REJECTED']),
- })
- )
- .mutation(async ({ ctx, input }) => {
- const submission = await ctx.prisma.applicationFormSubmission.update({
- where: { id: input.id },
- data: { status: input.status },
- })
-
- // Audit log
- await ctx.prisma.auditLog.create({
- data: {
- userId: ctx.user.id,
- action: 'UPDATE_STATUS',
- entityType: 'ApplicationFormSubmission',
- entityId: input.id,
- detailsJson: { status: input.status },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- },
- })
-
- return submission
- }),
-
- /**
- * Delete a submission
- */
- deleteSubmission: adminProcedure
- .input(z.object({ id: z.string() }))
- .mutation(async ({ ctx, input }) => {
- const submission = await ctx.prisma.applicationFormSubmission.delete({
- where: { id: input.id },
- })
-
- // Audit log
- await ctx.prisma.auditLog.create({
- data: {
- userId: ctx.user.id,
- action: 'DELETE',
- entityType: 'ApplicationFormSubmission',
- entityId: input.id,
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- },
- })
-
- return submission
- }),
-
- /**
- * Get download URL for a submission file
- */
- getSubmissionFileUrl: adminProcedure
- .input(z.object({ fileId: z.string() }))
- .query(async ({ ctx, input }) => {
- const file = await ctx.prisma.submissionFile.findUniqueOrThrow({
- where: { id: input.fileId },
- })
-
- const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900)
- return { url, fileName: file.fileName }
- }),
-
- /**
- * Duplicate a form
- */
- duplicate: adminProcedure
- .input(
- z.object({
- id: z.string(),
- name: z.string().min(1).max(255),
- })
- )
- .mutation(async ({ ctx, input }) => {
- // Get original form with fields
- const original = await ctx.prisma.applicationForm.findUniqueOrThrow({
- where: { id: input.id },
- include: { fields: true },
- })
-
- // Create new form
- const newForm = await ctx.prisma.applicationForm.create({
- data: {
- programId: original.programId,
- name: input.name,
- description: original.description,
- status: 'DRAFT',
- isPublic: false,
- confirmationMessage: original.confirmationMessage,
- },
- })
-
- // Copy fields
- await ctx.prisma.applicationFormField.createMany({
- data: original.fields.map((field) => ({
- formId: newForm.id,
- fieldType: field.fieldType,
- name: field.name,
- label: field.label,
- description: field.description,
- placeholder: field.placeholder,
- required: field.required,
- minLength: field.minLength,
- maxLength: field.maxLength,
- minValue: field.minValue,
- maxValue: field.maxValue,
- optionsJson: field.optionsJson as Prisma.InputJsonValue ?? undefined,
- conditionJson: field.conditionJson as Prisma.InputJsonValue ?? undefined,
- sortOrder: field.sortOrder,
- width: field.width,
- })),
- })
-
- // Audit log
- await ctx.prisma.auditLog.create({
- data: {
- userId: ctx.user.id,
- action: 'DUPLICATE',
- entityType: 'ApplicationForm',
- entityId: newForm.id,
- detailsJson: { originalId: input.id, name: input.name },
- ipAddress: ctx.ip,
- userAgent: ctx.userAgent,
- },
- })
-
- 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
- */
- getStats: adminProcedure
- .input(z.object({ formId: z.string() }))
- .query(async ({ ctx, input }) => {
- const [total, byStatus, recentSubmissions] = await Promise.all([
- ctx.prisma.applicationFormSubmission.count({
- where: { formId: input.formId },
- }),
- ctx.prisma.applicationFormSubmission.groupBy({
- by: ['status'],
- where: { formId: input.formId },
- _count: true,
- }),
- ctx.prisma.applicationFormSubmission.findMany({
- where: { formId: input.formId },
- select: { createdAt: true },
- orderBy: { createdAt: 'desc' },
- take: 30,
- }),
- ])
-
- // Calculate submissions per day for last 30 days
- const thirtyDaysAgo = new Date()
- thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
-
- const submissionsPerDay = new Map()
- for (const sub of recentSubmissions) {
- const date = sub.createdAt.toISOString().split('T')[0]
- submissionsPerDay.set(date, (submissionsPerDay.get(date) || 0) + 1)
- }
-
- return {
- total,
- byStatus: Object.fromEntries(
- byStatus.map((r) => [r.status, r._count])
- ),
- submissionsPerDay: Object.fromEntries(submissionsPerDay),
- }
- }),
-})
diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts
index 2246055..9044793 100644
--- a/src/server/routers/assignment.ts
+++ b/src/server/routers/assignment.ts
@@ -374,16 +374,12 @@ export const assignmentRouter = router({
where: { roundId: input.roundId },
_count: true,
}),
- ctx.prisma.roundProject.findMany({
+ ctx.prisma.project.findMany({
where: { roundId: input.roundId },
- include: {
- project: {
- select: {
- id: true,
- title: true,
- _count: { select: { assignments: true } },
- },
- },
+ select: {
+ id: true,
+ title: true,
+ _count: { select: { assignments: true } },
},
}),
])
@@ -394,7 +390,7 @@ export const assignmentRouter = router({
})
const projectsWithFullCoverage = projectCoverage.filter(
- (rp) => rp.project._count.assignments >= round.requiredReviews
+ (p) => p._count.assignments >= round.requiredReviews
).length
return {
@@ -446,20 +442,15 @@ export const assignmentRouter = router({
})
// Get all projects that need more assignments
- const roundProjectEntries = await ctx.prisma.roundProject.findMany({
+ const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
- include: {
- project: {
- select: {
- id: true,
- title: true,
- tags: true,
- _count: { select: { assignments: true } },
- },
- },
+ select: {
+ id: true,
+ title: true,
+ tags: true,
+ _count: { select: { assignments: true } },
},
})
- const projects = roundProjectEntries.map((rp) => rp.project)
// Get existing assignments to avoid duplicates
const existingAssignments = await ctx.prisma.assignment.findMany({
@@ -583,22 +574,17 @@ export const assignmentRouter = router({
})
// Get all projects in the round
- const roundProjectEntries = await ctx.prisma.roundProject.findMany({
+ const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
- include: {
- project: {
- select: {
- id: true,
- title: true,
- description: true,
- tags: true,
- teamName: true,
- _count: { select: { assignments: true } },
- },
- },
+ select: {
+ id: true,
+ title: true,
+ description: true,
+ tags: true,
+ teamName: true,
+ _count: { select: { assignments: true } },
},
})
- const projects = roundProjectEntries.map((rp) => rp.project)
// Get existing assignments
const existingAssignments = await ctx.prisma.assignment.findMany({
diff --git a/src/server/routers/export.ts b/src/server/routers/export.ts
index 547d793..29bf832 100644
--- a/src/server/routers/export.ts
+++ b/src/server/routers/export.ts
@@ -103,26 +103,21 @@ export const exportRouter = router({
projectScores: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
- const roundProjectEntries = await ctx.prisma.roundProject.findMany({
+ const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
include: {
- project: {
+ assignments: {
include: {
- assignments: {
- include: {
- evaluation: {
- where: { status: 'SUBMITTED' },
- },
- },
+ evaluation: {
+ where: { status: 'SUBMITTED' },
},
},
},
},
- orderBy: { project: { title: 'asc' } },
+ orderBy: { title: 'asc' },
})
- const data = roundProjectEntries.map((rp) => {
- const p = rp.project
+ const data = projects.map((p) => {
const evaluations = p.assignments
.map((a) => a.evaluation)
.filter((e) => e !== null)
@@ -138,7 +133,7 @@ export const exportRouter = router({
return {
title: p.title,
teamName: p.teamName,
- status: rp.status,
+ status: p.status,
tags: p.tags.join(', '),
totalEvaluations: evaluations.length,
averageScore:
diff --git a/src/server/routers/filtering.ts b/src/server/routers/filtering.ts
index 31f871f..4ed9a76 100644
--- a/src/server/routers/filtering.ts
+++ b/src/server/routers/filtering.ts
@@ -27,19 +27,14 @@ async function runFilteringJob(jobId: string, roundId: string, userId: string) {
})
// Get projects
- const roundProjectEntries = await prisma.roundProject.findMany({
+ const projects = await prisma.project.findMany({
where: { roundId },
include: {
- project: {
- include: {
- files: {
- select: { id: true, fileName: true, fileType: true },
- },
- },
+ files: {
+ select: { id: true, fileName: true, fileType: true },
},
},
})
- const projects = roundProjectEntries.map((rp) => rp.project)
// Calculate batch info
const BATCH_SIZE = 20
@@ -387,7 +382,7 @@ export const filteringRouter = router({
}
// Count projects
- const projectCount = await ctx.prisma.roundProject.count({
+ const projectCount = await ctx.prisma.project.count({
where: { roundId: input.roundId },
})
if (projectCount === 0) {
@@ -485,19 +480,14 @@ export const filteringRouter = router({
}
// Get projects in this round
- const roundProjectEntries = await ctx.prisma.roundProject.findMany({
+ const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
include: {
- project: {
- include: {
- files: {
- select: { id: true, fileName: true, fileType: true },
- },
- },
+ files: {
+ select: { id: true, fileName: true, fileType: true },
},
},
})
- const projects = roundProjectEntries.map((rp) => rp.project)
if (projects.length === 0) {
throw new TRPCError({
@@ -755,32 +745,29 @@ export const filteringRouter = router({
// Filtered out projects get REJECTED status (data preserved)
if (filteredOutIds.length > 0) {
operations.push(
- ctx.prisma.roundProject.updateMany({
- where: { roundId: input.roundId, projectId: { in: filteredOutIds } },
+ ctx.prisma.project.updateMany({
+ where: { roundId: input.roundId, id: { in: filteredOutIds } },
data: { status: 'REJECTED' },
})
)
}
- // Passed projects get ELIGIBLE status
+ // Passed projects get ELIGIBLE status (or advance to next round)
if (passedIds.length > 0) {
- operations.push(
- ctx.prisma.roundProject.updateMany({
- where: { roundId: input.roundId, projectId: { in: passedIds } },
- data: { status: 'ELIGIBLE' },
- })
- )
-
- // If there's a next round, advance passed projects to it
if (nextRound) {
+ // Advance passed projects to next round
operations.push(
- ctx.prisma.roundProject.createMany({
- data: passedIds.map((projectId) => ({
- roundId: nextRound.id,
- projectId,
- status: 'SUBMITTED' as const,
- })),
- skipDuplicates: true,
+ ctx.prisma.project.updateMany({
+ where: { roundId: input.roundId, id: { in: passedIds } },
+ data: { roundId: nextRound.id, status: 'SUBMITTED' },
+ })
+ )
+ } else {
+ // No next round, just mark as eligible
+ operations.push(
+ ctx.prisma.project.updateMany({
+ where: { roundId: input.roundId, id: { in: passedIds } },
+ data: { status: 'ELIGIBLE' },
})
)
}
@@ -837,9 +824,9 @@ export const filteringRouter = router({
},
})
- // Restore RoundProject status
- await ctx.prisma.roundProject.updateMany({
- where: { roundId: input.roundId, projectId: input.projectId },
+ // Restore project status
+ await ctx.prisma.project.updateMany({
+ where: { roundId: input.roundId, id: input.projectId },
data: { status: 'ELIGIBLE' },
})
@@ -883,8 +870,8 @@ export const filteringRouter = router({
},
})
),
- ctx.prisma.roundProject.updateMany({
- where: { roundId: input.roundId, projectId: { in: input.projectIds } },
+ ctx.prisma.project.updateMany({
+ where: { roundId: input.roundId, id: { in: input.projectIds } },
data: { status: 'ELIGIBLE' },
}),
])
diff --git a/src/server/routers/learningResource.ts b/src/server/routers/learningResource.ts
index 32bd86c..4ac8228 100644
--- a/src/server/routers/learningResource.ts
+++ b/src/server/routers/learningResource.ts
@@ -82,11 +82,7 @@ export const learningResourceRouter = router({
include: {
project: {
select: {
- roundProjects: {
- select: { status: true },
- orderBy: { addedAt: 'desc' },
- take: 1,
- },
+ status: true,
},
},
},
@@ -95,12 +91,12 @@ export const learningResourceRouter = router({
// Determine highest cohort level
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
- const rpStatus = assignment.project.roundProjects[0]?.status
- if (rpStatus === 'FINALIST') {
+ const projectStatus = assignment.project.status
+ if (projectStatus === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
- if (rpStatus === 'SEMIFINALIST') {
+ if (projectStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
@@ -166,11 +162,7 @@ export const learningResourceRouter = router({
include: {
project: {
select: {
- roundProjects: {
- select: { status: true },
- orderBy: { addedAt: 'desc' as const },
- take: 1,
- },
+ status: true,
},
},
},
@@ -178,12 +170,12 @@ export const learningResourceRouter = router({
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
- const rpStatus = assignment.project.roundProjects[0]?.status
- if (rpStatus === 'FINALIST') {
+ const projectStatus = assignment.project.status
+ if (projectStatus === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
- if (rpStatus === 'SEMIFINALIST') {
+ if (projectStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
@@ -241,11 +233,7 @@ export const learningResourceRouter = router({
include: {
project: {
select: {
- roundProjects: {
- select: { status: true },
- orderBy: { addedAt: 'desc' as const },
- take: 1,
- },
+ status: true,
},
},
},
@@ -253,12 +241,12 @@ export const learningResourceRouter = router({
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
- const rpStatus = assignment.project.roundProjects[0]?.status
- if (rpStatus === 'FINALIST') {
+ const projectStatus = assignment.project.status
+ if (projectStatus === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
- if (rpStatus === 'SEMIFINALIST') {
+ if (projectStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
diff --git a/src/server/routers/live-voting.ts b/src/server/routers/live-voting.ts
index 2ac71c9..34d4657 100644
--- a/src/server/routers/live-voting.ts
+++ b/src/server/routers/live-voting.ts
@@ -15,13 +15,9 @@ export const liveVotingRouter = router({
round: {
include: {
program: { select: { name: true, year: true } },
- roundProjects: {
+ projects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
- include: {
- project: {
- select: { id: true, title: true, teamName: true },
- },
- },
+ select: { id: true, title: true, teamName: true },
},
},
},
@@ -38,13 +34,9 @@ export const liveVotingRouter = router({
round: {
include: {
program: { select: { name: true, year: true } },
- roundProjects: {
+ projects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
- include: {
- project: {
- select: { id: true, title: true, teamName: true },
- },
- },
+ select: { id: true, title: true, teamName: true },
},
},
},
diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts
index dc7316d..1cf5d0f 100644
--- a/src/server/routers/mentor.ts
+++ b/src/server/routers/mentor.ts
@@ -410,7 +410,7 @@ export const mentorRouter = router({
// Get projects without mentors
const projects = await ctx.prisma.project.findMany({
where: {
- roundProjects: { some: { roundId: input.roundId } },
+ roundId: input.roundId,
mentorAssignment: null,
wantsMentorship: true,
},
@@ -549,17 +549,10 @@ export const mentorRouter = router({
include: {
project: {
include: {
- program: { select: { name: true, year: true } },
- roundProjects: {
+ round: {
include: {
- round: {
- include: {
- program: { select: { name: true, year: true } },
- },
- },
+ program: { select: { name: true, year: true } },
},
- orderBy: { addedAt: 'desc' },
- take: 1,
},
teamMembers: {
include: {
@@ -602,17 +595,10 @@ export const mentorRouter = router({
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: {
- program: { select: { id: true, name: true, year: true } },
- roundProjects: {
+ round: {
include: {
- round: {
- include: {
- program: { select: { id: true, name: true, year: true } },
- },
- },
+ program: { select: { id: true, name: true, year: true } },
},
- orderBy: { addedAt: 'desc' },
- take: 1,
},
teamMembers: {
include: {
@@ -660,7 +646,7 @@ export const mentorRouter = router({
)
.query(async ({ ctx, input }) => {
const where = {
- ...(input.roundId && { project: { roundProjects: { some: { roundId: input.roundId } } } }),
+ ...(input.roundId && { project: { roundId: input.roundId } }),
...(input.mentorId && { mentorId: input.mentorId }),
}
@@ -675,10 +661,7 @@ export const mentorRouter = router({
teamName: true,
oceanIssue: true,
competitionCategory: true,
- roundProjects: {
- select: { status: true },
- take: 1,
- },
+ status: true,
},
},
mentor: {
diff --git a/src/server/routers/notion-import.ts b/src/server/routers/notion-import.ts
index f7d6f7d..a5f942e 100644
--- a/src/server/routers/notion-import.ts
+++ b/src/server/routers/notion-import.ts
@@ -171,9 +171,10 @@ export const notionImportRouter = router({
}
// Create project
- const createdProject = await ctx.prisma.project.create({
+ await ctx.prisma.project.create({
data: {
- programId: round.programId,
+ roundId: round.id,
+ status: 'SUBMITTED',
title: title.trim(),
teamName: typeof teamName === 'string' ? teamName.trim() : null,
description: typeof description === 'string' ? description : null,
@@ -186,15 +187,6 @@ export const notionImportRouter = router({
},
})
- // Create RoundProject entry
- await ctx.prisma.roundProject.create({
- data: {
- roundId: round.id,
- projectId: createdProject.id,
- status: 'SUBMITTED',
- },
- })
-
results.imported++
} catch (error) {
results.errors.push({
diff --git a/src/server/routers/onboarding.ts b/src/server/routers/onboarding.ts
deleted file mode 100644
index 9dbc5c2..0000000
--- a/src/server/routers/onboarding.ts
+++ /dev/null
@@ -1,433 +0,0 @@
-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'
-import {
- createNotification,
- notifyAdmins,
- NotificationTypes,
-} from '../services/in-app-notification'
-
-// 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,
- },
- })
-
- // In-app notification for applicant
- const programName = form.program?.name || form.round?.name || 'the program'
- await createNotification({
- userId: contactUser.id,
- type: NotificationTypes.APPLICATION_SUBMITTED,
- title: 'Application Received',
- message: `Your application for "${input.projectName}" has been successfully submitted.`,
- linkUrl: `/team/projects/${project.id}`,
- linkLabel: 'View Application',
- metadata: {
- projectName: input.projectName,
- programName,
- },
- })
-
- // Notify admins of new application
- await notifyAdmins({
- type: NotificationTypes.NEW_APPLICATION,
- title: 'New Application',
- message: `New application received: "${input.projectName}" from ${input.contactName}.`,
- linkUrl: `/admin/projects/${project.id}`,
- linkLabel: 'Review Application',
- metadata: {
- projectName: input.projectName,
- applicantName: input.contactName,
- applicantEmail: input.contactEmail,
- programName,
- },
- })
-
- return {
- success: true,
- projectId: project.id,
- confirmationMessage: form.confirmationMessage,
- }
- }),
-})
diff --git a/src/server/routers/program.ts b/src/server/routers/program.ts
index c66fbc5..366f0a5 100644
--- a/src/server/routers/program.ts
+++ b/src/server/routers/program.ts
@@ -20,14 +20,14 @@ export const programRouter = router({
_count: {
select: { rounds: true },
},
- rounds: input?.includeRounds ? {
- orderBy: { sortOrder: 'asc' },
+ rounds: {
+ orderBy: { createdAt: 'asc' },
include: {
_count: {
- select: { roundProjects: true, assignments: true },
+ select: { projects: true, assignments: true },
},
},
- } : false,
+ },
},
})
}),
@@ -45,7 +45,7 @@ export const programRouter = router({
orderBy: { createdAt: 'asc' },
include: {
_count: {
- select: { roundProjects: true, assignments: true },
+ select: { projects: true, assignments: true },
},
},
},
diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts
index 4ec854e..141581d 100644
--- a/src/server/routers/project.ts
+++ b/src/server/routers/project.ts
@@ -69,48 +69,29 @@ export const projectRouter = router({
// Build where clause
const where: Record = {}
- if (programId) where.programId = programId
+ // Filter by program via round
+ if (programId) where.round = { programId }
- // Filter by round via RoundProject join
+ // Filter by round
if (roundId) {
- where.roundProjects = { some: { roundId } }
+ where.roundId = roundId
}
- // Exclude projects already in a specific round
+ // Exclude projects in a specific round
if (notInRoundId) {
- where.roundProjects = {
- ...(where.roundProjects as Record || {}),
- none: { roundId: notInRoundId },
- }
+ where.roundId = { not: notInRoundId }
}
- // Filter by unassigned (not in any round)
+ // Filter by unassigned (no round)
if (unassignedOnly) {
- where.roundProjects = { none: {} }
+ where.roundId = null
}
- // Status filter via RoundProject
- if (roundId && (statuses?.length || status)) {
+ // Status filter
+ if (statuses?.length || status) {
const statusValues = statuses?.length ? statuses : status ? [status] : []
if (statusValues.length > 0) {
- where.roundProjects = {
- some: {
- roundId,
- status: { in: statusValues },
- },
- }
- }
- } else if (statuses?.length || status) {
- // Status filter without specific round — match any round with that status
- const statusValues = statuses?.length ? statuses : status ? [status] : []
- if (statusValues.length > 0) {
- where.roundProjects = {
- ...(where.roundProjects as Record || {}),
- some: {
- ...((where.roundProjects as Record)?.some as Record || {}),
- status: { in: statusValues },
- },
- }
+ where.status = { in: statusValues }
}
}
@@ -150,16 +131,12 @@ export const projectRouter = router({
orderBy: { createdAt: 'desc' },
include: {
files: true,
- program: {
- select: { id: true, name: true, year: true },
- },
- roundProjects: {
- include: {
- round: {
- select: { id: true, name: true, sortOrder: true },
- },
+ round: {
+ select: {
+ id: true,
+ name: true,
+ program: { select: { id: true, name: true, year: true } },
},
- orderBy: { addedAt: 'desc' },
},
_count: { select: { assignments: true } },
},
@@ -183,8 +160,8 @@ export const projectRouter = router({
.query(async ({ ctx }) => {
const [rounds, countries, categories, issues] = await Promise.all([
ctx.prisma.round.findMany({
- select: { id: true, name: true, sortOrder: true, program: { select: { name: true, year: true } } },
- orderBy: [{ program: { year: 'desc' } }, { sortOrder: 'asc' }],
+ select: { id: true, name: true, program: { select: { name: true, year: true } } },
+ orderBy: [{ program: { year: 'desc' } }, { createdAt: 'asc' }],
}),
ctx.prisma.project.findMany({
where: { country: { not: null } },
@@ -228,17 +205,7 @@ export const projectRouter = router({
where: { id: input.id },
include: {
files: true,
- program: {
- select: { id: true, name: true, year: true },
- },
- roundProjects: {
- include: {
- round: {
- select: { id: true, name: true, sortOrder: true, status: true },
- },
- },
- orderBy: { round: { sortOrder: 'asc' } },
- },
+ round: true,
teamMembers: {
include: {
user: {
@@ -307,13 +274,12 @@ export const projectRouter = router({
/**
* Create a single project (admin only)
- * Projects belong to a program. Optionally assign to a round immediately.
+ * Projects belong to a round.
*/
create: adminProcedure
.input(
z.object({
- programId: z.string(),
- roundId: z.string().optional(),
+ roundId: z.string(),
title: z.string().min(1).max(500),
teamName: z.string().optional(),
description: z.string().optional(),
@@ -322,25 +288,15 @@ export const projectRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
- const { metadataJson, roundId, ...rest } = input
+ const { metadataJson, ...rest } = input
const project = await ctx.prisma.project.create({
data: {
...rest,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
+ status: 'SUBMITTED',
},
})
- // If roundId provided, also create RoundProject entry
- if (roundId) {
- await ctx.prisma.roundProject.create({
- data: {
- roundId,
- projectId: project.id,
- status: 'SUBMITTED',
- },
- })
- }
-
// Audit log
await ctx.prisma.auditLog.create({
data: {
@@ -348,7 +304,7 @@ export const projectRouter = router({
action: 'CREATE',
entityType: 'Project',
entityId: project.id,
- detailsJson: { title: input.title, programId: input.programId, roundId },
+ detailsJson: { title: input.title, roundId: input.roundId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
@@ -391,22 +347,20 @@ export const projectRouter = router({
where: { id },
data: {
...data,
+ ...(status && { status }),
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
},
})
- // Update status on RoundProject if both status and roundId provided
- if (status && roundId) {
- await ctx.prisma.roundProject.updateMany({
- where: { projectId: id, roundId },
- data: { status },
+ // Send notifications if status changed
+ if (status) {
+ // Get round details for notification
+ const projectWithRound = await ctx.prisma.project.findUnique({
+ where: { id },
+ include: { round: { select: { name: true, entryNotificationType: true, program: { select: { name: true } } } } },
})
- // Get round details including configured notification type
- const round = await ctx.prisma.round.findUnique({
- where: { id: roundId },
- select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
- })
+ const round = projectWithRound?.round
// Helper to get notification title based on type
const getNotificationTitle = (type: string): string => {
@@ -445,7 +399,7 @@ export const projectRouter = router({
programName: round.program?.name,
},
})
- } else {
+ } else if (round) {
// Fall back to hardcoded status-based notifications
const notificationConfig: Record<
string,
@@ -494,7 +448,7 @@ export const projectRouter = router({
action: 'UPDATE',
entityType: 'Project',
entityId: id,
- detailsJson: { ...data, status, roundId, metadataJson } as Prisma.InputJsonValue,
+ detailsJson: { ...data, status, metadataJson } as Prisma.InputJsonValue,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
@@ -570,12 +524,13 @@ export const projectRouter = router({
// Create projects in a transaction
const result = await ctx.prisma.$transaction(async (tx) => {
- // Create all projects
+ // Create all projects with roundId
const projectData = input.projects.map((p) => {
const { metadataJson, ...rest } = p
return {
...rest,
- programId: input.programId,
+ roundId: input.roundId!,
+ status: 'SUBMITTED' as const,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
}
})
@@ -585,17 +540,6 @@ export const projectRouter = router({
select: { id: true },
})
- // If roundId provided, create RoundProject entries
- if (input.roundId) {
- await tx.roundProject.createMany({
- data: created.map((p) => ({
- roundId: input.roundId!,
- projectId: p.id,
- status: 'SUBMITTED' as const,
- })),
- })
- }
-
return { imported: created.length }
})
@@ -624,8 +568,8 @@ export const projectRouter = router({
}))
.query(async ({ ctx, input }) => {
const where: Record = {}
- if (input.programId) where.programId = input.programId
- if (input.roundId) where.roundProjects = { some: { roundId: input.roundId } }
+ if (input.programId) where.round = { programId: input.programId }
+ if (input.roundId) where.roundId = input.roundId
const projects = await ctx.prisma.project.findMany({
where: Object.keys(where).length > 0 ? where : undefined,
@@ -658,9 +602,9 @@ export const projectRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
- const updated = await ctx.prisma.roundProject.updateMany({
+ const updated = await ctx.prisma.project.updateMany({
where: {
- projectId: { in: input.ids },
+ id: { in: input.ids },
roundId: input.roundId,
},
data: { status: input.status },
@@ -798,8 +742,8 @@ export const projectRouter = router({
const skip = (page - 1) * perPage
const where: Record = {
- programId,
- roundProjects: { none: {} },
+ round: { programId },
+ roundId: null,
}
if (search) {
diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts
index bdd729f..aa6d597 100644
--- a/src/server/routers/round.ts
+++ b/src/server/routers/round.ts
@@ -19,7 +19,7 @@ export const roundRouter = router({
orderBy: { sortOrder: 'asc' },
include: {
_count: {
- select: { roundProjects: true, assignments: true },
+ select: { projects: true, assignments: true },
},
},
})
@@ -36,7 +36,7 @@ export const roundRouter = router({
include: {
program: true,
_count: {
- select: { roundProjects: true, assignments: true },
+ select: { projects: true, assignments: true },
},
evaluationForms: {
where: { isActive: true },
@@ -113,23 +113,18 @@ export const roundRouter = router({
},
})
- // For FILTERING rounds, automatically add all projects from the program
+ // For FILTERING rounds, automatically move all projects from the program to this round
if (input.roundType === 'FILTERING') {
- const projects = await ctx.prisma.project.findMany({
- where: { programId: input.programId },
- select: { id: true },
+ await ctx.prisma.project.updateMany({
+ where: {
+ round: { programId: input.programId },
+ roundId: { not: round.id },
+ },
+ data: {
+ roundId: round.id,
+ status: 'SUBMITTED',
+ },
})
-
- if (projects.length > 0) {
- await ctx.prisma.roundProject.createMany({
- data: projects.map((p) => ({
- roundId: round.id,
- projectId: p.id,
- status: 'SUBMITTED',
- })),
- skipDuplicates: true,
- })
- }
}
// Audit log
@@ -341,7 +336,7 @@ export const roundRouter = router({
.query(async ({ ctx, input }) => {
const [totalProjects, totalAssignments, completedAssignments] =
await Promise.all([
- ctx.prisma.roundProject.count({ where: { roundId: input.id } }),
+ ctx.prisma.project.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({
where: { roundId: input.id, isCompleted: true },
@@ -472,7 +467,7 @@ export const roundRouter = router({
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.id },
include: {
- _count: { select: { roundProjects: true, assignments: true } },
+ _count: { select: { projects: true, assignments: true } },
},
})
@@ -490,7 +485,7 @@ export const roundRouter = router({
detailsJson: {
name: round.name,
status: round.status,
- projectsDeleted: round._count.roundProjects,
+ projectsDeleted: round._count.projects,
assignmentsDeleted: round._count.assignments,
},
ipAddress: ctx.ip,
@@ -532,29 +527,25 @@ export const roundRouter = router({
where: { id: input.roundId },
})
- // Verify all projects belong to the same program
- const projects = await ctx.prisma.project.findMany({
- where: { id: { in: input.projectIds }, programId: round.programId },
- select: { id: true },
+ // Update projects to assign them to this round
+ const updated = await ctx.prisma.project.updateMany({
+ where: {
+ id: { in: input.projectIds },
+ round: { programId: round.programId },
+ },
+ data: {
+ roundId: input.roundId,
+ status: 'SUBMITTED',
+ },
})
- if (projects.length !== input.projectIds.length) {
+ if (updated.count === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
- message: 'Some projects do not belong to this program',
+ message: 'No projects were assigned. Projects may not belong to this program.',
})
}
- // Create RoundProject entries (skip duplicates)
- const created = await ctx.prisma.roundProject.createMany({
- data: input.projectIds.map((projectId) => ({
- roundId: input.roundId,
- projectId,
- status: 'SUBMITTED' as const,
- })),
- skipDuplicates: true,
- })
-
// Audit log
await ctx.prisma.auditLog.create({
data: {
@@ -562,13 +553,13 @@ export const roundRouter = router({
action: 'ASSIGN_PROJECTS_TO_ROUND',
entityType: 'Round',
entityId: input.roundId,
- detailsJson: { projectCount: created.count },
+ detailsJson: { projectCount: updated.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
- return { assigned: created.count }
+ return { assigned: updated.count }
}),
/**
@@ -582,12 +573,17 @@ export const roundRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
- const deleted = await ctx.prisma.roundProject.deleteMany({
+ // Set roundId to null for these projects (remove from round)
+ const updated = await ctx.prisma.project.updateMany({
where: {
roundId: input.roundId,
- projectId: { in: input.projectIds },
+ id: { in: input.projectIds },
+ },
+ data: {
+ roundId: null as unknown as string, // Projects need to be orphaned
},
})
+ const deleted = { count: updated.count }
// Audit log
await ctx.prisma.auditLog.create({
@@ -632,12 +628,12 @@ export const roundRouter = router({
}
// Verify all projects are in the source round
- const sourceProjects = await ctx.prisma.roundProject.findMany({
+ const sourceProjects = await ctx.prisma.project.findMany({
where: {
roundId: input.fromRoundId,
- projectId: { in: input.projectIds },
+ id: { in: input.projectIds },
},
- select: { projectId: true },
+ select: { id: true },
})
if (sourceProjects.length !== input.projectIds.length) {
@@ -647,15 +643,18 @@ export const roundRouter = router({
})
}
- // Create entries in target round (skip duplicates)
- const created = await ctx.prisma.roundProject.createMany({
- data: input.projectIds.map((projectId) => ({
+ // Move projects to target round
+ const updated = await ctx.prisma.project.updateMany({
+ where: {
+ id: { in: input.projectIds },
+ roundId: input.fromRoundId,
+ },
+ data: {
roundId: input.toRoundId,
- projectId,
- status: 'SUBMITTED' as const,
- })),
- skipDuplicates: true,
+ status: 'SUBMITTED',
+ },
})
+ const created = { count: updated.count }
// Audit log
await ctx.prisma.auditLog.create({
diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts
index cd6884d..a9d357e 100644
--- a/src/server/routers/specialAward.ts
+++ b/src/server/routers/specialAward.ts
@@ -237,29 +237,22 @@ export const specialAwardRouter = router({
const statusFilter = input.includeSubmitted
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
- const roundProjectEntries = await ctx.prisma.roundProject.findMany({
+ const projects = await ctx.prisma.project.findMany({
where: {
round: { programId: award.programId },
status: { in: [...statusFilter] },
},
- include: {
- project: {
- select: {
- id: true,
- title: true,
- description: true,
- competitionCategory: true,
- country: true,
- geographicZone: true,
- tags: true,
- oceanIssue: true,
- },
- },
+ select: {
+ id: true,
+ title: true,
+ description: true,
+ competitionCategory: true,
+ country: true,
+ geographicZone: true,
+ tags: true,
+ oceanIssue: true,
},
})
- // Deduplicate projects (same project may be in multiple rounds)
- const projectMap = new Map(roundProjectEntries.map((rp) => [rp.project.id, rp.project]))
- const projects = Array.from(projectMap.values())
if (projects.length === 0) {
throw new TRPCError({
diff --git a/src/server/routers/tag.ts b/src/server/routers/tag.ts
index c43988c..c077912 100644
--- a/src/server/routers/tag.ts
+++ b/src/server/routers/tag.ts
@@ -1,6 +1,13 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '../trpc'
+import {
+ tagProject,
+ batchTagProjects,
+ getTagSuggestions,
+ addProjectTag,
+ removeProjectTag,
+} from '../services/ai-tagging'
export const tagRouter = router({
/**
@@ -391,6 +398,157 @@ export const tagRouter = router({
)
)
+ return { success: true }
+ }),
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // PROJECT TAGGING (AI-powered)
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ /**
+ * Get tags for a project
+ */
+ getProjectTags: protectedProcedure
+ .input(z.object({ projectId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const tags = await ctx.prisma.projectTag.findMany({
+ where: { projectId: input.projectId },
+ include: { tag: true },
+ orderBy: { confidence: 'desc' },
+ })
+
+ return tags.map((pt) => ({
+ id: pt.id,
+ tagId: pt.tagId,
+ name: pt.tag.name,
+ category: pt.tag.category,
+ color: pt.tag.color,
+ confidence: pt.confidence,
+ source: pt.source,
+ createdAt: pt.createdAt,
+ }))
+ }),
+
+ /**
+ * Get AI tag suggestions for a project (without applying)
+ */
+ getSuggestions: adminProcedure
+ .input(z.object({ projectId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const suggestions = await getTagSuggestions(input.projectId, ctx.user.id)
+ return suggestions
+ }),
+
+ /**
+ * Tag a single project with AI
+ */
+ tagProject: adminProcedure
+ .input(z.object({ projectId: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const result = await tagProject(input.projectId, ctx.user.id)
+
+ // Audit log
+ await ctx.prisma.auditLog.create({
+ data: {
+ userId: ctx.user.id,
+ action: 'AI_TAG',
+ entityType: 'Project',
+ entityId: input.projectId,
+ detailsJson: {
+ applied: result.applied.map((t) => t.tagName),
+ tokensUsed: result.tokensUsed,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ },
+ })
+
+ return result
+ }),
+
+ /**
+ * Batch tag all untagged projects in a round
+ */
+ batchTagProjects: adminProcedure
+ .input(z.object({ roundId: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const result = await batchTagProjects(input.roundId, ctx.user.id)
+
+ // Audit log
+ await ctx.prisma.auditLog.create({
+ data: {
+ userId: ctx.user.id,
+ action: 'BATCH_AI_TAG',
+ entityType: 'Round',
+ entityId: input.roundId,
+ detailsJson: {
+ processed: result.processed,
+ failed: result.failed,
+ skipped: result.skipped,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ },
+ })
+
+ return result
+ }),
+
+ /**
+ * Manually add a tag to a project
+ */
+ addProjectTag: adminProcedure
+ .input(
+ z.object({
+ projectId: z.string(),
+ tagId: z.string(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ await addProjectTag(input.projectId, input.tagId)
+
+ // Audit log
+ await ctx.prisma.auditLog.create({
+ data: {
+ userId: ctx.user.id,
+ action: 'ADD_TAG',
+ entityType: 'Project',
+ entityId: input.projectId,
+ detailsJson: { tagId: input.tagId },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ },
+ })
+
+ return { success: true }
+ }),
+
+ /**
+ * Remove a tag from a project
+ */
+ removeProjectTag: adminProcedure
+ .input(
+ z.object({
+ projectId: z.string(),
+ tagId: z.string(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ await removeProjectTag(input.projectId, input.tagId)
+
+ // Audit log
+ await ctx.prisma.auditLog.create({
+ data: {
+ userId: ctx.user.id,
+ action: 'REMOVE_TAG',
+ entityType: 'Project',
+ entityId: input.projectId,
+ detailsJson: { tagId: input.tagId },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ },
+ })
+
return { success: true }
}),
})
diff --git a/src/server/routers/typeform-import.ts b/src/server/routers/typeform-import.ts
index 2bfe802..dee645d 100644
--- a/src/server/routers/typeform-import.ts
+++ b/src/server/routers/typeform-import.ts
@@ -199,9 +199,10 @@ export const typeformImportRouter = router({
}
// Create project
- const createdProject = await ctx.prisma.project.create({
+ await ctx.prisma.project.create({
data: {
- programId: round.programId,
+ roundId: round.id,
+ status: 'SUBMITTED',
title: String(title).trim(),
teamName: typeof teamName === 'string' ? teamName.trim() : null,
description: typeof description === 'string' ? description : null,
@@ -214,15 +215,6 @@ export const typeformImportRouter = router({
},
})
- // Create RoundProject entry
- await ctx.prisma.roundProject.create({
- data: {
- roundId: round.id,
- projectId: createdProject.id,
- status: 'SUBMITTED',
- },
- })
-
results.imported++
} catch (error) {
results.errors.push({
diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts
index fe4c1c5..ff5a022 100644
--- a/src/server/routers/user.ts
+++ b/src/server/routers/user.ts
@@ -29,6 +29,7 @@ export const userRouter = router({
expertiseTags: true,
metadataJson: true,
phoneNumber: true,
+ country: true,
notificationPreference: true,
profileImageKey: true,
createdAt: true,
@@ -415,6 +416,7 @@ export const userRouter = router({
/**
* Bulk import users (admin only)
+ * Optionally pre-assign projects to jury members during invitation
*/
bulkCreate: adminProcedure
.input(
@@ -425,6 +427,15 @@ export const userRouter = router({
name: z.string().optional(),
role: z.enum(['JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
expertiseTags: z.array(z.string()).optional(),
+ // Optional pre-assignments for jury members
+ assignments: z
+ .array(
+ z.object({
+ projectId: z.string(),
+ roundId: z.string(),
+ })
+ )
+ .optional(),
})
),
})
@@ -456,10 +467,20 @@ export const userRouter = router({
return { created: 0, skipped }
}
+ // Build map of email -> assignments before createMany (since createMany removes extra fields)
+ const emailToAssignments = new Map>()
+ for (const u of newUsers) {
+ if (u.assignments && u.assignments.length > 0) {
+ emailToAssignments.set(u.email.toLowerCase(), u.assignments)
+ }
+ }
+
const created = await ctx.prisma.user.createMany({
data: newUsers.map((u) => ({
- ...u,
email: u.email.toLowerCase(),
+ name: u.name,
+ role: u.role,
+ expertiseTags: u.expertiseTags,
status: 'INVITED',
})),
})
@@ -483,6 +504,44 @@ export const userRouter = router({
select: { id: true, email: true, name: true, role: true },
})
+ // Create pre-assignments for users who have them
+ let assignmentsCreated = 0
+ for (const user of createdUsers) {
+ const assignments = emailToAssignments.get(user.email.toLowerCase())
+ if (assignments && assignments.length > 0) {
+ for (const assignment of assignments) {
+ try {
+ await ctx.prisma.assignment.create({
+ data: {
+ userId: user.id,
+ projectId: assignment.projectId,
+ roundId: assignment.roundId,
+ method: 'MANUAL',
+ createdBy: ctx.user.id,
+ },
+ })
+ assignmentsCreated++
+ } catch {
+ // Skip if assignment already exists (shouldn't happen for new users)
+ }
+ }
+ }
+ }
+
+ // Audit log for assignments if any were created
+ if (assignmentsCreated > 0) {
+ await ctx.prisma.auditLog.create({
+ data: {
+ userId: ctx.user.id,
+ action: 'BULK_ASSIGN',
+ entityType: 'Assignment',
+ detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ },
+ })
+ }
+
let emailsSent = 0
const emailErrors: string[] = []
@@ -525,7 +584,7 @@ export const userRouter = router({
}
}
- return { created: created.count, skipped, emailsSent, emailErrors }
+ return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated }
}),
/**
@@ -729,6 +788,7 @@ export const userRouter = router({
z.object({
name: z.string().min(1).max(255),
phoneNumber: z.string().optional(),
+ country: z.string().optional(),
expertiseTags: z.array(z.string()).optional(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
})
@@ -750,6 +810,7 @@ export const userRouter = router({
data: {
name: input.name,
phoneNumber: input.phoneNumber,
+ country: input.country,
expertiseTags: mergedTags,
notificationPreference: input.notificationPreference || 'EMAIL',
onboardingCompletedAt: new Date(),
@@ -782,8 +843,8 @@ export const userRouter = router({
select: { onboardingCompletedAt: true, role: true },
})
- // Jury members and mentors need onboarding
- const rolesRequiringOnboarding = ['JURY_MEMBER', 'MENTOR']
+ // Jury members, mentors, and admins need onboarding
+ const rolesRequiringOnboarding = ['JURY_MEMBER', 'MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN']
if (!rolesRequiringOnboarding.includes(user.role)) {
return false
}
diff --git a/src/server/services/ai-tagging.ts b/src/server/services/ai-tagging.ts
new file mode 100644
index 0000000..14f6940
--- /dev/null
+++ b/src/server/services/ai-tagging.ts
@@ -0,0 +1,541 @@
+/**
+ * AI-Powered Project Tagging Service
+ *
+ * Analyzes projects and assigns expertise tags automatically.
+ *
+ * Features:
+ * - Single project tagging (on-submit or manual)
+ * - Batch tagging for rounds
+ * - Confidence scores for each tag
+ * - Additive only - never removes existing tags
+ *
+ * GDPR Compliance:
+ * - All project data is anonymized before AI processing
+ * - Only necessary fields sent to OpenAI
+ * - No personal identifiers in prompts or responses
+ */
+
+import { prisma } from '@/lib/prisma'
+import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
+import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
+import { classifyAIError, createParseError, logAIError } from './ai-errors'
+import {
+ anonymizeProjectsForAI,
+ validateAnonymizedProjects,
+ type ProjectWithRelations,
+ type AnonymizedProjectForAI,
+ type ProjectAIMapping,
+} from './anonymization'
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export interface TagSuggestion {
+ tagId: string
+ tagName: string
+ confidence: number
+ reasoning: string
+}
+
+export interface TaggingResult {
+ projectId: string
+ suggestions: TagSuggestion[]
+ applied: TagSuggestion[]
+ tokensUsed: number
+}
+
+export interface BatchTaggingResult {
+ processed: number
+ failed: number
+ skipped: number
+ errors: string[]
+ results: TaggingResult[]
+}
+
+interface AvailableTag {
+ id: string
+ name: string
+ category: string | null
+ description: string | null
+}
+
+// ─── Constants ───────────────────────────────────────────────────────────────
+
+const DEFAULT_BATCH_SIZE = 10
+const MAX_BATCH_SIZE = 25
+const CONFIDENCE_THRESHOLD = 0.5
+const DEFAULT_MAX_TAGS = 5
+
+// System prompt optimized for tag suggestion
+const TAG_SUGGESTION_SYSTEM_PROMPT = `You are an expert at categorizing ocean conservation and sustainability projects.
+
+Analyze the project and suggest the most relevant expertise tags from the provided list.
+Consider the project's focus areas, technology, methodology, and domain.
+
+Return JSON with this format:
+{
+ "suggestions": [
+ {
+ "tag_name": "exact tag name from list",
+ "confidence": 0.0-1.0,
+ "reasoning": "brief explanation why this tag fits"
+ }
+ ]
+}
+
+Rules:
+- Only suggest tags from the provided list (exact names)
+- Order by relevance (most relevant first)
+- Confidence should reflect how well the tag matches
+- Maximum 7 suggestions per project
+- Be conservative - only suggest tags that truly apply`
+
+// ─── Helper Functions ────────────────────────────────────────────────────────
+
+/**
+ * Get system settings for AI tagging
+ */
+async function getTaggingSettings(): Promise<{
+ enabled: boolean
+ maxTags: number
+}> {
+ const settings = await prisma.systemSettings.findMany({
+ where: {
+ key: {
+ in: ['ai_tagging_enabled', 'ai_tagging_max_tags'],
+ },
+ },
+ })
+
+ const settingsMap = new Map(settings.map((s) => [s.key, s.value]))
+
+ return {
+ enabled: settingsMap.get('ai_tagging_enabled') === 'true',
+ maxTags: parseInt(settingsMap.get('ai_tagging_max_tags') || String(DEFAULT_MAX_TAGS)),
+ }
+}
+
+/**
+ * Get all active expertise tags
+ */
+async function getAvailableTags(): Promise {
+ return prisma.expertiseTag.findMany({
+ where: { isActive: true },
+ select: {
+ id: true,
+ name: true,
+ category: true,
+ description: true,
+ },
+ orderBy: [{ category: 'asc' }, { sortOrder: 'asc' }],
+ })
+}
+
+/**
+ * Convert project to format for anonymization
+ */
+function toProjectWithRelations(project: {
+ id: string
+ title: string
+ description?: string | null
+ competitionCategory?: string | null
+ oceanIssue?: string | null
+ country?: string | null
+ geographicZone?: string | null
+ institution?: string | null
+ tags: string[]
+ foundedAt?: Date | null
+ wantsMentorship?: boolean
+ submissionSource?: string
+ submittedAt?: Date | null
+ _count?: { teamMembers?: number; files?: number }
+ files?: Array<{ fileType: string | null }>
+}): ProjectWithRelations {
+ return {
+ id: project.id,
+ title: project.title,
+ description: project.description,
+ competitionCategory: project.competitionCategory as any,
+ oceanIssue: project.oceanIssue as any,
+ country: project.country,
+ geographicZone: project.geographicZone,
+ institution: project.institution,
+ tags: project.tags,
+ foundedAt: project.foundedAt,
+ wantsMentorship: project.wantsMentorship ?? false,
+ submissionSource: (project.submissionSource as any) ?? 'MANUAL',
+ submittedAt: project.submittedAt,
+ _count: {
+ teamMembers: project._count?.teamMembers ?? 0,
+ files: project._count?.files ?? 0,
+ },
+ files: project.files?.map((f) => ({ fileType: (f.fileType as any) ?? null })) ?? [],
+ }
+}
+
+// ─── AI Tagging Core ─────────────────────────────────────────────────────────
+
+/**
+ * Call OpenAI to get tag suggestions for a project
+ */
+async function getAISuggestions(
+ anonymizedProject: AnonymizedProjectForAI,
+ availableTags: AvailableTag[],
+ userId?: string
+): Promise<{ suggestions: TagSuggestion[]; tokensUsed: number }> {
+ const openai = await getOpenAI()
+ if (!openai) {
+ console.warn('[AI Tagging] OpenAI not configured')
+ return { suggestions: [], tokensUsed: 0 }
+ }
+
+ const model = await getConfiguredModel()
+
+ // Build tag list for prompt
+ const tagList = availableTags.map((t) => ({
+ name: t.name,
+ category: t.category,
+ description: t.description,
+ }))
+
+ const userPrompt = `PROJECT:
+${JSON.stringify(anonymizedProject, null, 2)}
+
+AVAILABLE TAGS:
+${JSON.stringify(tagList, null, 2)}
+
+Suggest relevant tags for this project.`
+
+ try {
+ const params = buildCompletionParams(model, {
+ messages: [
+ { role: 'system', content: TAG_SUGGESTION_SYSTEM_PROMPT },
+ { role: 'user', content: userPrompt },
+ ],
+ jsonMode: true,
+ temperature: 0.3,
+ maxTokens: 2000,
+ })
+
+ const response = await openai.chat.completions.create(params)
+ const usage = extractTokenUsage(response)
+
+ // Log usage
+ await logAIUsage({
+ userId,
+ action: 'PROJECT_TAGGING',
+ entityType: 'Project',
+ entityId: anonymizedProject.project_id,
+ model,
+ promptTokens: usage.promptTokens,
+ completionTokens: usage.completionTokens,
+ totalTokens: usage.totalTokens,
+ batchSize: 1,
+ itemsProcessed: 1,
+ status: 'SUCCESS',
+ })
+
+ const content = response.choices[0]?.message?.content
+ if (!content) {
+ throw new Error('Empty response from AI')
+ }
+
+ const parsed = JSON.parse(content) as {
+ suggestions: Array<{
+ tag_name: string
+ confidence: number
+ reasoning: string
+ }>
+ }
+
+ // Map to TagSuggestion format, matching tag names to IDs
+ const suggestions: TagSuggestion[] = []
+ for (const s of parsed.suggestions || []) {
+ const tag = availableTags.find(
+ (t) => t.name.toLowerCase() === s.tag_name.toLowerCase()
+ )
+ if (tag) {
+ suggestions.push({
+ tagId: tag.id,
+ tagName: tag.name,
+ confidence: Math.max(0, Math.min(1, s.confidence)),
+ reasoning: s.reasoning,
+ })
+ }
+ }
+
+ return { suggestions, tokensUsed: usage.totalTokens }
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ const parseError = createParseError(error.message)
+ logAIError('Tagging', 'getAISuggestions', parseError)
+ }
+
+ await logAIUsage({
+ userId,
+ action: 'PROJECT_TAGGING',
+ entityType: 'Project',
+ entityId: anonymizedProject.project_id,
+ model,
+ promptTokens: 0,
+ completionTokens: 0,
+ totalTokens: 0,
+ batchSize: 1,
+ itemsProcessed: 0,
+ status: 'ERROR',
+ errorMessage: error instanceof Error ? error.message : 'Unknown error',
+ })
+
+ throw error
+ }
+}
+
+// ─── Public API ──────────────────────────────────────────────────────────────
+
+/**
+ * Tag a single project with AI-suggested expertise tags
+ *
+ * Behavior:
+ * - Only applies tags with confidence >= 0.5
+ * - Additive only - never removes existing tags
+ * - Respects maxTags setting
+ */
+export async function tagProject(
+ projectId: string,
+ userId?: string
+): Promise {
+ const settings = await getTaggingSettings()
+ if (!settings.enabled) {
+ return {
+ projectId,
+ suggestions: [],
+ applied: [],
+ tokensUsed: 0,
+ }
+ }
+
+ // Fetch project with needed fields
+ const project = await prisma.project.findUnique({
+ where: { id: projectId },
+ include: {
+ projectTags: true,
+ files: { select: { fileType: true } },
+ _count: { select: { teamMembers: true, files: true } },
+ },
+ })
+
+ if (!project) {
+ throw new Error(`Project not found: ${projectId}`)
+ }
+
+ // Get available tags
+ const availableTags = await getAvailableTags()
+ if (availableTags.length === 0) {
+ return {
+ projectId,
+ suggestions: [],
+ applied: [],
+ tokensUsed: 0,
+ }
+ }
+
+ // Anonymize project data
+ const projectWithRelations = toProjectWithRelations(project)
+ const { anonymized, mappings } = anonymizeProjectsForAI([projectWithRelations], 'FILTERING')
+
+ // Validate anonymization
+ if (!validateAnonymizedProjects(anonymized)) {
+ throw new Error('GDPR compliance check failed: PII detected in anonymized data')
+ }
+
+ // Get AI suggestions
+ const { suggestions, tokensUsed } = await getAISuggestions(
+ anonymized[0],
+ availableTags,
+ userId
+ )
+
+ // Filter by confidence threshold
+ const validSuggestions = suggestions.filter(
+ (s) => s.confidence >= CONFIDENCE_THRESHOLD
+ )
+
+ // Get existing tag IDs to avoid duplicates
+ const existingTagIds = new Set(project.projectTags.map((pt) => pt.tagId))
+
+ // Calculate how many more tags we can add
+ const currentTagCount = project.projectTags.length
+ const remainingSlots = Math.max(0, settings.maxTags - currentTagCount)
+
+ // Filter out existing tags and limit to remaining slots
+ const newSuggestions = validSuggestions
+ .filter((s) => !existingTagIds.has(s.tagId))
+ .slice(0, remainingSlots)
+
+ // Apply new tags
+ const applied: TagSuggestion[] = []
+ for (const suggestion of newSuggestions) {
+ try {
+ await prisma.projectTag.create({
+ data: {
+ projectId,
+ tagId: suggestion.tagId,
+ confidence: suggestion.confidence,
+ source: 'AI',
+ },
+ })
+ applied.push(suggestion)
+ } catch (error) {
+ // Skip if tag already exists (race condition)
+ console.warn(`[AI Tagging] Failed to apply tag ${suggestion.tagName}: ${error}`)
+ }
+ }
+
+ return {
+ projectId,
+ suggestions,
+ applied,
+ tokensUsed,
+ }
+}
+
+/**
+ * Batch tag all untagged projects in a round
+ *
+ * Only processes projects with zero tags.
+ */
+export async function batchTagProjects(
+ roundId: string,
+ userId?: string,
+ onProgress?: (processed: number, total: number) => void
+): Promise {
+ const settings = await getTaggingSettings()
+ if (!settings.enabled) {
+ return {
+ processed: 0,
+ failed: 0,
+ skipped: 0,
+ errors: ['AI tagging is disabled'],
+ results: [],
+ }
+ }
+
+ // Get untagged projects in round
+ const projects = await prisma.project.findMany({
+ where: {
+ roundId,
+ projectTags: { none: {} }, // Only projects with no tags
+ },
+ include: {
+ files: { select: { fileType: true } },
+ _count: { select: { teamMembers: true, files: true } },
+ },
+ })
+
+ if (projects.length === 0) {
+ return {
+ processed: 0,
+ failed: 0,
+ skipped: 0,
+ errors: [],
+ results: [],
+ }
+ }
+
+ const results: TaggingResult[] = []
+ let processed = 0
+ let failed = 0
+ const errors: string[] = []
+
+ for (let i = 0; i < projects.length; i++) {
+ const project = projects[i]
+ try {
+ const result = await tagProject(project.id, userId)
+ results.push(result)
+ processed++
+ } catch (error) {
+ failed++
+ errors.push(`${project.title}: ${error instanceof Error ? error.message : 'Unknown error'}`)
+ }
+
+ // Report progress
+ if (onProgress) {
+ onProgress(i + 1, projects.length)
+ }
+ }
+
+ return {
+ processed,
+ failed,
+ skipped: 0,
+ errors,
+ results,
+ }
+}
+
+/**
+ * Get tag suggestions for a project without applying them
+ * Useful for preview/review before applying
+ */
+export async function getTagSuggestions(
+ projectId: string,
+ userId?: string
+): Promise {
+ // Fetch project
+ const project = await prisma.project.findUnique({
+ where: { id: projectId },
+ include: {
+ files: { select: { fileType: true } },
+ _count: { select: { teamMembers: true, files: true } },
+ },
+ })
+
+ if (!project) {
+ throw new Error(`Project not found: ${projectId}`)
+ }
+
+ // Get available tags
+ const availableTags = await getAvailableTags()
+ if (availableTags.length === 0) {
+ return []
+ }
+
+ // Anonymize project data
+ const projectWithRelations = toProjectWithRelations(project)
+ const { anonymized } = anonymizeProjectsForAI([projectWithRelations], 'FILTERING')
+
+ // Validate anonymization
+ if (!validateAnonymizedProjects(anonymized)) {
+ throw new Error('GDPR compliance check failed')
+ }
+
+ // Get AI suggestions
+ const { suggestions } = await getAISuggestions(anonymized[0], availableTags, userId)
+
+ return suggestions
+}
+
+/**
+ * Manually add a tag to a project
+ */
+export async function addProjectTag(
+ projectId: string,
+ tagId: string
+): Promise {
+ await prisma.projectTag.upsert({
+ where: { projectId_tagId: { projectId, tagId } },
+ create: { projectId, tagId, source: 'MANUAL', confidence: 1.0 },
+ update: { source: 'MANUAL', confidence: 1.0 },
+ })
+}
+
+/**
+ * Remove a tag from a project
+ */
+export async function removeProjectTag(
+ projectId: string,
+ tagId: string
+): Promise {
+ await prisma.projectTag.deleteMany({
+ where: { projectId, tagId },
+ })
+}
diff --git a/src/server/services/smart-assignment.ts b/src/server/services/smart-assignment.ts
new file mode 100644
index 0000000..a175393
--- /dev/null
+++ b/src/server/services/smart-assignment.ts
@@ -0,0 +1,381 @@
+/**
+ * Smart Assignment Scoring Service
+ *
+ * Calculates scores for jury/mentor-project matching based on:
+ * - Tag overlap (expertise match)
+ * - Workload balance
+ * - Country match (mentors only)
+ *
+ * Score Breakdown (100 points max):
+ * - Tag overlap: 0-50 points (weighted by confidence)
+ * - Workload balance: 0-25 points
+ * - Country match: 0-15 points (mentors only)
+ * - Reserved: 0-10 points (future AI boost)
+ */
+
+import { prisma } from '@/lib/prisma'
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export interface ScoreBreakdown {
+ tagOverlap: number
+ workloadBalance: number
+ countryMatch: number
+ aiBoost: number
+}
+
+export interface AssignmentScore {
+ userId: string
+ userName: string
+ userEmail: string
+ projectId: string
+ projectTitle: string
+ score: number
+ breakdown: ScoreBreakdown
+ reasoning: string[]
+ matchingTags: string[]
+}
+
+export interface ProjectTagData {
+ tagId: string
+ tagName: string
+ confidence: number
+}
+
+// ─── Constants ───────────────────────────────────────────────────────────────
+
+const MAX_TAG_OVERLAP_SCORE = 50
+const MAX_WORKLOAD_SCORE = 25
+const MAX_COUNTRY_SCORE = 15
+const POINTS_PER_TAG_MATCH = 10
+
+// ─── Scoring Functions ───────────────────────────────────────────────────────
+
+/**
+ * Calculate tag overlap score between user expertise and project tags
+ */
+export function calculateTagOverlapScore(
+ userTagNames: string[],
+ projectTags: ProjectTagData[]
+): { score: number; matchingTags: string[] } {
+ if (projectTags.length === 0 || userTagNames.length === 0) {
+ return { score: 0, matchingTags: [] }
+ }
+
+ const userTagSet = new Set(userTagNames.map((t) => t.toLowerCase()))
+ const matchingTags: string[] = []
+ let weightedScore = 0
+
+ for (const pt of projectTags) {
+ if (userTagSet.has(pt.tagName.toLowerCase())) {
+ matchingTags.push(pt.tagName)
+ // Weight by confidence - higher confidence = more points
+ weightedScore += POINTS_PER_TAG_MATCH * pt.confidence
+ }
+ }
+
+ // Cap at max score
+ const score = Math.min(MAX_TAG_OVERLAP_SCORE, Math.round(weightedScore))
+ return { score, matchingTags }
+}
+
+/**
+ * Calculate workload balance score
+ * Full points if under target, decreasing as over target
+ */
+export function calculateWorkloadScore(
+ currentAssignments: number,
+ targetAssignments: number,
+ maxAssignments?: number | null
+): number {
+ // If user is at or over their personal max, return 0
+ if (maxAssignments !== null && maxAssignments !== undefined) {
+ if (currentAssignments >= maxAssignments) {
+ return 0
+ }
+ }
+
+ // If under target, full points
+ if (currentAssignments < targetAssignments) {
+ return MAX_WORKLOAD_SCORE
+ }
+
+ // Over target - decrease score
+ const overload = currentAssignments - targetAssignments
+ return Math.max(0, MAX_WORKLOAD_SCORE - overload * 5)
+}
+
+/**
+ * Calculate country match score (mentors only)
+ * Same country = bonus points
+ */
+export function calculateCountryMatchScore(
+ userCountry: string | null | undefined,
+ projectCountry: string | null | undefined
+): number {
+ if (!userCountry || !projectCountry) {
+ return 0
+ }
+
+ // Normalize for comparison
+ const normalizedUser = userCountry.toLowerCase().trim()
+ const normalizedProject = projectCountry.toLowerCase().trim()
+
+ if (normalizedUser === normalizedProject) {
+ return MAX_COUNTRY_SCORE
+ }
+
+ return 0
+}
+
+// ─── Main Scoring Function ───────────────────────────────────────────────────
+
+/**
+ * Get smart assignment suggestions for a round
+ */
+export async function getSmartSuggestions(options: {
+ roundId: string
+ type: 'jury' | 'mentor'
+ limit?: number
+ aiMaxPerJudge?: number
+}): Promise {
+ const { roundId, type, limit = 50, aiMaxPerJudge = 20 } = options
+
+ // Get projects in round with their tags
+ const projects = await prisma.project.findMany({
+ where: {
+ roundId,
+ status: { not: 'REJECTED' },
+ },
+ include: {
+ projectTags: {
+ include: { tag: true },
+ },
+ },
+ })
+
+ if (projects.length === 0) {
+ return []
+ }
+
+ // Get users of the appropriate role
+ const role = type === 'jury' ? 'JURY_MEMBER' : 'MENTOR'
+ const users = await prisma.user.findMany({
+ where: {
+ role,
+ status: 'ACTIVE',
+ },
+ include: {
+ _count: {
+ select: {
+ assignments: {
+ where: { roundId },
+ },
+ },
+ },
+ },
+ })
+
+ if (users.length === 0) {
+ return []
+ }
+
+ // Get existing assignments to avoid duplicates
+ const existingAssignments = await prisma.assignment.findMany({
+ where: { roundId },
+ select: { userId: true, projectId: true },
+ })
+ const assignedPairs = new Set(
+ existingAssignments.map((a) => `${a.userId}:${a.projectId}`)
+ )
+
+ // Calculate target assignments per user
+ const targetPerUser = Math.ceil(projects.length / users.length)
+
+ // Calculate scores for all user-project pairs
+ const suggestions: AssignmentScore[] = []
+
+ for (const user of users) {
+ // Skip users at AI max (they won't appear in suggestions)
+ const currentCount = user._count.assignments
+ if (currentCount >= aiMaxPerJudge) {
+ continue
+ }
+
+ for (const project of projects) {
+ // Skip if already assigned
+ const pairKey = `${user.id}:${project.id}`
+ if (assignedPairs.has(pairKey)) {
+ continue
+ }
+
+ // Get project tags data
+ const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({
+ tagId: pt.tagId,
+ tagName: pt.tag.name,
+ confidence: pt.confidence,
+ }))
+
+ // Calculate scores
+ const { score: tagScore, matchingTags } = calculateTagOverlapScore(
+ user.expertiseTags,
+ projectTags
+ )
+
+ const workloadScore = calculateWorkloadScore(
+ currentCount,
+ targetPerUser,
+ user.maxAssignments
+ )
+
+ // Country match only for mentors
+ const countryScore =
+ type === 'mentor'
+ ? calculateCountryMatchScore(
+ (user as any).country, // User might have country field
+ project.country
+ )
+ : 0
+
+ const totalScore = tagScore + workloadScore + countryScore
+
+ // Build reasoning
+ const reasoning: string[] = []
+ if (matchingTags.length > 0) {
+ reasoning.push(`Expertise match: ${matchingTags.length} tag(s)`)
+ }
+ if (workloadScore === MAX_WORKLOAD_SCORE) {
+ reasoning.push('Available capacity')
+ } else if (workloadScore > 0) {
+ reasoning.push('Moderate workload')
+ }
+ if (countryScore > 0) {
+ reasoning.push('Same country')
+ }
+
+ suggestions.push({
+ userId: user.id,
+ userName: user.name || 'Unknown',
+ userEmail: user.email,
+ projectId: project.id,
+ projectTitle: project.title,
+ score: totalScore,
+ breakdown: {
+ tagOverlap: tagScore,
+ workloadBalance: workloadScore,
+ countryMatch: countryScore,
+ aiBoost: 0,
+ },
+ reasoning,
+ matchingTags,
+ })
+ }
+ }
+
+ // Sort by score descending and limit
+ return suggestions.sort((a, b) => b.score - a.score).slice(0, limit)
+}
+
+/**
+ * Get mentor suggestions for a specific project
+ */
+export async function getMentorSuggestionsForProject(
+ projectId: string,
+ limit: number = 10
+): Promise {
+ const project = await prisma.project.findUnique({
+ where: { id: projectId },
+ include: {
+ projectTags: {
+ include: { tag: true },
+ },
+ mentorAssignment: true,
+ },
+ })
+
+ if (!project) {
+ throw new Error(`Project not found: ${projectId}`)
+ }
+
+ // Get all active mentors
+ const mentors = await prisma.user.findMany({
+ where: {
+ role: 'MENTOR',
+ status: 'ACTIVE',
+ },
+ include: {
+ _count: {
+ select: { mentorAssignments: true },
+ },
+ },
+ })
+
+ if (mentors.length === 0) {
+ return []
+ }
+
+ const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({
+ tagId: pt.tagId,
+ tagName: pt.tag.name,
+ confidence: pt.confidence,
+ }))
+
+ const targetPerMentor = 5 // Target 5 projects per mentor
+
+ const suggestions: AssignmentScore[] = []
+
+ for (const mentor of mentors) {
+ // Skip if already assigned to this project
+ if (project.mentorAssignment?.mentorId === mentor.id) {
+ continue
+ }
+
+ const { score: tagScore, matchingTags } = calculateTagOverlapScore(
+ mentor.expertiseTags,
+ projectTags
+ )
+
+ const workloadScore = calculateWorkloadScore(
+ mentor._count.mentorAssignments,
+ targetPerMentor,
+ mentor.maxAssignments
+ )
+
+ const countryScore = calculateCountryMatchScore(
+ (mentor as any).country,
+ project.country
+ )
+
+ const totalScore = tagScore + workloadScore + countryScore
+
+ const reasoning: string[] = []
+ if (matchingTags.length > 0) {
+ reasoning.push(`${matchingTags.length} matching expertise tag(s)`)
+ }
+ if (countryScore > 0) {
+ reasoning.push('Same country of origin')
+ }
+ if (workloadScore === MAX_WORKLOAD_SCORE) {
+ reasoning.push('Available capacity')
+ }
+
+ suggestions.push({
+ userId: mentor.id,
+ userName: mentor.name || 'Unknown',
+ userEmail: mentor.email,
+ projectId: project.id,
+ projectTitle: project.title,
+ score: totalScore,
+ breakdown: {
+ tagOverlap: tagScore,
+ workloadBalance: workloadScore,
+ countryMatch: countryScore,
+ aiBoost: 0,
+ },
+ reasoning,
+ matchingTags,
+ })
+ }
+
+ return suggestions.sort((a, b) => b.score - a.score).slice(0, limit)
+}
diff --git a/src/server/utils/ai-usage.ts b/src/server/utils/ai-usage.ts
index faa1f6a..c540c49 100644
--- a/src/server/utils/ai-usage.ts
+++ b/src/server/utils/ai-usage.ts
@@ -16,6 +16,7 @@ export type AIAction =
| 'FILTERING'
| 'AWARD_ELIGIBILITY'
| 'MENTOR_MATCHING'
+ | 'PROJECT_TAGGING'
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'