feat: admin UX improvements — notify buttons, eval config, round finalization

Custom body support for advancement/rejection notification emails, evaluation
config toggle fix, user actions improvements, round finalization with reorder
support, project detail page enhancements, award pool duplicate prevention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 13:29:22 +01:00
parent f24bea3df2
commit 1103d42439
11 changed files with 606 additions and 265 deletions

View File

@@ -23,6 +23,29 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { FileViewer } from '@/components/shared/file-viewer'
import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
@@ -37,7 +60,6 @@ import {
Users,
FileText,
Calendar,
Clock,
BarChart3,
ThumbsUp,
ThumbsDown,
@@ -50,9 +72,11 @@ import {
Loader2,
ScanSearch,
Eye,
Plus,
X,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDate, formatDateOnly } from '@/lib/utils'
import { formatDateOnly } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
@@ -121,6 +145,42 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
// State for add member dialog
const [addMemberOpen, setAddMemberOpen] = useState(false)
const [addMemberForm, setAddMemberForm] = useState({
email: '',
name: '',
role: 'MEMBER' as 'LEAD' | 'MEMBER' | 'ADVISOR',
title: '',
sendInvite: true,
})
// State for remove member confirmation
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
const addTeamMember = trpc.project.addTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member added')
setAddMemberOpen(false)
setAddMemberForm({ email: '', name: '', role: 'MEMBER', title: '', sendInvite: true })
utils.project.getFullDetail.invalidate({ id: projectId })
},
onError: (err) => {
toast.error(err.message || 'Failed to add team member')
},
})
const removeTeamMember = trpc.project.removeTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member removed')
setRemovingMemberId(null)
utils.project.getFullDetail.invalidate({ id: projectId })
},
onError: (err) => {
toast.error(err.message || 'Failed to remove team member')
},
})
if (isLoading) {
return <ProjectDetailSkeleton />
}
@@ -184,9 +244,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
</h1>
<Badge variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}>
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
{(() => {
const prs = (project as any).projectRoundStates ?? []
if (!prs.length) return <Badge variant="secondary">Submitted</Badge>
if (prs.some((p: any) => p.state === 'REJECTED')) return <Badge variant="destructive">Rejected</Badge>
const latest = prs[0]
return <Badge variant={latest.state === 'PASSED' ? 'default' : 'secondary'}>{latest.round.name}</Badge>
})()}
</div>
{project.teamName && (
<p className="text-muted-foreground">{project.teamName}</p>
@@ -430,53 +494,203 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</AnimatedCard>
{/* Team Members Section */}
{project.teamMembers && project.teamMembers.length > 0 && (
<AnimatedCard index={2}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Users className="h-4 w-4 text-violet-500" />
</div>
Team Members ({project.teamMembers.length})
</CardTitle>
</div>
</CardHeader>
<CardContent>
<AnimatedCard index={2}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Users className="h-4 w-4 text-violet-500" />
</div>
Team Members ({project.teamMembers?.length ?? 0})
</CardTitle>
<Button variant="outline" size="sm" onClick={() => setAddMemberOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Member
</Button>
</div>
</CardHeader>
<CardContent>
{project.teamMembers && project.teamMembers.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => (
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
{member.role === 'LEAD' ? (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<Crown className="h-5 w-5 text-yellow-500" />
</div>
) : (
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate">
{member.user.name || 'Unnamed'}
</p>
<Badge variant="outline" className="text-xs">
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{member.user.email}
</p>
{member.title && (
<p className="text-xs text-muted-foreground">{member.title}</p>
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => {
const isLastLead =
member.role === 'LEAD' &&
project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1
return (
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
{member.role === 'LEAD' ? (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<Crown className="h-5 w-5 text-yellow-500" />
</div>
) : (
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate">
{member.user.name || 'Unnamed'}
</p>
<Badge variant="outline" className="text-xs">
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{member.user.email}
</p>
{member.title && (
<p className="text-xs text-muted-foreground">{member.title}</p>
)}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
disabled={isLastLead}
onClick={() => setRemovingMemberId(member.user.id)}
>
<X className="h-4 w-4" />
</Button>
</span>
</TooltipTrigger>
{isLastLead && (
<TooltipContent>
Cannot remove the last team lead
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
</div>
))}
)
})}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
) : (
<p className="text-sm text-muted-foreground">No team members yet.</p>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Add Member Dialog */}
<Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Team Member</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="member-email">Email</Label>
<Input
id="member-email"
type="email"
placeholder="member@example.com"
value={addMemberForm.email}
onChange={(e) => setAddMemberForm((f) => ({ ...f, email: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="member-name">Name</Label>
<Input
id="member-name"
placeholder="Full name"
value={addMemberForm.name}
onChange={(e) => setAddMemberForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="member-role">Role</Label>
<Select
value={addMemberForm.role}
onValueChange={(v) => setAddMemberForm((f) => ({ ...f, role: v as 'LEAD' | 'MEMBER' | 'ADVISOR' }))}
>
<SelectTrigger id="member-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LEAD">Lead</SelectItem>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="member-title">Title (optional)</Label>
<Input
id="member-title"
placeholder="e.g. CEO, Co-founder"
value={addMemberForm.title}
onChange={(e) => setAddMemberForm((f) => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="member-invite"
checked={addMemberForm.sendInvite}
onCheckedChange={(checked) =>
setAddMemberForm((f) => ({ ...f, sendInvite: checked === true }))
}
/>
<Label htmlFor="member-invite" className="font-normal cursor-pointer">
Send invite email
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddMemberOpen(false)}>
Cancel
</Button>
<Button
onClick={() =>
addTeamMember.mutate({
projectId,
email: addMemberForm.email,
name: addMemberForm.name,
role: addMemberForm.role,
title: addMemberForm.title || undefined,
sendInvite: addMemberForm.sendInvite,
})
}
disabled={addTeamMember.isPending || !addMemberForm.email || !addMemberForm.name}
>
{addTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add Member
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Remove Member Confirmation Dialog */}
<Dialog open={!!removingMemberId} onOpenChange={(open) => { if (!open) setRemovingMemberId(null) }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Remove Team Member</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to remove this team member? This action cannot be undone.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setRemovingMemberId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (removingMemberId) {
removeTeamMember.mutate({ projectId, userId: removingMemberId })
}
}}
disabled={removeTeamMember.isPending}
>
{removeTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Mentor Assignment Section */}
{project.wantsMentorship && (

View File

@@ -432,6 +432,7 @@ export function MembersContent() {
userEmail={user.email}
userStatus={user.status}
userRole={user.role as RoleValue}
userRoles={(user as unknown as { roles?: RoleValue[] }).roles}
currentUserRole={currentUserRole}
/>
</TableCell>
@@ -524,6 +525,7 @@ export function MembersContent() {
userEmail={user.email}
userStatus={user.status}
userRole={user.role as RoleValue}
userRoles={(user as unknown as { roles?: RoleValue[] }).roles}
currentUserRole={currentUserRole}
/>
</CardContent>

View File

@@ -4,6 +4,8 @@ import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Trophy } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { EmailPreviewDialog } from './email-preview-dialog'
interface NotifyAdvancedButtonProps {
@@ -14,9 +16,10 @@ interface NotifyAdvancedButtonProps {
export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedButtonProps) {
const [open, setOpen] = useState(false)
const [customMessage, setCustomMessage] = useState<string | undefined>()
const [fullCustomBody, setFullCustomBody] = useState(false)
const preview = trpc.round.previewAdvancementEmail.useQuery(
{ roundId, targetRoundId, customMessage },
{ roundId, targetRoundId, customMessage, fullCustomBody },
{ enabled: open }
)
@@ -32,18 +35,31 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB
return (
<>
<button
onClick={() => setOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<Trophy className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Notify Advanced Teams</p>
<p className="text-xs text-muted-foreground mt-0.5">
Send advancement emails to passed projects
</p>
<div className="space-y-2">
<button
onClick={() => setOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
>
<Trophy className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Notify Advanced Teams</p>
<p className="text-xs text-muted-foreground mt-0.5">
Send advancement emails to passed projects
</p>
</div>
</button>
<div className="flex items-center gap-2 px-1">
<Switch
id="advancement-full-custom-body"
checked={fullCustomBody}
onCheckedChange={setFullCustomBody}
/>
<Label htmlFor="advancement-full-custom-body" className="text-xs cursor-pointer">
<span className="font-medium">Full custom body</span>
<span className="text-muted-foreground ml-1"> only your message is sent (no standard text)</span>
</Label>
</div>
</button>
</div>
<EmailPreviewDialog
open={open}
@@ -53,7 +69,7 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB
recipientCount={preview.data?.recipientCount ?? 0}
previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading}
onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg })}
onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg, fullCustomBody })}
isSending={sendMutation.isPending}
onRefreshPreview={(msg) => setCustomMessage(msg)}
/>

View File

@@ -4,6 +4,8 @@ import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { XCircle } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { EmailPreviewDialog } from './email-preview-dialog'
interface NotifyRejectedButtonProps {
@@ -13,9 +15,10 @@ interface NotifyRejectedButtonProps {
export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
const [open, setOpen] = useState(false)
const [customMessage, setCustomMessage] = useState<string | undefined>()
const [fullCustomBody, setFullCustomBody] = useState(false)
const preview = trpc.round.previewRejectionEmail.useQuery(
{ roundId, customMessage },
{ roundId, customMessage, fullCustomBody },
{ enabled: open }
)
@@ -31,18 +34,31 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
return (
<>
<button
onClick={() => setOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-red-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Notify Non-Advanced</p>
<p className="text-xs text-muted-foreground mt-0.5">
Send rejection emails to non-advanced projects
</p>
<div className="space-y-2">
<button
onClick={() => setOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-red-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
>
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Notify Non-Advanced</p>
<p className="text-xs text-muted-foreground mt-0.5">
Send rejection emails to non-advanced projects
</p>
</div>
</button>
<div className="flex items-center gap-2 px-1">
<Switch
id="rejection-full-custom-body"
checked={fullCustomBody}
onCheckedChange={setFullCustomBody}
/>
<Label htmlFor="rejection-full-custom-body" className="text-xs cursor-pointer">
<span className="font-medium">Full custom body</span>
<span className="text-muted-foreground ml-1"> only your message is sent (no standard text)</span>
</Label>
</div>
</button>
</div>
<EmailPreviewDialog
open={open}
@@ -52,7 +68,7 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
recipientCount={preview.data?.recipientCount ?? 0}
previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading}
onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg })}
onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg, fullCustomBody })}
isSending={sendMutation.isPending}
onRefreshPreview={(msg) => setCustomMessage(msg)}
/>

View File

@@ -26,7 +26,7 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
}
const visConfig = (config.applicantVisibility as {
enabled?: boolean; showGlobalScore?: boolean; showCriterionScores?: boolean; showFeedbackText?: boolean
enabled?: boolean; showGlobalScore?: boolean; showCriterionScores?: boolean; showFeedbackText?: boolean; hideFromRejected?: boolean
}) ?? {}
const updateVisibility = (key: string, value: unknown) => {
@@ -293,6 +293,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="hideFromRejected">Hide from Rejected Applicants</Label>
<p className="text-xs text-muted-foreground">Applicants whose project was rejected will not see evaluations from this round</p>
</div>
<Switch
id="hideFromRejected"
checked={visConfig.hideFromRejected ?? false}
onCheckedChange={(v) => updateVisibility('hideFromRejected', v)}
/>
</div>
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
Evaluations are only visible to applicants after this round closes.
</p>

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
@@ -32,7 +33,6 @@ import {
Trash2,
Loader2,
Shield,
Check,
} from 'lucide-react'
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
@@ -50,10 +50,11 @@ interface UserActionsProps {
userEmail: string
userStatus: string
userRole: Role
userRoles?: Role[]
currentUserRole?: Role
}
export function UserActions({ userId, userEmail, userStatus, userRole, currentUserRole }: UserActionsProps) {
export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false)
@@ -64,13 +65,13 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs
utils.user.list.invalidate()
},
})
const updateUser = trpc.user.update.useMutation({
const updateRoles = trpc.user.updateRoles.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
toast.success('Role updated successfully')
toast.success('Roles updated successfully')
},
onError: (error) => {
toast.error(error.message || 'Failed to update role')
toast.error(error.message || 'Failed to update roles')
},
})
@@ -88,9 +89,20 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs
// Can this user's role be changed by the current user?
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const handleRoleChange = (newRole: Role) => {
if (newRole === userRole) return
updateUser.mutate({ id: userId, role: newRole })
// Current roles for this user (array or fallback to single role)
const currentRoles: Role[] = userRoles?.length ? userRoles : [userRole]
const handleToggleRole = (role: Role) => {
const has = currentRoles.includes(role)
let newRoles: Role[]
if (has) {
// Don't allow removing the last role
if (currentRoles.length <= 1) return
newRoles = currentRoles.filter(r => r !== role)
} else {
newRoles = [...currentRoles, role]
}
updateRoles.mutate({ userId, roles: newRoles })
}
const handleSendInvitation = async () => {
@@ -144,22 +156,20 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs
</DropdownMenuItem>
{canChangeRole && (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={updateUser.isPending}>
<DropdownMenuSubTrigger disabled={updateRoles.isPending}>
<Shield className="mr-2 h-4 w-4" />
{updateUser.isPending ? 'Updating...' : 'Change Role'}
{updateRoles.isPending ? 'Updating...' : 'Roles'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{getAvailableRoles().map((role) => (
<DropdownMenuItem
<DropdownMenuCheckboxItem
key={role}
onClick={() => handleRoleChange(role)}
disabled={role === userRole}
checked={currentRoles.includes(role)}
onCheckedChange={() => handleToggleRole(role)}
disabled={currentRoles.includes(role) && currentRoles.length <= 1}
>
{role === userRole && <Check className="mr-2 h-4 w-4" />}
<span className={role === userRole ? 'font-medium' : role !== userRole ? 'ml-6' : ''}>
{ROLE_LABELS[role]}
</span>
</DropdownMenuItem>
{ROLE_LABELS[role]}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
@@ -214,6 +224,7 @@ interface UserMobileActionsProps {
userEmail: string
userStatus: string
userRole: Role
userRoles?: Role[]
currentUserRole?: Role
}
@@ -222,23 +233,25 @@ export function UserMobileActions({
userEmail,
userStatus,
userRole,
userRoles,
currentUserRole,
}: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false)
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation()
const updateUser = trpc.user.update.useMutation({
const updateRoles = trpc.user.updateRoles.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
toast.success('Role updated successfully')
toast.success('Roles updated successfully')
},
onError: (error) => {
toast.error(error.message || 'Failed to update role')
toast.error(error.message || 'Failed to update roles')
},
})
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const currentRoles: Role[] = userRoles?.length ? userRoles : [userRole]
const handleSendInvitation = async () => {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
@@ -283,21 +296,31 @@ export function UserMobileActions({
</Button>
</div>
{canChangeRole && (
<select
value={userRole}
onChange={(e) => updateUser.mutate({ id: userId, role: e.target.value as Role })}
disabled={updateUser.isPending}
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
>
<div className="flex flex-wrap gap-1.5">
{(isSuperAdmin
? (['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
: (['JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
).map((role) => (
<option key={role} value={role}>
{ROLE_LABELS[role]}
</option>
))}
</select>
).map((role) => {
const isActive = currentRoles.includes(role)
return (
<Button
key={role}
variant={isActive ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
disabled={updateRoles.isPending || (isActive && currentRoles.length <= 1)}
onClick={() => {
const newRoles = isActive
? currentRoles.filter(r => r !== role)
: [...currentRoles, role]
updateRoles.mutate({ userId, roles: newRoles })
}}
>
{ROLE_LABELS[role]}
</Button>
)
})}
</div>
)}
</div>
)

View File

@@ -9,10 +9,11 @@ import { createBulkNotifications } from '../services/in-app-notification'
import {
getAdvancementNotificationTemplate,
getRejectionNotificationTemplate,
sendStyledNotificationEmail,
sendInvitationEmail,
getBaseUrl,
} from '@/lib/email'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
import {
openWindow,
@@ -812,10 +813,11 @@ export const roundRouter = router({
roundId: z.string(),
targetRoundId: z.string().optional(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
})
)
.query(async ({ ctx, input }) => {
const { roundId, targetRoundId, customMessage } = input
const { roundId, targetRoundId, customMessage, fullCustomBody } = input
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
@@ -865,7 +867,9 @@ export const roundRouter = router({
'Your Project',
currentRound.name,
toRoundName,
customMessage || undefined
customMessage || undefined,
undefined,
fullCustomBody,
)
return { html: template.html, subject: template.subject, recipientCount }
@@ -877,11 +881,12 @@ export const roundRouter = router({
roundId: z.string(),
targetRoundId: z.string().optional(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
projectIds: z.array(z.string()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, targetRoundId, customMessage } = input
const { roundId, targetRoundId, customMessage, fullCustomBody } = input
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
@@ -922,48 +927,47 @@ export const roundRouter = router({
},
})
let sent = 0
let failed = 0
const allUserIds = new Set<string>()
const items: NotificationItem[] = []
for (const project of projects) {
const recipients = new Map<string, string | null>()
const recipients = new Map<string, { name: string | null; userId: string }>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
allUserIds.add(tm.user.id)
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
recipients.set(project.submittedByEmail, { name: null, userId: '' })
}
for (const [email, name] of recipients) {
try {
await sendStyledNotificationEmail(
email,
name || '',
'ADVANCEMENT_NOTIFICATION',
{
title: 'Your project has advanced!',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
fromRoundName: currentRound.name,
toRoundName,
customMessage: customMessage || undefined,
},
}
)
sent++
} catch (err) {
console.error(`[sendAdvancementNotifications] Failed for ${email}:`, err)
failed++
}
for (const [email, { name, userId }] of recipients) {
items.push({
email,
name: name || '',
type: 'ADVANCEMENT_NOTIFICATION',
context: {
title: 'Your project has advanced!',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
fromRoundName: currentRound.name,
toRoundName,
customMessage: customMessage || undefined,
fullCustomBody,
},
},
projectId: project.id,
userId: userId || undefined,
roundId,
})
}
}
const result = await sendBatchNotifications(items)
// Create in-app notifications
if (allUserIds.size > 0) {
void createBulkNotifications({
@@ -985,12 +989,12 @@ export const roundRouter = router({
action: 'SEND_ADVANCEMENT_NOTIFICATIONS',
entityType: 'Round',
entityId: roundId,
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
detailsJson: { sent: result.sent, failed: result.failed, projectCount: projectIds.length, customMessage: !!customMessage },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, failed }
return { sent: result.sent, failed: result.failed }
}),
previewRejectionEmail: adminProcedure
@@ -998,22 +1002,36 @@ export const roundRouter = router({
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
})
)
.query(async ({ ctx, input }) => {
const { roundId, customMessage } = input
const { roundId, customMessage, fullCustomBody } = input
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true },
select: { name: true, roundType: true },
})
// Count recipients: team members of REJECTED projects
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
// For FILTERING rounds, also count projects filtered out via FilteringResult
let projectIds: string[]
if (round.roundType === 'FILTERING') {
const fromPRS = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const fromFR = await ctx.prisma.filteringResult.findMany({
where: { roundId, finalOutcome: 'FILTERED_OUT' },
select: { projectId: true },
})
projectIds = [...new Set([...fromPRS, ...fromFR].map((p) => p.projectId))]
} else {
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
projectIds = projectStates.map((ps) => ps.projectId)
}
let recipientCount = 0
if (projectIds.length > 0) {
@@ -1039,7 +1057,8 @@ export const roundRouter = router({
'Team Member',
'Your Project',
round.name,
customMessage || undefined
customMessage || undefined,
fullCustomBody,
)
return { html: template.html, subject: template.subject, recipientCount }
@@ -1050,21 +1069,36 @@ export const roundRouter = router({
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, customMessage } = input
const { roundId, customMessage, fullCustomBody } = input
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true },
select: { name: true, roundType: true },
})
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
// For FILTERING rounds, also include projects filtered out via FilteringResult
let projectIds: string[]
if (round.roundType === 'FILTERING') {
const fromPRS = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const fromFR = await ctx.prisma.filteringResult.findMany({
where: { roundId, finalOutcome: 'FILTERED_OUT' },
select: { projectId: true },
})
projectIds = [...new Set([...fromPRS, ...fromFR].map((p) => p.projectId))]
} else {
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
projectIds = projectStates.map((ps) => ps.projectId)
}
if (projectIds.length === 0) {
return { sent: 0, failed: 0 }
@@ -1082,47 +1116,46 @@ export const roundRouter = router({
},
})
let sent = 0
let failed = 0
const allUserIds = new Set<string>()
const items: NotificationItem[] = []
for (const project of projects) {
const recipients = new Map<string, string | null>()
const recipients = new Map<string, { name: string | null; userId: string }>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
allUserIds.add(tm.user.id)
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
recipients.set(project.submittedByEmail, { name: null, userId: '' })
}
for (const [email, name] of recipients) {
try {
await sendStyledNotificationEmail(
email,
name || '',
'REJECTION_NOTIFICATION',
{
title: 'Update on your application',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
roundName: round.name,
customMessage: customMessage || undefined,
},
}
)
sent++
} catch (err) {
console.error(`[sendRejectionNotifications] Failed for ${email}:`, err)
failed++
}
for (const [email, { name, userId }] of recipients) {
items.push({
email,
name: name || '',
type: 'REJECTION_NOTIFICATION',
context: {
title: 'Update on your application',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
roundName: round.name,
customMessage: customMessage || undefined,
fullCustomBody,
},
},
projectId: project.id,
userId: userId || undefined,
roundId,
})
}
}
const result = await sendBatchNotifications(items)
// In-app notifications
if (allUserIds.size > 0) {
void createBulkNotifications({
@@ -1142,12 +1175,12 @@ export const roundRouter = router({
action: 'SEND_REJECTION_NOTIFICATIONS',
entityType: 'Round',
entityId: roundId,
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
detailsJson: { sent: result.sent, failed: result.failed, projectCount: projectIds.length, customMessage: !!customMessage },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, failed }
return { sent: result.sent, failed: result.failed }
}),
getBulkInvitePreview: adminProcedure

View File

@@ -4,8 +4,10 @@ import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { processEligibilityJob } from '../services/award-eligibility-job'
import { sendStyledNotificationEmail, getAwardSelectionNotificationTemplate } from '@/lib/email'
import { getAwardSelectionNotificationTemplate } from '@/lib/email'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
import type { PrismaClient } from '@prisma/client'
/**
@@ -1270,8 +1272,18 @@ export const specialAwardRouter = router({
})
// Get eligible projects that haven't been notified yet
// Exclude projects that have been rejected at any stage
const eligibilities = await ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, eligible: true, notifiedAt: null },
where: {
awardId: input.awardId,
eligible: true,
notifiedAt: null,
project: {
projectRoundStates: {
none: { state: 'REJECTED' },
},
},
},
select: {
id: true,
projectId: true,
@@ -1324,12 +1336,12 @@ export const specialAwardRouter = router({
})
}
// Send emails
let emailsSent = 0
let emailsFailed = 0
// Build notification items — track which eligibility each email belongs to
const items: NotificationItem[] = []
const eligibilityEmailMap = new Map<string, Set<string>>() // eligibilityId → Set<email>
for (const e of eligibilities) {
const recipients: Array<{ id: string; email: string; name: string | null; passwordHash: string | null }> = []
const recipients: Array<{ id: string; email: string; name: string | null }> = []
if (e.project.submittedBy) recipients.push(e.project.submittedBy)
for (const tm of e.project.teamMembers) {
if (!recipients.some((r) => r.id === tm.user.id)) {
@@ -1337,39 +1349,46 @@ export const specialAwardRouter = router({
}
}
const emails = new Set<string>()
for (const recipient of recipients) {
const token = tokenMap.get(recipient.id)
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
emails.add(recipient.email)
try {
await sendStyledNotificationEmail(
recipient.email,
recipient.name || '',
'AWARD_SELECTION_NOTIFICATION',
{
title: `Under consideration for ${award.name}`,
message: input.customMessage || '',
metadata: {
projectName: e.project.title,
awardName: award.name,
customMessage: input.customMessage,
accountUrl,
},
items.push({
email: recipient.email,
name: recipient.name || '',
type: 'AWARD_SELECTION_NOTIFICATION',
context: {
title: `Under consideration for ${award.name}`,
message: input.customMessage || '',
metadata: {
projectName: e.project.title,
awardName: award.name,
customMessage: input.customMessage,
accountUrl,
},
)
emailsSent++
} catch (err) {
console.error(`[award-notify] Failed to email ${recipient.email}:`, err)
emailsFailed++
}
},
projectId: e.projectId,
userId: recipient.id,
})
}
eligibilityEmailMap.set(e.id, emails)
}
// Stamp notifiedAt on all processed eligibilities to prevent re-notification
const notifiedIds = eligibilities.map((e) => e.id)
if (notifiedIds.length > 0) {
const result = await sendBatchNotifications(items)
// Determine which eligibilities had zero failures
const failedEmails = new Set(result.errors.map((e) => e.email))
const successfulEligibilityIds: string[] = []
for (const [eligId, emails] of eligibilityEmailMap) {
const hasFailure = [...emails].some((email) => failedEmails.has(email))
if (!hasFailure) successfulEligibilityIds.push(eligId)
}
if (successfulEligibilityIds.length > 0) {
await ctx.prisma.awardEligibility.updateMany({
where: { id: { in: notifiedIds } },
where: { id: { in: successfulEligibilityIds } },
data: { notifiedAt: new Date() },
})
}
@@ -1383,14 +1402,15 @@ export const specialAwardRouter = router({
detailsJson: {
action: 'NOTIFY_ELIGIBLE_PROJECTS',
eligibleCount: eligibilities.length,
emailsSent,
emailsFailed,
emailsSent: result.sent,
emailsFailed: result.failed,
failedRecipients: result.errors.length > 0 ? result.errors.map((e) => e.email) : undefined,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { notified: eligibilities.length, emailsSent, emailsFailed }
return { notified: successfulEligibilityIds.length, emailsSent: result.sent, emailsFailed: result.failed }
}),
/**

View File

@@ -10,12 +10,11 @@
import type { PrismaClient, ProjectRoundStateValue, RoundType, Prisma } from '@prisma/client'
import { transitionProject, isTerminalState } from './round-engine'
import { logAudit } from '@/server/utils/audit'
import {
sendStyledNotificationEmail,
getRejectionNotificationTemplate,
} from '@/lib/email'
import { getRejectionNotificationTemplate } from '@/lib/email'
import { createBulkNotifications } from '../services/in-app-notification'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
import { sendBatchNotifications } from './notification-sender'
import type { NotificationItem } from './notification-sender'
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -724,6 +723,7 @@ export async function confirmFinalization(
const advancedUserIds = new Set<string>()
const rejectedUserIds = new Set<string>()
const notificationItems: NotificationItem[] = []
for (const prs of finalizedStates) {
type Recipient = { email: string; name: string | null; userId: string | null }
@@ -748,53 +748,56 @@ export async function confirmFinalization(
}
for (const recipient of recipients) {
try {
if (prs.state === 'PASSED') {
// Build account creation URL for passwordless users
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
if (prs.state === 'PASSED') {
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
await sendStyledNotificationEmail(
recipient.email,
recipient.name || '',
'ADVANCEMENT_NOTIFICATION',
{
title: 'Your project has advanced!',
message: '',
linkUrl: accountUrl || '/applicant',
metadata: {
projectName: prs.project.title,
fromRoundName: round.name,
toRoundName: targetRoundName,
customMessage: options.advancementMessage || undefined,
accountUrl,
},
notificationItems.push({
email: recipient.email,
name: recipient.name || '',
type: 'ADVANCEMENT_NOTIFICATION',
context: {
title: 'Your project has advanced!',
message: '',
linkUrl: accountUrl || '/applicant',
metadata: {
projectName: prs.project.title,
fromRoundName: round.name,
toRoundName: targetRoundName,
customMessage: options.advancementMessage || undefined,
accountUrl,
},
)
} else {
await sendStyledNotificationEmail(
recipient.email,
recipient.name || '',
'REJECTION_NOTIFICATION',
{
title: `Update on your application: "${prs.project.title}"`,
message: '',
metadata: {
projectName: prs.project.title,
roundName: round.name,
customMessage: options.rejectionMessage || undefined,
},
},
projectId: prs.projectId,
userId: recipient.userId || undefined,
roundId: round.id,
})
} else {
notificationItems.push({
email: recipient.email,
name: recipient.name || '',
type: 'REJECTION_NOTIFICATION',
context: {
title: `Update on your application: "${prs.project.title}"`,
message: '',
metadata: {
projectName: prs.project.title,
roundName: round.name,
customMessage: options.rejectionMessage || undefined,
},
)
}
emailsSent++
} catch (err) {
console.error(`[Finalization] Email failed for ${recipient.email}:`, err)
emailsFailed++
},
projectId: prs.projectId,
userId: recipient.userId || undefined,
roundId: round.id,
})
}
}
}
const batchResult = await sendBatchNotifications(notificationItems)
emailsSent = batchResult.sent
emailsFailed = batchResult.failed
// Create in-app notifications
if (advancedUserIds.size > 0) {
void createBulkNotifications({

View File

@@ -80,7 +80,7 @@ export async function getImageUploadUrl(
if (!isValidImageType(contentType)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP',
message: `Invalid image type: "${contentType}". Allowed: JPEG, PNG, GIF, WebP, SVG`,
})
}

View File

@@ -117,12 +117,14 @@ export const EvaluationConfigSchema = z.object({
showGlobalScore: z.boolean().default(false),
showCriterionScores: z.boolean().default(false),
showFeedbackText: z.boolean().default(false),
hideFromRejected: z.boolean().default(false),
})
.default({
enabled: false,
showGlobalScore: false,
showCriterionScores: false,
showFeedbackText: false,
hideFromRejected: false,
}),
advancementMode: z