feat: revamp communication hub with recipient preview and state filtering
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- New previewRecipients query shows live project/applicant counts as you
  compose, with per-state breakdown for Round Applicants
- Exclude Rejected/Withdrawn checkboxes filter out terminal-state projects
- Compose form now has 2/3 + 1/3 layout with always-visible recipient
  summary sidebar showing project counts, applicant counts, state badges
- Preview dialog enlarged (max-w-3xl) with split layout: email preview
  on left, recipient/delivery summary on right
- Send button now shows recipient count ("Send to N Recipients")
- resolveRecipients accepts excludeStates param for ROUND_APPLICANTS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 10:32:03 +01:00
parent ea46d7293f
commit 34fc0b81e0
2 changed files with 672 additions and 332 deletions

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useMemo } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
@@ -60,6 +60,10 @@ import {
Inbox, Inbox,
CheckCircle2, CheckCircle2,
Eye, Eye,
Users,
FolderOpen,
XCircle,
ArrowRight,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatDate } from '@/lib/utils' import { formatDate } from '@/lib/utils'
@@ -77,6 +81,24 @@ const RECIPIENT_TYPE_OPTIONS: { value: RecipientType; label: string }[] = [
const ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN'] const ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN']
const STATE_LABELS: Record<string, string> = {
PENDING: 'Pending',
IN_PROGRESS: 'Active',
COMPLETED: 'Completed',
PASSED: 'Passed',
REJECTED: 'Rejected',
WITHDRAWN: 'Withdrawn',
}
const STATE_BADGE_VARIANT: Record<string, 'default' | 'success' | 'destructive' | 'secondary'> = {
PENDING: 'secondary',
IN_PROGRESS: 'default',
COMPLETED: 'default',
PASSED: 'success',
REJECTED: 'destructive',
WITHDRAWN: 'secondary',
}
export default function MessagesPage() { export default function MessagesPage() {
const [recipientType, setRecipientType] = useState<RecipientType>('ALL') const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
const [selectedRole, setSelectedRole] = useState('') const [selectedRole, setSelectedRole] = useState('')
@@ -90,11 +112,12 @@ export default function MessagesPage() {
const [isScheduled, setIsScheduled] = useState(false) const [isScheduled, setIsScheduled] = useState(false)
const [scheduledAt, setScheduledAt] = useState('') const [scheduledAt, setScheduledAt] = useState('')
const [showPreview, setShowPreview] = useState(false) const [showPreview, setShowPreview] = useState(false)
const [excludeRejected, setExcludeRejected] = useState(true)
const [excludeWithdrawn, setExcludeWithdrawn] = useState(true)
const utils = trpc.useUtils() const utils = trpc.useUtils()
// Fetch supporting data // Fetch supporting data
// Get programs with stages
const { data: programs } = trpc.program.list.useQuery({ includeStages: true }) const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap((p) => const rounds = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, program: { name: p.name } })) ((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, program: { name: p.name } }))
@@ -105,12 +128,43 @@ export default function MessagesPage() {
{ enabled: recipientType === 'USER' } { enabled: recipientType === 'USER' }
) )
// Fetch sent messages for history (messages sent BY this admin) // Fetch sent messages for history
const { data: sentMessages, isLoading: loadingSent } = trpc.message.sent.useQuery( const { data: sentMessages, isLoading: loadingSent } = trpc.message.sent.useQuery(
{ page: 1, pageSize: 50 }, { page: 1, pageSize: 50 },
{ refetchInterval: 30_000 } { refetchInterval: 30_000 }
) )
// Compute exclude states list
const excludeStates = useMemo(() => {
const states: string[] = []
if (excludeRejected) states.push('REJECTED')
if (excludeWithdrawn) states.push('WITHDRAWN')
return states
}, [excludeRejected, excludeWithdrawn])
// Live recipient preview — fetches whenever selection changes
const recipientPreview = trpc.message.previewRecipients.useQuery(
{
recipientType,
recipientFilter: buildRecipientFilterValue(),
roundId: roundId || undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
},
{
enabled: recipientType === 'ROUND_APPLICANTS'
? !!roundId
: recipientType === 'ROUND_JURY'
? !!roundId
: recipientType === 'ROLE'
? !!selectedRole
: recipientType === 'USER'
? !!selectedUserId
: recipientType === 'PROGRAM_TEAM'
? !!selectedProgramId
: recipientType === 'ALL',
}
)
const emailPreview = trpc.message.previewEmail.useQuery( const emailPreview = trpc.message.previewEmail.useQuery(
{ subject, body }, { subject, body },
{ enabled: showPreview && subject.length > 0 && body.length > 0 } { enabled: showPreview && subject.length > 0 && body.length > 0 }
@@ -164,7 +218,7 @@ export default function MessagesPage() {
) )
} }
const buildRecipientFilter = (): unknown => { function buildRecipientFilterValue(): unknown {
switch (recipientType) { switch (recipientType) {
case 'ROLE': case 'ROLE':
return selectedRole ? { role: selectedRole } : undefined return selectedRole ? { role: selectedRole } : undefined
@@ -187,9 +241,7 @@ export default function MessagesPage() {
} }
case 'ROUND_JURY': { case 'ROUND_JURY': {
if (!roundId) return 'Stage Jury (none selected)' if (!roundId) return 'Stage Jury (none selected)'
const stage = rounds?.find( const stage = rounds?.find((r) => r.id === roundId)
(r) => r.id === roundId
)
return stage return stage
? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}` ? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}`
: 'Stage Jury' : 'Stage Jury'
@@ -219,44 +271,49 @@ export default function MessagesPage() {
} }
} }
const handlePreview = () => { const validateForm = (): boolean => {
if (!subject.trim()) { if (!subject.trim()) {
toast.error('Subject is required') toast.error('Subject is required')
return return false
} }
if (!body.trim()) { if (!body.trim()) {
toast.error('Message body is required') toast.error('Message body is required')
return return false
} }
if (deliveryChannels.length === 0) { if (deliveryChannels.length === 0) {
toast.error('Select at least one delivery channel') toast.error('Select at least one delivery channel')
return return false
} }
if (recipientType === 'ROLE' && !selectedRole) { if (recipientType === 'ROLE' && !selectedRole) {
toast.error('Please select a role') toast.error('Please select a role')
return return false
} }
if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && !roundId) { if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && !roundId) {
toast.error('Please select a round') toast.error('Please select a round')
return return false
} }
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) { if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
toast.error('Please select a program') toast.error('Please select a program')
return return false
} }
if (recipientType === 'USER' && !selectedUserId) { if (recipientType === 'USER' && !selectedUserId) {
toast.error('Please select a user') toast.error('Please select a user')
return return false
}
return true
} }
const handlePreview = () => {
if (!validateForm()) return
setShowPreview(true) setShowPreview(true)
} }
const handleActualSend = () => { const handleActualSend = () => {
sendMutation.mutate({ sendMutation.mutate({
recipientType, recipientType,
recipientFilter: buildRecipientFilter(), recipientFilter: buildRecipientFilterValue(),
roundId: roundId || undefined, roundId: roundId || undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
subject: subject.trim(), subject: subject.trim(),
body: body.trim(), body: body.trim(),
deliveryChannels, deliveryChannels,
@@ -266,6 +323,8 @@ export default function MessagesPage() {
setShowPreview(false) setShowPreview(false)
} }
const preview = recipientPreview.data
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@@ -297,7 +356,9 @@ export default function MessagesPage() {
</TabsList> </TabsList>
<TabsContent value="compose" className="space-y-4 mt-4"> <TabsContent value="compose" className="space-y-4 mt-4">
{/* Compose Form */} <div className="grid gap-4 lg:grid-cols-3">
{/* Left: Compose form */}
<div className="lg:col-span-2 space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Compose Message</CardTitle> <CardTitle className="text-lg">Compose Message</CardTitle>
@@ -369,6 +430,37 @@ export default function MessagesPage() {
</div> </div>
)} )}
{/* Exclude filters for Round Applicants */}
{recipientType === 'ROUND_APPLICANTS' && roundId && (
<div className="rounded-lg border p-3 space-y-2">
<Label className="text-sm font-medium">Exclude Project States</Label>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="exclude-rejected"
checked={excludeRejected}
onCheckedChange={(v) => setExcludeRejected(!!v)}
/>
<label htmlFor="exclude-rejected" className="text-sm cursor-pointer flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-destructive" />
Exclude Rejected
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="exclude-withdrawn"
checked={excludeWithdrawn}
onCheckedChange={(v) => setExcludeWithdrawn(!!v)}
/>
<label htmlFor="exclude-withdrawn" className="text-sm cursor-pointer flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-muted-foreground" />
Exclude Withdrawn
</label>
</div>
</div>
</div>
)}
{recipientType === 'PROGRAM_TEAM' && ( {recipientType === 'PROGRAM_TEAM' && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Select Program</Label> <Label>Select Program</Label>
@@ -544,7 +636,7 @@ export default function MessagesPage() {
)} )}
</div> </div>
{/* Send button */} {/* Preview button */}
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={handlePreview} disabled={sendMutation.isPending}> <Button onClick={handlePreview} disabled={sendMutation.isPending}>
{sendMutation.isPending ? ( {sendMutation.isPending ? (
@@ -557,6 +649,115 @@ export default function MessagesPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div>
{/* Right sidebar: Recipient Summary */}
<div className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
Recipient Summary
</CardTitle>
</CardHeader>
<CardContent>
{recipientPreview.isLoading ? (
<div className="space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-24" />
</div>
) : preview ? (
<div className="space-y-3">
<div className="text-sm text-muted-foreground">
{getRecipientDescription()}
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3">
{preview.totalProjects > 0 && (
<div className="rounded-lg border p-3 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Projects</span>
</div>
<p className="text-xl font-bold tabular-nums">{preview.totalProjects}</p>
</div>
)}
<div className={`rounded-lg border p-3 text-center ${preview.totalProjects > 0 ? '' : 'col-span-2'}`}>
<div className="flex items-center justify-center gap-1.5 mb-1">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Recipients</span>
</div>
<p className="text-xl font-bold tabular-nums">{preview.totalApplicants}</p>
</div>
</div>
{/* State breakdown for Round Applicants */}
{recipientType === 'ROUND_APPLICANTS' && Object.keys(preview.stateBreakdown).length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Projects by Status
</p>
<div className="space-y-1.5">
{Object.entries(preview.stateBreakdown)
.sort(([a], [b]) => {
const order = ['IN_PROGRESS', 'PENDING', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b)
})
.map(([state, count]) => {
const isExcluded = excludeStates.includes(state)
return (
<div
key={state}
className={`flex items-center justify-between rounded-md px-2.5 py-1.5 text-sm ${
isExcluded ? 'bg-muted/50 opacity-50 line-through' : 'bg-muted/30'
}`}
>
<div className="flex items-center gap-2">
<Badge
variant={STATE_BADGE_VARIANT[state] || 'secondary'}
className="text-[10px] px-1.5 py-0"
>
{STATE_LABELS[state] || state}
</Badge>
{isExcluded && (
<span className="text-[10px] text-muted-foreground">(excluded)</span>
)}
</div>
<span className="font-semibold tabular-nums">{count}</span>
</div>
)
})}
</div>
{/* Included vs excluded summary */}
{excludeStates.length > 0 && (
<div className="rounded-lg bg-blue-500/5 border border-blue-200/50 p-2.5 mt-2">
<p className="text-xs text-blue-700">
Sending to <span className="font-semibold">{preview.totalApplicants}</span> applicants
{' '}across{' '}
<span className="font-semibold">
{preview.totalProjects - Object.entries(preview.stateBreakdown)
.filter(([s]) => excludeStates.includes(s))
.reduce((sum, [, c]) => sum + c, 0)}
</span>{' '}
projects (excluding{' '}
{excludeStates.map((s) => STATE_LABELS[s] || s).join(' & ').toLowerCase()}).
</p>
</div>
)}
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">
Select a recipient type{recipientType !== 'ALL' ? ' and filter' : ''} to see a summary.
</p>
)}
</CardContent>
</Card>
</div>
</div>
</TabsContent> </TabsContent>
<TabsContent value="history" className="mt-4"> <TabsContent value="history" className="mt-4">
@@ -609,6 +810,8 @@ export default function MessagesPage() {
? `By role` ? `By role`
: msg.recipientType === 'ROUND_JURY' : msg.recipientType === 'ROUND_JURY'
? 'Round jury' ? 'Round jury'
: msg.recipientType === 'ROUND_APPLICANTS'
? 'Round applicants'
: msg.recipientType === 'USER' : msg.recipientType === 'USER'
? `${recipientCount || 1} user${recipientCount > 1 ? 's' : ''}` ? `${recipientCount || 1} user${recipientCount > 1 ? 's' : ''}`
: msg.recipientType} : msg.recipientType}
@@ -671,30 +874,30 @@ export default function MessagesPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{/* Preview Dialog */} {/* Preview & Send Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}> <Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Preview Message</DialogTitle> <DialogTitle className="text-xl">Review & Send</DialogTitle>
<DialogDescription>Review your message before sending</DialogDescription> <DialogDescription>Verify everything looks correct before sending</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4">
<div> <div className="grid gap-6 md:grid-cols-5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Recipients</p> {/* Left: Message details */}
<p className="text-sm mt-1">{getRecipientDescription()}</p> <div className="md:col-span-3 space-y-4">
</div>
<div> <div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Subject</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Subject</p>
<p className="text-sm font-medium mt-1">{subject}</p> <p className="text-base font-semibold mt-1">{subject}</p>
</div> </div>
<div> <div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Email Preview</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">Email Preview</p>
<div className="mt-1 rounded-lg border overflow-hidden bg-gray-50"> <div className="rounded-lg border overflow-hidden bg-gray-50">
{emailPreview.data?.html ? ( {emailPreview.data?.html ? (
<iframe <iframe
srcDoc={emailPreview.data.html} srcDoc={emailPreview.data.html}
sandbox="allow-same-origin" sandbox="allow-same-origin"
className="w-full h-[500px] border-0" className="w-full h-[400px] border-0"
title="Email Preview" title="Email Preview"
/> />
) : ( ) : (
@@ -704,9 +907,71 @@ export default function MessagesPage() {
)} )}
</div> </div>
</div> </div>
<div> </div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Delivery Channels</p>
<div className="flex items-center gap-2 mt-1"> {/* Right: Recipient & delivery summary */}
<div className="md:col-span-2 space-y-4">
<div className="rounded-lg border p-4 space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Recipients</p>
<p className="text-sm font-medium">{getRecipientDescription()}</p>
{preview && (
<div className="space-y-2">
{preview.totalProjects > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-1.5">
<FolderOpen className="h-3.5 w-3.5" />
Projects
</span>
<span className="font-semibold tabular-nums">
{preview.totalProjects - Object.entries(preview.stateBreakdown)
.filter(([s]) => excludeStates.includes(s))
.reduce((sum, [, c]) => sum + c, 0)}
{excludeStates.length > 0 && (
<span className="text-muted-foreground font-normal"> / {preview.totalProjects}</span>
)}
</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
Applicants
</span>
<span className="font-semibold tabular-nums">{preview.totalApplicants}</span>
</div>
{/* State breakdown in preview */}
{recipientType === 'ROUND_APPLICANTS' && Object.keys(preview.stateBreakdown).length > 0 && (
<div className="pt-2 border-t space-y-1">
{Object.entries(preview.stateBreakdown)
.sort(([a], [b]) => {
const order = ['IN_PROGRESS', 'PENDING', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b)
})
.map(([state, count]) => {
const isExcluded = excludeStates.includes(state)
return (
<div
key={state}
className={`flex items-center justify-between text-xs ${isExcluded ? 'opacity-40 line-through' : ''}`}
>
<Badge variant={STATE_BADGE_VARIANT[state] || 'secondary'} className="text-[10px] px-1.5 py-0">
{STATE_LABELS[state] || state}
</Badge>
<span className="tabular-nums">{count} projects</span>
</div>
)
})}
</div>
)}
</div>
)}
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Delivery</p>
<div className="flex flex-wrap gap-1.5">
{deliveryChannels.includes('EMAIL') && ( {deliveryChannels.includes('EMAIL') && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
<Mail className="mr-1 h-3 w-3" /> <Mail className="mr-1 h-3 w-3" />
@@ -720,15 +985,17 @@ export default function MessagesPage() {
</Badge> </Badge>
)} )}
</div> </div>
</div>
{isScheduled && scheduledAt && ( {isScheduled && scheduledAt && (
<div> <div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Scheduled For</p> <Clock className="h-3.5 w-3.5" />
<p className="text-sm mt-1">{formatDate(new Date(scheduledAt))}</p> Scheduled: {formatDate(new Date(scheduledAt))}
</div> </div>
)} )}
</div> </div>
<DialogFooter className="flex-col sm:flex-row gap-2"> </div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2 mt-4 border-t pt-4">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -744,15 +1011,15 @@ export default function MessagesPage() {
Send Test to Me Send Test to Me
</Button> </Button>
<Button variant="outline" onClick={() => setShowPreview(false)}> <Button variant="outline" onClick={() => setShowPreview(false)}>
Edit Back to Edit
</Button> </Button>
<Button onClick={handleActualSend} disabled={sendMutation.isPending}> <Button onClick={handleActualSend} disabled={sendMutation.isPending} size="lg">
{sendMutation.isPending ? ( {sendMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<Send className="mr-2 h-4 w-4" /> <Send className="mr-2 h-4 w-4" />
)} )}
{isScheduled ? 'Confirm & Schedule' : 'Confirm & Send'} {isScheduled ? 'Confirm & Schedule' : `Send to ${preview?.totalApplicants ?? '...'} Recipients`}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -17,6 +17,7 @@ export const messageRouter = router({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']), recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
recipientFilter: z.any().optional(), recipientFilter: z.any().optional(),
roundId: z.string().optional(), roundId: z.string().optional(),
excludeStates: z.array(z.string()).optional(),
subject: z.string().min(1).max(500), subject: z.string().min(1).max(500),
body: z.string().min(1), body: z.string().min(1),
deliveryChannels: z.array(z.string()).min(1), deliveryChannels: z.array(z.string()).min(1),
@@ -30,7 +31,8 @@ export const messageRouter = router({
ctx.prisma, ctx.prisma,
input.recipientType, input.recipientType,
input.recipientFilter, input.recipientFilter,
input.roundId input.roundId,
input.excludeStates
) )
if (recipientUserIds.length === 0) { if (recipientUserIds.length === 0) {
@@ -385,6 +387,72 @@ export const messageRouter = router({
return { html: getEmailPreviewHtml(input.subject, input.body) } return { html: getEmailPreviewHtml(input.subject, input.body) }
}), }),
/**
* Preview recipient counts for a given recipient type + filters.
* Returns project breakdown by state for ROUND_APPLICANTS, or total user count for others.
*/
previewRecipients: adminProcedure
.input(z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
recipientFilter: z.any().optional(),
roundId: z.string().optional(),
excludeStates: z.array(z.string()).optional(),
}))
.query(async ({ ctx, input }) => {
// For ROUND_APPLICANTS, return a breakdown by project state
if (input.recipientType === 'ROUND_APPLICANTS' && input.roundId) {
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId },
select: {
state: true,
projectId: true,
project: {
select: {
submittedByUserId: true,
teamMembers: { select: { userId: true } },
},
},
},
})
// Count projects per state
const stateBreakdown: Record<string, number> = {}
for (const ps of projectStates) {
stateBreakdown[ps.state] = (stateBreakdown[ps.state] || 0) + 1
}
// Compute total unique users respecting exclusions
const excludeSet = new Set(input.excludeStates ?? [])
const includedUserIds = new Set<string>()
for (const ps of projectStates) {
if (excludeSet.has(ps.state)) continue
if (ps.project.submittedByUserId) includedUserIds.add(ps.project.submittedByUserId)
for (const tm of ps.project.teamMembers) includedUserIds.add(tm.userId)
}
return {
totalProjects: projectStates.length,
totalApplicants: includedUserIds.size,
stateBreakdown,
}
}
// For other recipient types, just count resolved users
const userIds = await resolveRecipients(
ctx.prisma,
input.recipientType,
input.recipientFilter,
input.roundId,
input.excludeStates
)
return {
totalProjects: 0,
totalApplicants: userIds.length,
stateBreakdown: {} as Record<string, number>,
}
}),
/** /**
* Send a test email to the currently logged-in admin. * Send a test email to the currently logged-in admin.
*/ */
@@ -415,7 +483,8 @@ async function resolveRecipients(
prisma: PrismaClient, prisma: PrismaClient,
recipientType: string, recipientType: string,
recipientFilter: unknown, recipientFilter: unknown,
roundId?: string roundId?: string,
excludeStates?: string[]
): Promise<string[]> { ): Promise<string[]> {
const filter = recipientFilter as Record<string, unknown> | undefined const filter = recipientFilter as Record<string, unknown> | undefined
@@ -454,9 +523,13 @@ async function resolveRecipients(
case 'ROUND_APPLICANTS': { case 'ROUND_APPLICANTS': {
const targetRoundId = roundId || (filter?.roundId as string) const targetRoundId = roundId || (filter?.roundId as string)
if (!targetRoundId) return [] if (!targetRoundId) return []
// Get all projects in this round // Get all projects in this round, optionally excluding certain states
const stateWhere: Record<string, unknown> = { roundId: targetRoundId }
if (excludeStates && excludeStates.length > 0) {
stateWhere.state = { notIn: excludeStates }
}
const projectStates = await prisma.projectRoundState.findMany({ const projectStates = await prisma.projectRoundState.findMany({
where: { roundId: targetRoundId }, where: stateWhere,
select: { projectId: true }, select: { projectId: true },
}) })
const projectIds = projectStates.map((ps) => ps.projectId) const projectIds = projectStates.map((ps) => ps.projectId)