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:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user