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, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } 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 { FileViewer } from '@/components/shared/file-viewer'
import { FileUpload } from '@/components/shared/file-upload' import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
@@ -37,7 +60,6 @@ import {
Users, Users,
FileText, FileText,
Calendar, Calendar,
Clock,
BarChart3, BarChart3,
ThumbsUp, ThumbsUp,
ThumbsDown, ThumbsDown,
@@ -50,9 +72,11 @@ import {
Loader2, Loader2,
ScanSearch, ScanSearch,
Eye, Eye,
Plus,
X,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatDate, formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
interface PageProps { interface PageProps {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@@ -121,6 +145,42 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null) 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) { if (isLoading) {
return <ProjectDetailSkeleton /> return <ProjectDetailSkeleton />
} }
@@ -184,9 +244,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
{project.title} {project.title}
</h1> </h1>
<Badge variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}> {(() => {
{(project.status ?? 'SUBMITTED').replace('_', ' ')} const prs = (project as any).projectRoundStates ?? []
</Badge> 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> </div>
{project.teamName && ( {project.teamName && (
<p className="text-muted-foreground">{project.teamName}</p> <p className="text-muted-foreground">{project.teamName}</p>
@@ -430,53 +494,203 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</AnimatedCard> </AnimatedCard>
{/* Team Members Section */} {/* Team Members Section */}
{project.teamMembers && project.teamMembers.length > 0 && ( <AnimatedCard index={2}>
<AnimatedCard index={2}> <Card>
<Card> <CardHeader>
<CardHeader> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <CardTitle className="flex items-center gap-2.5 text-lg">
<CardTitle className="flex items-center gap-2.5 text-lg"> <div className="rounded-lg bg-violet-500/10 p-1.5">
<div className="rounded-lg bg-violet-500/10 p-1.5"> <Users className="h-4 w-4 text-violet-500" />
<Users className="h-4 w-4 text-violet-500" /> </div>
</div> Team Members ({project.teamMembers?.length ?? 0})
Team Members ({project.teamMembers.length}) </CardTitle>
</CardTitle> <Button variant="outline" size="sm" onClick={() => setAddMemberOpen(true)}>
</div> <Plus className="mr-2 h-4 w-4" />
</CardHeader> Add Member
<CardContent> </Button>
</div>
</CardHeader>
<CardContent>
{project.teamMembers && project.teamMembers.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2"> <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 } }) => ( {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"> const isLastLead =
{member.role === 'LEAD' ? ( member.role === 'LEAD' &&
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted"> project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1
<Crown className="h-5 w-5 text-yellow-500" /> return (
</div> <div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
) : ( {member.role === 'LEAD' ? (
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" /> <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 className="flex-1 min-w-0"> </div>
<div className="flex items-center gap-2"> ) : (
<p className="font-medium text-sm truncate"> <UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
{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 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> )
))} })}
</div> </div>
</CardContent> ) : (
</Card> <p className="text-sm text-muted-foreground">No team members yet.</p>
</AnimatedCard> )}
)} </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 */} {/* Mentor Assignment Section */}
{project.wantsMentorship && ( {project.wantsMentorship && (

View File

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

View File

@@ -4,6 +4,8 @@ import { useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Trophy } from 'lucide-react' import { Trophy } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { EmailPreviewDialog } from './email-preview-dialog' import { EmailPreviewDialog } from './email-preview-dialog'
interface NotifyAdvancedButtonProps { interface NotifyAdvancedButtonProps {
@@ -14,9 +16,10 @@ interface NotifyAdvancedButtonProps {
export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedButtonProps) { export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedButtonProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [customMessage, setCustomMessage] = useState<string | undefined>() const [customMessage, setCustomMessage] = useState<string | undefined>()
const [fullCustomBody, setFullCustomBody] = useState(false)
const preview = trpc.round.previewAdvancementEmail.useQuery( const preview = trpc.round.previewAdvancementEmail.useQuery(
{ roundId, targetRoundId, customMessage }, { roundId, targetRoundId, customMessage, fullCustomBody },
{ enabled: open } { enabled: open }
) )
@@ -32,18 +35,31 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB
return ( return (
<> <>
<button <div className="space-y-2">
onClick={() => setOpen(true)} <button
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" 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> <Trophy className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<p className="text-sm font-medium">Notify Advanced Teams</p> <div>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-sm font-medium">Notify Advanced Teams</p>
Send advancement emails to passed projects <p className="text-xs text-muted-foreground mt-0.5">
</p> 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> </div>
</button> </div>
<EmailPreviewDialog <EmailPreviewDialog
open={open} open={open}
@@ -53,7 +69,7 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB
recipientCount={preview.data?.recipientCount ?? 0} recipientCount={preview.data?.recipientCount ?? 0}
previewHtml={preview.data?.html} previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading} isPreviewLoading={preview.isLoading}
onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg })} onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg, fullCustomBody })}
isSending={sendMutation.isPending} isSending={sendMutation.isPending}
onRefreshPreview={(msg) => setCustomMessage(msg)} onRefreshPreview={(msg) => setCustomMessage(msg)}
/> />

View File

@@ -4,6 +4,8 @@ import { useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { XCircle } from 'lucide-react' import { XCircle } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { EmailPreviewDialog } from './email-preview-dialog' import { EmailPreviewDialog } from './email-preview-dialog'
interface NotifyRejectedButtonProps { interface NotifyRejectedButtonProps {
@@ -13,9 +15,10 @@ interface NotifyRejectedButtonProps {
export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) { export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [customMessage, setCustomMessage] = useState<string | undefined>() const [customMessage, setCustomMessage] = useState<string | undefined>()
const [fullCustomBody, setFullCustomBody] = useState(false)
const preview = trpc.round.previewRejectionEmail.useQuery( const preview = trpc.round.previewRejectionEmail.useQuery(
{ roundId, customMessage }, { roundId, customMessage, fullCustomBody },
{ enabled: open } { enabled: open }
) )
@@ -31,18 +34,31 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
return ( return (
<> <>
<button <div className="space-y-2">
onClick={() => setOpen(true)} <button
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" 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> <XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<p className="text-sm font-medium">Notify Non-Advanced</p> <div>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-sm font-medium">Notify Non-Advanced</p>
Send rejection emails to non-advanced projects <p className="text-xs text-muted-foreground mt-0.5">
</p> 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> </div>
</button> </div>
<EmailPreviewDialog <EmailPreviewDialog
open={open} open={open}
@@ -52,7 +68,7 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
recipientCount={preview.data?.recipientCount ?? 0} recipientCount={preview.data?.recipientCount ?? 0}
previewHtml={preview.data?.html} previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading} isPreviewLoading={preview.isLoading}
onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg })} onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg, fullCustomBody })}
isSending={sendMutation.isPending} isSending={sendMutation.isPending}
onRefreshPreview={(msg) => setCustomMessage(msg)} onRefreshPreview={(msg) => setCustomMessage(msg)}
/> />

View File

@@ -26,7 +26,7 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
} }
const visConfig = (config.applicantVisibility as { 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) => { const updateVisibility = (key: string, value: unknown) => {
@@ -293,6 +293,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
/> />
</div> </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"> <p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
Evaluations are only visible to applicants after this round closes. Evaluations are only visible to applicants after this round closes.
</p> </p>

View File

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

View File

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

View File

@@ -4,8 +4,10 @@ import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit' import { logAudit } from '../utils/audit'
import { processEligibilityJob } from '../services/award-eligibility-job' 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 { 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' import type { PrismaClient } from '@prisma/client'
/** /**
@@ -1270,8 +1272,18 @@ export const specialAwardRouter = router({
}) })
// Get eligible projects that haven't been notified yet // 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({ 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: { select: {
id: true, id: true,
projectId: true, projectId: true,
@@ -1324,12 +1336,12 @@ export const specialAwardRouter = router({
}) })
} }
// Send emails // Build notification items — track which eligibility each email belongs to
let emailsSent = 0 const items: NotificationItem[] = []
let emailsFailed = 0 const eligibilityEmailMap = new Map<string, Set<string>>() // eligibilityId → Set<email>
for (const e of eligibilities) { 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) if (e.project.submittedBy) recipients.push(e.project.submittedBy)
for (const tm of e.project.teamMembers) { for (const tm of e.project.teamMembers) {
if (!recipients.some((r) => r.id === tm.user.id)) { 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) { for (const recipient of recipients) {
const token = tokenMap.get(recipient.id) const token = tokenMap.get(recipient.id)
const accountUrl = token ? `/accept-invite?token=${token}` : undefined const accountUrl = token ? `/accept-invite?token=${token}` : undefined
emails.add(recipient.email)
try { items.push({
await sendStyledNotificationEmail( email: recipient.email,
recipient.email, name: recipient.name || '',
recipient.name || '', type: 'AWARD_SELECTION_NOTIFICATION',
'AWARD_SELECTION_NOTIFICATION', context: {
{ title: `Under consideration for ${award.name}`,
title: `Under consideration for ${award.name}`, message: input.customMessage || '',
message: input.customMessage || '', metadata: {
metadata: { projectName: e.project.title,
projectName: e.project.title, awardName: award.name,
awardName: award.name, customMessage: input.customMessage,
customMessage: input.customMessage, accountUrl,
accountUrl,
},
}, },
) },
emailsSent++ projectId: e.projectId,
} catch (err) { userId: recipient.id,
console.error(`[award-notify] Failed to email ${recipient.email}:`, err) })
emailsFailed++
}
} }
eligibilityEmailMap.set(e.id, emails)
} }
// Stamp notifiedAt on all processed eligibilities to prevent re-notification const result = await sendBatchNotifications(items)
const notifiedIds = eligibilities.map((e) => e.id)
if (notifiedIds.length > 0) { // 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({ await ctx.prisma.awardEligibility.updateMany({
where: { id: { in: notifiedIds } }, where: { id: { in: successfulEligibilityIds } },
data: { notifiedAt: new Date() }, data: { notifiedAt: new Date() },
}) })
} }
@@ -1383,14 +1402,15 @@ export const specialAwardRouter = router({
detailsJson: { detailsJson: {
action: 'NOTIFY_ELIGIBLE_PROJECTS', action: 'NOTIFY_ELIGIBLE_PROJECTS',
eligibleCount: eligibilities.length, eligibleCount: eligibilities.length,
emailsSent, emailsSent: result.sent,
emailsFailed, emailsFailed: result.failed,
failedRecipients: result.errors.length > 0 ? result.errors.map((e) => e.email) : undefined,
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, 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 type { PrismaClient, ProjectRoundStateValue, RoundType, Prisma } from '@prisma/client'
import { transitionProject, isTerminalState } from './round-engine' import { transitionProject, isTerminalState } from './round-engine'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
import { import { getRejectionNotificationTemplate } from '@/lib/email'
sendStyledNotificationEmail,
getRejectionNotificationTemplate,
} from '@/lib/email'
import { createBulkNotifications } from '../services/in-app-notification' import { createBulkNotifications } from '../services/in-app-notification'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite' import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
import { sendBatchNotifications } from './notification-sender'
import type { NotificationItem } from './notification-sender'
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@@ -724,6 +723,7 @@ export async function confirmFinalization(
const advancedUserIds = new Set<string>() const advancedUserIds = new Set<string>()
const rejectedUserIds = new Set<string>() const rejectedUserIds = new Set<string>()
const notificationItems: NotificationItem[] = []
for (const prs of finalizedStates) { for (const prs of finalizedStates) {
type Recipient = { email: string; name: string | null; userId: string | null } type Recipient = { email: string; name: string | null; userId: string | null }
@@ -748,53 +748,56 @@ export async function confirmFinalization(
} }
for (const recipient of recipients) { for (const recipient of recipients) {
try { if (prs.state === 'PASSED') {
if (prs.state === 'PASSED') { const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
// Build account creation URL for passwordless users const accountUrl = token ? `/accept-invite?token=${token}` : undefined
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
await sendStyledNotificationEmail( notificationItems.push({
recipient.email, email: recipient.email,
recipient.name || '', name: recipient.name || '',
'ADVANCEMENT_NOTIFICATION', type: 'ADVANCEMENT_NOTIFICATION',
{ context: {
title: 'Your project has advanced!', title: 'Your project has advanced!',
message: '', message: '',
linkUrl: accountUrl || '/applicant', linkUrl: accountUrl || '/applicant',
metadata: { metadata: {
projectName: prs.project.title, projectName: prs.project.title,
fromRoundName: round.name, fromRoundName: round.name,
toRoundName: targetRoundName, toRoundName: targetRoundName,
customMessage: options.advancementMessage || undefined, customMessage: options.advancementMessage || undefined,
accountUrl, accountUrl,
},
}, },
) },
} else { projectId: prs.projectId,
await sendStyledNotificationEmail( userId: recipient.userId || undefined,
recipient.email, roundId: round.id,
recipient.name || '', })
'REJECTION_NOTIFICATION', } else {
{ notificationItems.push({
title: `Update on your application: "${prs.project.title}"`, email: recipient.email,
message: '', name: recipient.name || '',
metadata: { type: 'REJECTION_NOTIFICATION',
projectName: prs.project.title, context: {
roundName: round.name, title: `Update on your application: "${prs.project.title}"`,
customMessage: options.rejectionMessage || undefined, message: '',
}, metadata: {
projectName: prs.project.title,
roundName: round.name,
customMessage: options.rejectionMessage || undefined,
}, },
) },
} projectId: prs.projectId,
emailsSent++ userId: recipient.userId || undefined,
} catch (err) { roundId: round.id,
console.error(`[Finalization] Email failed for ${recipient.email}:`, err) })
emailsFailed++
} }
} }
} }
const batchResult = await sendBatchNotifications(notificationItems)
emailsSent = batchResult.sent
emailsFailed = batchResult.failed
// Create in-app notifications // Create in-app notifications
if (advancedUserIds.size > 0) { if (advancedUserIds.size > 0) {
void createBulkNotifications({ void createBulkNotifications({

View File

@@ -80,7 +80,7 @@ export async function getImageUploadUrl(
if (!isValidImageType(contentType)) { if (!isValidImageType(contentType)) {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', 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), showGlobalScore: z.boolean().default(false),
showCriterionScores: z.boolean().default(false), showCriterionScores: z.boolean().default(false),
showFeedbackText: z.boolean().default(false), showFeedbackText: z.boolean().default(false),
hideFromRejected: z.boolean().default(false),
}) })
.default({ .default({
enabled: false, enabled: false,
showGlobalScore: false, showGlobalScore: false,
showCriterionScores: false, showCriterionScores: false,
showFeedbackText: false, showFeedbackText: false,
hideFromRejected: false,
}), }),
advancementMode: z advancementMode: z