feat: applicant onboarding, bulk invite, team management enhancements
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s

- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 10:11:11 +01:00
parent 68aa393559
commit 49e706f2cf
11 changed files with 1509 additions and 8 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "nationality" TEXT;
ALTER TABLE "User" ADD COLUMN "institution" TEXT;

View File

@@ -308,6 +308,8 @@ model User {
expertiseTags String[] @default([]) expertiseTags String[] @default([])
maxAssignments Int? // Per-round limit maxAssignments Int? // Per-round limit
country String? // User's home country (for mentor matching) country String? // User's home country (for mentor matching)
nationality String? // User's nationality (for applicant profiles)
institution String? // User's institution/organization
metadataJson Json? @db.JsonB metadataJson Json? @db.JsonB
// Profile // Profile

View File

@@ -46,6 +46,8 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { CountrySelect } from '@/components/ui/country-select'
import { Checkbox as CheckboxPrimitive } from '@/components/ui/checkbox'
import { import {
Users, Users,
UserPlus, UserPlus,
@@ -64,6 +66,10 @@ const inviteSchema = z.object({
email: z.string().email('Invalid email address'), email: z.string().email('Invalid email address'),
role: z.enum(['MEMBER', 'ADVISOR']), role: z.enum(['MEMBER', 'ADVISOR']),
title: z.string().optional(), title: z.string().optional(),
nationality: z.string().optional(),
country: z.string().optional(),
institution: z.string().optional(),
sendInvite: z.boolean().default(true),
}) })
type InviteFormData = z.infer<typeof inviteSchema> type InviteFormData = z.infer<typeof inviteSchema>
@@ -129,6 +135,10 @@ export default function ApplicantTeamPage() {
email: '', email: '',
role: 'MEMBER', role: 'MEMBER',
title: '', title: '',
nationality: '',
country: '',
institution: '',
sendInvite: true,
}, },
}) })
@@ -280,6 +290,42 @@ export default function ApplicantTeamPage() {
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nationality</Label>
<CountrySelect
value={form.watch('nationality') || ''}
onChange={(v) => form.setValue('nationality', v)}
placeholder="Select nationality"
/>
</div>
<div className="space-y-2">
<Label>Country of Residence</Label>
<CountrySelect
value={form.watch('country') || ''}
onChange={(v) => form.setValue('country', v)}
placeholder="Select country"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="institution">Institution (optional)</Label>
<Input
id="institution"
placeholder="e.g., Ocean Research Institute"
{...form.register('institution')}
/>
</div>
<div className="flex items-center gap-2">
<CheckboxPrimitive
id="sendInvite"
checked={form.watch('sendInvite')}
onCheckedChange={(checked) => form.setValue('sendInvite', !!checked)}
/>
<Label htmlFor="sendInvite" className="text-sm font-normal cursor-pointer">
Send platform invite email
</Label>
</div>
<div className="rounded-lg bg-muted/50 border p-3 text-sm"> <div className="rounded-lg bg-muted/50 border p-3 text-sm">
<p className="font-medium mb-1">What invited members can do:</p> <p className="font-medium mb-1">What invited members can do:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground"> <ul className="list-disc list-inside space-y-1 text-muted-foreground">

View File

@@ -35,6 +35,8 @@ export default async function AuthLayout({
redirect('/observer') redirect('/observer')
} else if (role === 'MENTOR') { } else if (role === 'MENTOR') {
redirect('/mentor') redirect('/mentor')
} else if (role === 'APPLICANT') {
redirect('/applicant')
} }
} }
// If user doesn't exist in DB, fall through and show auth page // If user doesn't exist in DB, fall through and show auth page

View File

@@ -0,0 +1,599 @@
'use client'
import { useState, useMemo, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CountrySelect } from '@/components/ui/country-select'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { AvatarUpload } from '@/components/shared/avatar-upload'
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
import { Textarea } from '@/components/ui/textarea'
import {
User,
Bell,
CheckCircle,
Loader2,
ArrowRight,
ArrowLeft,
Camera,
Globe,
FileText,
Building2,
Flag,
ImageIcon,
} from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
type Step =
| 'name'
| 'photo'
| 'nationality'
| 'country'
| 'institution'
| 'bio'
| 'logo'
| 'preferences'
| 'complete'
type ApplicantWizardProps = {
userData: {
id: string
name: string | null
email: string
role: string
country: string | null
nationality: string | null
institution: string | null
bio: string | null
profileImageKey: string | null
notificationPreference: string
}
avatarUrl: string | null | undefined
refetchUser: () => void
}
export function ApplicantOnboardingWizard({
userData,
avatarUrl,
refetchUser,
}: ApplicantWizardProps) {
const router = useRouter()
const [step, setStep] = useState<Step>('name')
const [initialized, setInitialized] = useState(false)
// Form state
const [name, setName] = useState('')
const [nationality, setNationality] = useState('')
const [country, setCountry] = useState('')
const [institution, setInstitution] = useState('')
const [bio, setBio] = useState('')
const [notificationPreference, setNotificationPreference] = useState<
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
>('EMAIL')
// Fetch onboarding context (project info)
const { data: onboardingCtx } = trpc.applicant.getOnboardingContext.useQuery()
const { data: logoUrl, refetch: refetchLogo } = trpc.applicant.getProjectLogoUrl.useQuery(
{ projectId: onboardingCtx?.projectId ?? '' },
{ enabled: !!onboardingCtx?.projectId }
)
// Initialize form with user data
useEffect(() => {
if (userData && !initialized) {
if (userData.name) setName(userData.name)
if (userData.country) setCountry(userData.country)
if (userData.nationality) setNationality(userData.nationality)
if (userData.institution) setInstitution(userData.institution)
if (userData.bio) setBio(userData.bio)
if (userData.notificationPreference) {
setNotificationPreference(userData.notificationPreference as typeof notificationPreference)
}
setInitialized(true)
}
}, [userData, initialized])
// Prefill institution from project if user hasn't set one
useEffect(() => {
if (onboardingCtx?.institution && !institution && initialized) {
setInstitution(onboardingCtx.institution)
}
}, [onboardingCtx, institution, initialized])
const utils = trpc.useUtils()
const completeOnboarding = trpc.user.completeOnboarding.useMutation({
onSuccess: () => utils.user.me.invalidate(),
})
const steps: Step[] = useMemo(() => {
const base: Step[] = [
'name',
'photo',
'nationality',
'country',
'institution',
'bio',
]
// Only show logo step if applicant has a project
if (onboardingCtx?.projectId) {
base.push('logo')
}
base.push('preferences', 'complete')
return base
}, [onboardingCtx?.projectId])
const currentIndex = steps.indexOf(step)
const totalVisibleSteps = steps.length - 1
const goNext = () => {
if (step === 'name' && !name.trim()) {
toast.error('Please enter your name')
return
}
const nextIndex = currentIndex + 1
if (nextIndex < steps.length) {
setStep(steps[nextIndex])
}
}
const goBack = () => {
const prevIndex = currentIndex - 1
if (prevIndex >= 0) {
setStep(steps[prevIndex])
}
}
const handleComplete = async () => {
try {
await completeOnboarding.mutateAsync({
name,
country: country || undefined,
nationality: nationality || undefined,
institution: institution || undefined,
bio: bio || undefined,
notificationPreference,
})
setStep('complete')
toast.success('Welcome to MOPC!')
setTimeout(() => {
router.push('/applicant')
}, 2000)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to complete onboarding')
}
}
const stepLabels: Record<Step, string> = {
name: 'Name',
photo: 'Photo',
nationality: 'Nationality',
country: 'Residence',
institution: 'Institution',
bio: 'About',
logo: 'Logo',
preferences: 'Settings',
complete: 'Done',
}
return (
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
<AnimatedCard>
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto overflow-x-hidden shadow-2xl">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
{/* Progress indicator */}
{step !== 'complete' && (
<div className="px-6 pt-6">
<div className="flex items-center gap-2">
{steps.slice(0, -1).map((s, i) => (
<div key={s} className="flex items-center flex-1">
<div
className={`h-2 flex-1 rounded-full transition-colors ${
i < currentIndex
? 'bg-primary'
: i === currentIndex
? 'bg-primary/60'
: 'bg-muted'
}`}
/>
</div>
))}
</div>
<div className="flex items-center gap-2 mt-1">
{steps.slice(0, -1).map((s, i) => (
<div key={s} className="flex-1 text-center">
<span
className={cn(
'text-[10px]',
i <= currentIndex ? 'text-primary font-medium' : 'text-muted-foreground'
)}
>
{stepLabels[s]}
</span>
</div>
))}
</div>
<p className="text-sm text-muted-foreground mt-2">
Step {currentIndex + 1} of {totalVisibleSteps}
</p>
</div>
)}
{/* Step: Name */}
{step === 'name' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
Welcome to MOPC
</CardTitle>
<CardDescription>
Let&apos;s get your profile set up. What should we call you?
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your full name"
autoFocus
/>
</div>
<Button onClick={goNext} className="w-full" disabled={!name.trim()}>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</>
)}
{/* Step: Profile Photo */}
{step === 'photo' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Camera className="h-5 w-5 text-primary" />
Profile Photo
</CardTitle>
<CardDescription>
Add a profile photo so others can recognize you. This step is optional.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-center">
<AvatarUpload
user={{
name: userData?.name,
email: userData?.email,
profileImageKey: userData?.profileImageKey,
}}
currentAvatarUrl={avatarUrl}
onUploadComplete={() => refetchUser()}
/>
</div>
<p className="text-sm text-muted-foreground text-center">
Click the avatar to upload a new photo.
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{avatarUrl ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Nationality */}
{step === 'nationality' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-primary" />
Nationality
</CardTitle>
<CardDescription>
Select your nationality.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="nationality">Nationality</Label>
<CountrySelect
value={nationality}
onChange={setNationality}
placeholder="Select your nationality"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{nationality ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Country of Residence */}
{step === 'country' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-primary" />
Country of Residence
</CardTitle>
<CardDescription>
Where are you currently based?
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="country">Country</Label>
<CountrySelect
value={country}
onChange={setCountry}
placeholder="Select your country"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{country ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Institution */}
{step === 'institution' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-primary" />
Institution
</CardTitle>
<CardDescription>
Your organization or institution name.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="institution">Institution / Organization</Label>
<Input
id="institution"
value={institution}
onChange={(e) => setInstitution(e.target.value)}
placeholder="e.g., Ocean Research Institute"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{institution ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Bio */}
{step === 'bio' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
About You
</CardTitle>
<CardDescription>
Tell us a bit about yourself and your work. (Optional)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder="e.g., Marine biologist working on coral reef conservation..."
rows={4}
maxLength={500}
/>
<p className="text-xs text-muted-foreground text-right">
{bio.length}/500 characters
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{bio ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Project Logo */}
{step === 'logo' && onboardingCtx?.projectId && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="h-5 w-5 text-primary" />
Project Logo
</CardTitle>
<CardDescription>
Upload a logo for &quot;{onboardingCtx.projectTitle}&quot;. This step is optional.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-center">
<ProjectLogoUpload
projectId={onboardingCtx.projectId}
currentLogoUrl={logoUrl}
onUploadComplete={() => refetchLogo()}
/>
</div>
<p className="text-sm text-muted-foreground text-center">
Click the image area to upload a logo.
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{logoUrl ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Notification Preferences */}
{step === 'preferences' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
Notification Preferences
</CardTitle>
<CardDescription>
How would you like to receive notifications?
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="notifications">Notification Channel</Label>
<Select
value={notificationPreference}
onValueChange={(v) =>
setNotificationPreference(v as typeof notificationPreference)
}
>
<SelectTrigger id="notifications">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EMAIL">Email only</SelectItem>
<SelectItem value="NONE">No notifications</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-lg border p-4 bg-muted/50">
<h4 className="font-medium mb-2">Summary</h4>
<div className="space-y-1 text-sm">
<p>
<span className="text-muted-foreground">Name:</span> {name}
</p>
{nationality && (
<p>
<span className="text-muted-foreground">Nationality:</span> {nationality}
</p>
)}
{country && (
<p>
<span className="text-muted-foreground">Country:</span> {country}
</p>
)}
{institution && (
<p>
<span className="text-muted-foreground">Institution:</span> {institution}
</p>
)}
{bio && (
<p>
<span className="text-muted-foreground">Bio:</span>{' '}
{bio.length > 50 ? `${bio.substring(0, 50)}...` : bio}
</p>
)}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
onClick={handleComplete}
className="flex-1"
disabled={completeOnboarding.isPending}
>
{completeOnboarding.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle className="mr-2 h-4 w-4" />
)}
Complete Setup
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Complete */}
{step === 'complete' && (
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="rounded-2xl bg-emerald-50 p-4 mb-4 animate-in zoom-in-50 duration-500">
<CheckCircle className="h-12 w-12 text-green-600" />
</div>
<h2 className="text-xl font-semibold mb-2 animate-in fade-in slide-in-from-bottom-2 duration-500 delay-200">
Welcome, {name}!
</h2>
<p className="text-muted-foreground text-center mb-4">
Your profile is all set up. You&apos;ll be redirected to your dashboard shortly.
</p>
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</CardContent>
)}
</Card>
</AnimatedCard>
</div>
)
}

View File

@@ -44,6 +44,7 @@ import {
Scale, Scale,
} from 'lucide-react' } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { ApplicantOnboardingWizard } from './applicant-wizard'
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'jury' | 'preferences' | 'complete' type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'jury' | 'preferences' | 'complete'
@@ -208,6 +209,8 @@ export default function OnboardingPage() {
router.push('/mentor') router.push('/mentor')
} else if (role === 'OBSERVER') { } else if (role === 'OBSERVER') {
router.push('/observer') router.push('/observer')
} else if (role === 'APPLICANT') {
router.push('/applicant')
} else { } else {
router.push('/jury') router.push('/jury')
} }
@@ -234,6 +237,17 @@ export default function OnboardingPage() {
) )
} }
// Applicant users get a dedicated onboarding wizard
if (userData?.role === 'APPLICANT') {
return (
<ApplicantOnboardingWizard
userData={userData as unknown as Parameters<typeof ApplicantOnboardingWizard>[0]['userData']}
avatarUrl={avatarUrl}
refetchUser={refetchUser}
/>
)
}
return ( return (
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat"> <div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
<AnimatedCard> <AnimatedCard>

View File

@@ -42,6 +42,8 @@ export default function SetPasswordPage() {
router.push('/jury') router.push('/jury')
} else if (session?.user?.role === 'SUPER_ADMIN' || session?.user?.role === 'PROGRAM_ADMIN') { } else if (session?.user?.role === 'SUPER_ADMIN' || session?.user?.role === 'PROGRAM_ADMIN') {
router.push('/admin') router.push('/admin')
} else if (session?.user?.role === 'APPLICANT') {
router.push('/applicant')
} else { } else {
router.push('/') router.push('/')
} }

View File

@@ -34,7 +34,7 @@ import { formatRelativeTime } from '@/lib/utils'
import { AnimatePresence, motion } from 'motion/react' import { AnimatePresence, motion } from 'motion/react'
type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins' type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins' | 'applicants'
const TAB_ROLES: Record<TabKey, RoleValue[] | undefined> = { const TAB_ROLES: Record<TabKey, RoleValue[] | undefined> = {
all: undefined, all: undefined,
@@ -42,6 +42,7 @@ const TAB_ROLES: Record<TabKey, RoleValue[] | undefined> = {
mentors: ['MENTOR'], mentors: ['MENTOR'],
observers: ['OBSERVER'], observers: ['OBSERVER'],
admins: ['SUPER_ADMIN', 'PROGRAM_ADMIN'], admins: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
applicants: undefined, // handled separately
} }
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = { const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
@@ -265,6 +266,7 @@ export function MembersContent() {
<TabsTrigger value="mentors">Mentors</TabsTrigger> <TabsTrigger value="mentors">Mentors</TabsTrigger>
<TabsTrigger value="observers">Observers</TabsTrigger> <TabsTrigger value="observers">Observers</TabsTrigger>
<TabsTrigger value="admins">Admins</TabsTrigger> <TabsTrigger value="admins">Admins</TabsTrigger>
<TabsTrigger value="applicants">Applicants</TabsTrigger>
</TabsList> </TabsList>
{/* Search */} {/* Search */}
@@ -280,8 +282,11 @@ export function MembersContent() {
</div> </div>
</Tabs> </Tabs>
{/* Content */} {/* Applicants tab */}
{isLoading ? ( {tab === 'applicants' && <ApplicantsTabContent search={search} searchInput={searchInput} setSearchInput={setSearchInput} />}
{/* Content (non-applicant tabs) */}
{tab !== 'applicants' && isLoading ? (
<MembersSkeleton /> <MembersSkeleton />
) : data && data.users.length > 0 ? ( ) : data && data.users.length > 0 ? (
<> <>
@@ -535,7 +540,7 @@ export function MembersContent() {
onPageChange={(newPage) => updateParams({ page: String(newPage) })} onPageChange={(newPage) => updateParams({ page: String(newPage) })}
/> />
</> </>
) : ( ) : tab !== 'applicants' ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> <CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Users className="h-12 w-12 text-muted-foreground/50" /> <Users className="h-12 w-12 text-muted-foreground/50" />
@@ -553,7 +558,7 @@ export function MembersContent() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
)} ) : null}
{/* Floating bulk invite toolbar */} {/* Floating bulk invite toolbar */}
<AnimatePresence> <AnimatePresence>
@@ -602,6 +607,227 @@ export function MembersContent() {
) )
} }
function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search: string; searchInput: string; setSearchInput: (v: string) => void }) {
const [page, setPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const utils = trpc.useUtils()
const { data, isLoading } = trpc.user.getApplicants.useQuery({
search: search || undefined,
page,
perPage: 20,
})
const bulkInvite = trpc.user.bulkInviteApplicants.useMutation({
onSuccess: (result) => {
const msg = `Sent ${result.sent} invite${result.sent !== 1 ? 's' : ''}`
if (result.failed.length > 0) {
toast.warning(`${msg}, ${result.failed.length} failed`)
} else {
toast.success(msg)
}
setSelectedIds(new Set())
utils.user.getApplicants.invalidate()
},
onError: (error) => toast.error(error.message),
})
const toggleUser = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const users = data?.users ?? []
const allSelected = users.length > 0 && users.every((u) => selectedIds.has(u.id))
const someSelected = users.some((u) => selectedIds.has(u.id)) && !allSelected
const toggleAll = useCallback(() => {
if (allSelected) {
setSelectedIds((prev) => {
const next = new Set(prev)
users.forEach((u) => next.delete(u.id))
return next
})
} else {
setSelectedIds((prev) => {
const next = new Set(prev)
users.forEach((u) => next.add(u.id))
return next
})
}
}, [allSelected, users])
if (isLoading) return <MembersSkeleton />
if (!data || data.users.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Users className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No applicants found</p>
<p className="text-sm text-muted-foreground">
{search ? 'Try adjusting your search' : 'Applicant users will appear here after CSV import or project submission'}
</p>
</CardContent>
</Card>
)
}
return (
<>
{/* Desktop table */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead>Applicant</TableHead>
<TableHead>Project</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Login</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<Checkbox
checked={selectedIds.has(user.id)}
onCheckedChange={() => toggleUser(user.id)}
/>
</TableCell>
<TableCell>
<div>
<p className="font-medium">{user.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
</TableCell>
<TableCell>
{user.projectName ? (
<span className="text-sm">{user.projectName}</span>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Badge variant={statusColors[user.status] || 'secondary'}>
{statusLabels[user.status] || user.status}
</Badge>
{user.status === 'NONE' && (
<InlineSendInvite userId={user.id} userEmail={user.email} />
)}
</div>
</TableCell>
<TableCell>
{user.lastLoginAt ? (
<span title={new Date(user.lastLoginAt).toLocaleString()}>
{formatRelativeTime(user.lastLoginAt)}
</span>
) : (
<span className="text-muted-foreground">Never</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Mobile cards */}
<div className="space-y-4 md:hidden">
{data.users.map((user) => (
<Card key={user.id}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Checkbox
checked={selectedIds.has(user.id)}
onCheckedChange={() => toggleUser(user.id)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<p className="font-medium">{user.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
{user.projectName && (
<p className="text-sm text-muted-foreground mt-1">{user.projectName}</p>
)}
</div>
<Badge variant={statusColors[user.status] || 'secondary'}>
{statusLabels[user.status] || user.status}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
{data.totalPages > 1 && (
<Pagination
page={page}
totalPages={data.totalPages}
total={data.total}
perPage={data.perPage}
onPageChange={setPage}
/>
)}
{/* Floating bulk invite bar */}
<AnimatePresence>
{selectedIds.size > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50"
>
<Card className="shadow-lg border-2">
<CardContent className="flex items-center gap-3 px-4 py-3">
<span className="text-sm font-medium whitespace-nowrap">
{selectedIds.size} selected
</span>
<Button
size="sm"
onClick={() => bulkInvite.mutate({ userIds: Array.from(selectedIds) })}
disabled={bulkInvite.isPending}
className="gap-1.5"
>
{bulkInvite.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
Send Invites
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedIds(new Set())}
disabled={bulkInvite.isPending}
className="gap-1.5"
>
<X className="h-4 w-4" />
Clear
</Button>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</>
)
}
function MembersSkeleton() { function MembersSkeleton() {
return ( return (
<Card> <Card>

View File

@@ -0,0 +1,279 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import Cropper from 'react-easy-crop'
import type { Area } from 'react-easy-crop'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Upload, Loader2, ZoomIn, ImageIcon } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
type ProjectLogoUploadProps = {
projectId: string
currentLogoUrl?: string | null
onUploadComplete?: () => void
}
const MAX_SIZE_MB = 5
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<Blob> {
const image = new Image()
image.crossOrigin = 'anonymous'
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve()
image.onerror = reject
image.src = imageSrc
})
const canvas = document.createElement('canvas')
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
)
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob)
else reject(new Error('Canvas toBlob failed'))
},
'image/png',
0.9
)
})
}
export function ProjectLogoUpload({
projectId,
currentLogoUrl,
onUploadComplete,
}: ProjectLogoUploadProps) {
const [open, setOpen] = useState(false)
const [imageSrc, setImageSrc] = useState<string | null>(null)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const [isUploading, setIsUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.applicant.getProjectLogoUploadUrl.useMutation()
const confirmUpload = trpc.applicant.confirmProjectLogo.useMutation()
const onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
setCroppedAreaPixels(croppedPixels)
}, [])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!ALLOWED_TYPES.includes(file.type)) {
toast.error('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.')
return
}
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
toast.error(`File too large. Maximum size is ${MAX_SIZE_MB}MB.`)
return
}
const reader = new FileReader()
reader.onload = (ev) => {
setImageSrc(ev.target?.result as string)
setCrop({ x: 0, y: 0 })
setZoom(1)
}
reader.readAsDataURL(file)
}, [])
const handleUpload = async () => {
if (!imageSrc || !croppedAreaPixels) return
setIsUploading(true)
try {
const croppedBlob = await getCroppedImg(imageSrc, croppedAreaPixels)
const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({
projectId,
fileName: 'logo.png',
contentType: 'image/png',
})
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: croppedBlob,
headers: { 'Content-Type': 'image/png' },
})
if (!uploadResponse.ok) {
throw new Error('Failed to upload file')
}
await confirmUpload.mutateAsync({ projectId, key, providerType })
toast.success('Project logo updated')
setOpen(false)
resetState()
onUploadComplete?.()
} catch (error) {
console.error('Upload error:', error)
toast.error('Failed to upload logo. Please try again.')
} finally {
setIsUploading(false)
}
}
const resetState = () => {
setImageSrc(null)
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedAreaPixels(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleCancel = () => {
resetState()
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
type="button"
className="relative mx-auto flex h-24 w-24 items-center justify-center rounded-xl border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 transition-colors cursor-pointer overflow-hidden bg-muted"
>
{currentLogoUrl ? (
<img src={currentLogoUrl} alt="Project logo" className="h-full w-full object-cover" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
)}
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Project Logo</DialogTitle>
<DialogDescription>
{imageSrc
? 'Drag to reposition and use the slider to zoom.'
: 'Upload a logo for your project. Allowed formats: JPEG, PNG, GIF, WebP.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{imageSrc ? (
<>
<div className="relative w-full h-64 bg-muted rounded-lg overflow-hidden">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
showGrid={false}
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
</div>
<div className="flex items-center gap-3 px-1">
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
<Slider
value={[zoom]}
min={1}
max={3}
step={0.1}
onValueChange={([val]) => setZoom(val)}
className="flex-1"
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
resetState()
fileInputRef.current?.click()
}}
className="w-full"
>
Choose a different image
</Button>
</>
) : (
<>
{currentLogoUrl && (
<div className="flex justify-center">
<img
src={currentLogoUrl}
alt="Current logo"
className="h-24 w-24 rounded-xl object-cover border"
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="logo">Select image</Label>
<Input
ref={fileInputRef}
id="logo"
type="file"
accept={ALLOWED_TYPES.join(',')}
onChange={handleFileSelect}
className="cursor-pointer"
/>
</div>
</>
)}
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row">
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1">
Cancel
</Button>
{imageSrc && (
<Button
onClick={handleUpload}
disabled={!croppedAreaPixels || isUploading}
className="flex-1"
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -3,6 +3,8 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc' import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey } from '@/lib/minio' import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
import { getImageUploadUrl, confirmImageUpload, getImageUrl, type ImageUploadConfig } from '@/server/utils/image-upload'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email' import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification' import { createNotification } from '../services/in-app-notification'
@@ -757,6 +759,10 @@ export const applicantRouter = router({
name: z.string().min(1), name: z.string().min(1),
role: z.enum(['MEMBER', 'ADVISOR']), role: z.enum(['MEMBER', 'ADVISOR']),
title: z.string().optional(), title: z.string().optional(),
nationality: z.string().optional(),
country: z.string().optional(),
institution: z.string().optional(),
sendInvite: z.boolean().default(true),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -813,9 +819,25 @@ export const applicantRouter = router({
email: normalizedEmail, email: normalizedEmail,
name: input.name, name: input.name,
role: 'APPLICANT', role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'NONE', status: 'NONE',
nationality: input.nationality,
country: input.country,
institution: input.institution,
}, },
}) })
} else {
// Update existing user with new profile fields if provided
const profileUpdates: Record<string, string> = {}
if (input.nationality && !user.nationality) profileUpdates.nationality = input.nationality
if (input.country && !user.country) profileUpdates.country = input.country
if (input.institution && !user.institution) profileUpdates.institution = input.institution
if (Object.keys(profileUpdates).length > 0) {
user = await ctx.prisma.user.update({
where: { id: user.id },
data: profileUpdates,
})
}
} }
if (user.status === 'SUSPENDED') { if (user.status === 'SUSPENDED') {
@@ -829,7 +851,10 @@ export const applicantRouter = router({
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const requiresAccountSetup = user.status !== 'ACTIVE' const requiresAccountSetup = user.status !== 'ACTIVE'
try { // If sendInvite is false, skip email entirely and leave user as NONE
if (!input.sendInvite) {
// No email, no status change — just create team membership below
} else try {
if (requiresAccountSetup) { if (requiresAccountSetup) {
const token = generateInviteToken() const token = generateInviteToken()
await ctx.prisma.user.update({ await ctx.prisma.user.update({
@@ -1607,7 +1632,8 @@ export const applicantRouter = router({
}> }>
}> = [] }> = []
for (const round of evalRounds) { for (let i = 0; i < evalRounds.length; i++) {
const round = evalRounds[i]
const parsed = EvaluationConfigSchema.safeParse(round.configJson) const parsed = EvaluationConfigSchema.safeParse(round.configJson)
if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue
@@ -1633,9 +1659,12 @@ export const applicantRouter = router({
orderBy: { submittedAt: 'asc' }, orderBy: { submittedAt: 'asc' },
}) })
// Mask round names: "Evaluation Round 1", "Evaluation Round 2", etc.
const maskedName = `Evaluation Round ${i + 1}`
results.push({ results.push({
roundId: round.id, roundId: round.id,
roundName: round.name, roundName: maskedName,
evaluationCount: evaluations.length, evaluationCount: evaluations.length,
evaluations: evaluations.map((ev) => ({ evaluations: evaluations.map((ev) => ({
id: ev.id, id: ev.id,
@@ -1752,4 +1781,155 @@ export const applicantRouter = router({
return results return results
}), }),
/**
* Get onboarding context for applicant wizard — project info, institution, logo status.
*/
getOnboardingContext: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role !== 'APPLICANT') {
return null
}
const project = await ctx.prisma.project.findFirst({
where: {
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: {
id: true,
title: true,
institution: true,
logoKey: true,
},
})
if (!project) return null
return {
projectId: project.id,
projectTitle: project.title,
institution: project.institution,
hasLogo: !!project.logoKey,
}
}),
/**
* Get a pre-signed URL for uploading a project logo (applicant access).
*/
getProjectLogoUploadUrl: protectedProcedure
.input(
z.object({
projectId: z.string(),
fileName: z.string(),
contentType: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify team membership
const isMember = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
})
if (!isMember) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
}
return getImageUploadUrl(
input.projectId,
input.fileName,
input.contentType,
generateLogoKey
)
}),
/**
* Confirm project logo upload (applicant access).
*/
confirmProjectLogo: protectedProcedure
.input(
z.object({
projectId: z.string(),
key: z.string(),
providerType: z.enum(['s3', 'local']),
})
)
.mutation(async ({ ctx, input }) => {
// Verify team membership
const isMember = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
})
if (!isMember) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
}
const logoConfig: ImageUploadConfig<{ logoKey: string | null; logoProvider: string | null }> = {
label: 'logo',
generateKey: generateLogoKey,
findCurrent: (prisma, entityId) =>
prisma.project.findUnique({
where: { id: entityId },
select: { logoKey: true, logoProvider: true },
}),
getImageKey: (record) => record.logoKey,
getProviderType: (record) =>
(record.logoProvider as StorageProviderType) || 's3',
setImage: (prisma, entityId, key, providerType) =>
prisma.project.update({
where: { id: entityId },
data: { logoKey: key, logoProvider: providerType },
}),
clearImage: (prisma, entityId) =>
prisma.project.update({
where: { id: entityId },
data: { logoKey: null, logoProvider: null },
}),
auditEntityType: 'Project',
auditFieldName: 'logoKey',
}
await confirmImageUpload(ctx.prisma, logoConfig, input.projectId, input.key, input.providerType, {
userId: ctx.user.id,
ip: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true }
}),
/**
* Get project logo URL (applicant access).
*/
getProjectLogoUrl: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const logoConfig = {
findCurrent: (prisma: typeof ctx.prisma, entityId: string) =>
prisma.project.findUnique({
where: { id: entityId },
select: { logoKey: true, logoProvider: true },
}),
getImageKey: (record: { logoKey: string | null }) => record.logoKey,
getProviderType: (record: { logoProvider: string | null }) =>
(record.logoProvider as StorageProviderType) || 's3' as StorageProviderType,
}
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
}),
}) })

View File

@@ -49,6 +49,8 @@ export const userRouter = router({
metadataJson: true, metadataJson: true,
phoneNumber: true, phoneNumber: true,
country: true, country: true,
nationality: true,
institution: true,
bio: true, bio: true,
notificationPreference: true, notificationPreference: true,
profileImageKey: true, profileImageKey: true,
@@ -109,6 +111,9 @@ export const userRouter = router({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
bio: z.string().max(1000).optional(), bio: z.string().max(1000).optional(),
phoneNumber: z.string().max(20).optional().nullable(), phoneNumber: z.string().max(20).optional().nullable(),
nationality: z.string().max(100).optional().nullable(),
institution: z.string().max(255).optional().nullable(),
country: z.string().max(100).optional(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(), notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
expertiseTags: z.array(z.string()).max(15).optional(), expertiseTags: z.array(z.string()).max(15).optional(),
digestFrequency: z.enum(['none', 'daily', 'weekly']).optional(), digestFrequency: z.enum(['none', 'daily', 'weekly']).optional(),
@@ -1147,6 +1152,8 @@ export const userRouter = router({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
phoneNumber: z.string().optional(), phoneNumber: z.string().optional(),
country: z.string().optional(), country: z.string().optional(),
nationality: z.string().optional(),
institution: z.string().optional(),
bio: z.string().max(500).optional(), bio: z.string().max(500).optional(),
expertiseTags: z.array(z.string()).optional(), expertiseTags: z.array(z.string()).optional(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(), notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
@@ -1181,6 +1188,8 @@ export const userRouter = router({
name: input.name, name: input.name,
phoneNumber: input.phoneNumber, phoneNumber: input.phoneNumber,
country: input.country, country: input.country,
nationality: input.nationality,
institution: input.institution,
bio: input.bio, bio: input.bio,
expertiseTags: mergedTags, expertiseTags: mergedTags,
notificationPreference: input.notificationPreference || 'EMAIL', notificationPreference: input.notificationPreference || 'EMAIL',
@@ -1552,4 +1561,143 @@ export const userRouter = router({
data: { roles: input.roles, role: primaryRole }, data: { roles: input.roles, role: primaryRole },
}) })
}), }),
/**
* List applicant users with project info for admin bulk-invite page.
*/
getApplicants: adminProcedure
.input(
z.object({
search: z.string().optional(),
roundId: z.string().optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
page: z.number().int().positive().default(1),
perPage: z.number().int().positive().max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const where: Prisma.UserWhereInput = {
role: 'APPLICANT',
...(input.status && { status: input.status }),
...(input.search && {
OR: [
{ name: { contains: input.search, mode: 'insensitive' as const } },
{ email: { contains: input.search, mode: 'insensitive' as const } },
{ teamMemberships: { some: { project: { title: { contains: input.search, mode: 'insensitive' as const } } } } },
],
}),
...(input.roundId && {
teamMemberships: { some: { project: { projectRoundStates: { some: { roundId: input.roundId } } } } },
}),
}
const [users, total] = await Promise.all([
ctx.prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: true,
status: true,
nationality: true,
institution: true,
lastLoginAt: true,
onboardingCompletedAt: true,
teamMemberships: {
take: 1,
select: {
role: true,
project: { select: { id: true, title: true } },
},
},
submittedProjects: {
take: 1,
select: { id: true, title: true },
},
},
orderBy: { name: 'asc' },
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
ctx.prisma.user.count({ where }),
])
return {
users: users.map((u) => {
const project = u.submittedProjects[0] || u.teamMemberships[0]?.project || null
return {
id: u.id,
email: u.email,
name: u.name,
status: u.status,
nationality: u.nationality,
institution: u.institution,
lastLoginAt: u.lastLoginAt,
onboardingCompleted: !!u.onboardingCompletedAt,
projectName: project?.title ?? null,
projectId: project?.id ?? null,
}
}),
total,
totalPages: Math.ceil(total / input.perPage),
perPage: input.perPage,
}
}),
/**
* Bulk invite applicant users — generates tokens, sets INVITED, sends emails.
*/
bulkInviteApplicants: adminProcedure
.input(z.object({ userIds: z.array(z.string()).min(1).max(500) }))
.mutation(async ({ ctx, input }) => {
const users = await ctx.prisma.user.findMany({
where: {
id: { in: input.userIds },
role: 'APPLICANT',
status: { in: ['NONE', 'INVITED'] },
},
select: { id: true, email: true, name: true, status: true },
})
const expiryMs = await getInviteExpiryMs(ctx.prisma)
let sent = 0
let skipped = 0
const failed: string[] = []
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
for (const user of users) {
try {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name || 'Applicant', inviteUrl, 'APPLICANT')
sent++
} catch (error) {
failed.push(user.email)
}
}
skipped = input.userIds.length - users.length
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_INVITE_APPLICANTS',
entityType: 'User',
entityId: 'bulk',
detailsJson: { sent, skipped, failed: failed.length, total: input.userIds.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, skipped, failed }
}),
}) })