feat: multi-round messaging, login logo, applicant seed user
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m40s

- Communication hub now supports selecting multiple rounds when sending
  to Round Jury or Round Applicants (checkbox list instead of dropdown)
- Recipients are unioned across selected rounds with deduplication
- Recipient details grouped by round when multiple rounds selected
- Added MOPC logo above "Welcome back" on login page
- Added matt@letsbe.solutions as seeded APPLICANT account

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 12:22:01 +01:00
parent 3180bfa946
commit e7b99fff63
4 changed files with 205 additions and 71 deletions

View File

@@ -316,6 +316,7 @@ async function main() {
const staffAccounts = [ const staffAccounts = [
{ email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' }, { email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' }, { email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' }, { email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
] ]
@@ -323,10 +324,10 @@ async function main() {
const staffUsers: Record<string, string> = {} const staffUsers: Record<string, string> = {}
for (const account of staffAccounts) { for (const account of staffAccounts) {
const passwordHash = await bcrypt.hash(account.password, 12) const passwordHash = await bcrypt.hash(account.password, 12)
const isSuperAdmin = account.role === UserRole.SUPER_ADMIN const needsPassword = account.role === UserRole.SUPER_ADMIN || account.role === UserRole.APPLICANT
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email: account.email }, where: { email: account.email },
update: isSuperAdmin update: needsPassword
? { ? {
status: UserStatus.ACTIVE, status: UserStatus.ACTIVE,
passwordHash, passwordHash,
@@ -348,11 +349,11 @@ async function main() {
name: account.name, name: account.name,
role: account.role, role: account.role,
roles: [account.role], roles: [account.role],
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE, status: needsPassword ? UserStatus.ACTIVE : UserStatus.NONE,
passwordHash: isSuperAdmin ? passwordHash : null, passwordHash: needsPassword ? passwordHash : null,
mustSetPassword: !isSuperAdmin, mustSetPassword: !needsPassword,
passwordSetAt: isSuperAdmin ? new Date() : null, passwordSetAt: needsPassword ? new Date() : null,
onboardingCompletedAt: isSuperAdmin ? new Date() : null, onboardingCompletedAt: needsPassword ? new Date() : null,
}, },
}) })
staffUsers[account.email] = user.id staffUsers[account.email] = user.id

View File

@@ -109,7 +109,7 @@ const STATE_BADGE_VARIANT: Record<string, 'default' | 'success' | 'destructive'
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('')
const [roundId, setRoundId] = useState('') const [roundIds, setRoundIds] = useState<string[]>([])
const [selectedProgramId, setSelectedProgramId] = useState('') const [selectedProgramId, setSelectedProgramId] = useState('')
const [selectedUserId, setSelectedUserId] = useState('') const [selectedUserId, setSelectedUserId] = useState('')
const [subject, setSubject] = useState('') const [subject, setSubject] = useState('')
@@ -155,14 +155,14 @@ export default function MessagesPage() {
{ {
recipientType, recipientType,
recipientFilter: buildRecipientFilterValue(), recipientFilter: buildRecipientFilterValue(),
roundId: roundId || undefined, roundIds: roundIds.length > 0 ? roundIds : undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined, excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
}, },
{ {
enabled: recipientType === 'ROUND_APPLICANTS' enabled: recipientType === 'ROUND_APPLICANTS'
? !!roundId ? roundIds.length > 0
: recipientType === 'ROUND_JURY' : recipientType === 'ROUND_JURY'
? !!roundId ? roundIds.length > 0
: recipientType === 'ROLE' : recipientType === 'ROLE'
? !!selectedRole ? !!selectedRole
: recipientType === 'USER' : recipientType === 'USER'
@@ -179,15 +179,15 @@ export default function MessagesPage() {
{ {
recipientType, recipientType,
recipientFilter: buildRecipientFilterValue(), recipientFilter: buildRecipientFilterValue(),
roundId: roundId || undefined, roundIds: roundIds.length > 0 ? roundIds : undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined, excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
}, },
{ {
enabled: showRecipientDetails && ( enabled: showRecipientDetails && (
recipientType === 'ROUND_APPLICANTS' recipientType === 'ROUND_APPLICANTS'
? !!roundId ? roundIds.length > 0
: recipientType === 'ROUND_JURY' : recipientType === 'ROUND_JURY'
? !!roundId ? roundIds.length > 0
: recipientType === 'ROLE' : recipientType === 'ROLE'
? !!selectedRole ? !!selectedRole
: recipientType === 'USER' : recipientType === 'USER'
@@ -224,7 +224,7 @@ export default function MessagesPage() {
setBody('') setBody('')
setSelectedTemplateId('') setSelectedTemplateId('')
setSelectedRole('') setSelectedRole('')
setRoundId('') setRoundIds([])
setSelectedProgramId('') setSelectedProgramId('')
setSelectedUserId('') setSelectedUserId('')
setIsScheduled(false) setIsScheduled(false)
@@ -276,18 +276,24 @@ export default function MessagesPage() {
return roleLabel ? `All ${roleLabel}s` : 'By Role (none selected)' return roleLabel ? `All ${roleLabel}s` : 'By Role (none selected)'
} }
case 'ROUND_JURY': { case 'ROUND_JURY': {
if (!roundId) return 'Stage Jury (none selected)' if (roundIds.length === 0) return 'Stage Jury (none selected)'
const stage = rounds?.find((r) => r.id === roundId) const selectedJuryRounds = rounds?.filter((r) => roundIds.includes(r.id))
return stage if (!selectedJuryRounds?.length) return 'Stage Jury'
? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}` if (selectedJuryRounds.length === 1) {
: 'Stage Jury' const s = selectedJuryRounds[0]
return `Jury of ${s.program ? `${s.program.name} - ` : ''}${s.name}`
}
return `Jury across ${selectedJuryRounds.length} rounds`
} }
case 'ROUND_APPLICANTS': { case 'ROUND_APPLICANTS': {
if (!roundId) return 'Round Applicants (none selected)' if (roundIds.length === 0) return 'Round Applicants (none selected)'
const appRound = rounds?.find((r) => r.id === roundId) const selectedAppRounds = rounds?.filter((r) => roundIds.includes(r.id))
return appRound if (!selectedAppRounds?.length) return 'Round Applicants'
? `Applicants in ${appRound.program ? `${appRound.program.name} - ` : ''}${appRound.name}` if (selectedAppRounds.length === 1) {
: 'Round Applicants' const ar = selectedAppRounds[0]
return `Applicants in ${ar.program ? `${ar.program.name} - ` : ''}${ar.name}`
}
return `Applicants across ${selectedAppRounds.length} rounds`
} }
case 'PROGRAM_TEAM': { case 'PROGRAM_TEAM': {
if (!selectedProgramId) return 'Program Team (none selected)' if (!selectedProgramId) return 'Program Team (none selected)'
@@ -324,8 +330,8 @@ export default function MessagesPage() {
toast.error('Please select a role') toast.error('Please select a role')
return false return false
} }
if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && !roundId) { if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && roundIds.length === 0) {
toast.error('Please select a round') toast.error('Please select at least one round')
return false return false
} }
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) { if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
@@ -348,7 +354,7 @@ export default function MessagesPage() {
sendMutation.mutate({ sendMutation.mutate({
recipientType, recipientType,
recipientFilter: buildRecipientFilterValue(), recipientFilter: buildRecipientFilterValue(),
roundId: roundId || undefined, roundIds: roundIds.length > 0 ? roundIds : undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined, excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
subject: subject.trim(), subject: subject.trim(),
body: body.trim(), body: body.trim(),
@@ -412,7 +418,7 @@ export default function MessagesPage() {
onValueChange={(v) => { onValueChange={(v) => {
setRecipientType(v as RecipientType) setRecipientType(v as RecipientType)
setSelectedRole('') setSelectedRole('')
setRoundId('') setRoundIds([])
setSelectedProgramId('') setSelectedProgramId('')
setSelectedUserId('') setSelectedUserId('')
}} }}
@@ -451,24 +457,44 @@ export default function MessagesPage() {
{(recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && ( {(recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Select Round</Label> <Label>Select Rounds</Label>
<Select value={roundId} onValueChange={setRoundId}> <div className="rounded-lg border p-3 space-y-2 max-h-48 overflow-y-auto">
<SelectTrigger> {rounds?.length === 0 && (
<SelectValue placeholder="Choose a round..." /> <p className="text-sm text-muted-foreground">No rounds available</p>
</SelectTrigger> )}
<SelectContent> {rounds?.map((round) => {
{rounds?.map((round) => ( const label = round.program ? `${round.program.name} - ${round.name}` : round.name
<SelectItem key={round.id} value={round.id}> const isChecked = roundIds.includes(round.id)
{round.program ? `${round.program.name} - ${round.name}` : round.name} return (
</SelectItem> <div key={round.id} className="flex items-center gap-2">
))} <Checkbox
</SelectContent> id={`round-${round.id}`}
</Select> checked={isChecked}
onCheckedChange={(checked) => {
setRoundIds((prev) =>
checked
? [...prev, round.id]
: prev.filter((id) => id !== round.id)
)
}}
/>
<label htmlFor={`round-${round.id}`} className="text-sm cursor-pointer">
{label}
</label>
</div>
)
})}
</div>
{roundIds.length > 0 && (
<p className="text-xs text-muted-foreground">
{roundIds.length} round{roundIds.length > 1 ? 's' : ''} selected
</p>
)}
</div> </div>
)} )}
{/* Exclude filters for Round Applicants */} {/* Exclude filters for Round Applicants */}
{recipientType === 'ROUND_APPLICANTS' && roundId && ( {recipientType === 'ROUND_APPLICANTS' && roundIds.length > 0 && (
<div className="rounded-lg border p-3 space-y-2"> <div className="rounded-lg border p-3 space-y-2">
<Label className="text-sm font-medium">Exclude Project States</Label> <Label className="text-sm font-medium">Exclude Project States</Label>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
@@ -1119,6 +1145,8 @@ type RecipientDetailsData = {
id: string id: string
title: string title: string
state: string state: string
roundId?: string
roundName?: string
members: Array<{ id: string; name: string | null; email: string }> members: Array<{ id: string; name: string | null; email: string }>
}> }>
users: Array<{ users: Array<{
@@ -1129,6 +1157,7 @@ type RecipientDetailsData = {
projectNames?: string[] projectNames?: string[]
projectName?: string | null projectName?: string | null
role?: string role?: string
roundNames?: string[]
}> }>
} }
@@ -1162,12 +1191,38 @@ function RecipientDetailsList({
) : !data || (data.projects.length === 0 && data.users.length === 0) ? ( ) : !data || (data.projects.length === 0 && data.users.length === 0) ? (
<p className="text-xs text-muted-foreground p-1">No recipients found.</p> <p className="text-xs text-muted-foreground p-1">No recipients found.</p>
) : data.type === 'projects' ? ( ) : data.type === 'projects' ? (
// ROUND_APPLICANTS: projects with their members // ROUND_APPLICANTS: projects grouped by round (if multi-round)
data.projects.map((project) => ( (() => {
<ProjectRecipientRow key={project.id} project={project} /> const roundGroups = new Map<string, { roundName: string; projects: typeof data.projects }>()
for (const project of data.projects) {
const key = project.roundId || '_unknown'
const existing = roundGroups.get(key)
if (existing) {
existing.projects.push(project)
} else {
roundGroups.set(key, {
roundName: project.roundName || 'Unknown Round',
projects: [project],
})
}
}
const groups = Array.from(roundGroups.values())
const isMultiRound = groups.length > 1
return groups.map((group) => (
<div key={group.roundName}>
{isMultiRound && (
<div className="sticky top-0 bg-muted/60 backdrop-blur-sm px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground border-b mb-1">
{group.roundName} ({group.projects.length})
</div>
)}
{group.projects.map((project) => (
<ProjectRecipientRow key={`${project.roundId}-${project.id}`} project={project} />
))}
</div>
)) ))
})()
) : data.type === 'jurors' ? ( ) : data.type === 'jurors' ? (
// ROUND_JURY: jurors with project counts // ROUND_JURY: jurors with project counts and round info
data.users.map((user) => ( data.users.map((user) => (
<div key={user.id} className="flex items-center justify-between rounded px-2 py-1.5 text-xs hover:bg-muted/50"> <div key={user.id} className="flex items-center justify-between rounded px-2 py-1.5 text-xs hover:bg-muted/50">
<div className="min-w-0"> <div className="min-w-0">
@@ -1177,11 +1232,14 @@ function RecipientDetailsList({
> >
{user.name || user.email} {user.name || user.email}
</Link> </Link>
{user.projectCount !== undefined && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{user.projectCount} project{user.projectCount !== 1 ? 's' : ''} assigned {user.projectCount !== undefined && (
</span> <>{user.projectCount} project{user.projectCount !== 1 ? 's' : ''} assigned</>
)} )}
{user.roundNames && user.roundNames.length > 1 && (
<> &middot; {user.roundNames.length} rounds</>
)}
</span>
</div> </div>
</div> </div>
)) ))
@@ -1219,6 +1277,8 @@ function ProjectRecipientRow({ project }: {
id: string id: string
title: string title: string
state: string state: string
roundId?: string
roundName?: string
members: Array<{ id: string; name: string | null; email: string }> members: Array<{ id: string; name: string | null; email: string }>
} }
}) { }) {

View File

@@ -4,6 +4,7 @@ import { useState } from 'react'
import type { Route } from 'next' import type { Route } from 'next'
import { useSearchParams, useRouter } from 'next/navigation' import { useSearchParams, useRouter } from 'next/navigation'
import { signIn } from 'next-auth/react' import { signIn } from 'next-auth/react'
import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -165,6 +166,15 @@ export default function LoginPage() {
<Card className="w-full max-w-md overflow-hidden"> <Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" /> <div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="flex justify-center mb-2">
<Image
src="/images/MOPC-blue-small.png"
alt="MOPC"
width={48}
height={48}
className="h-12 w-auto"
/>
</div>
<CardTitle className="text-2xl">Welcome back</CardTitle> <CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription> <CardDescription>
{mode === 'password' {mode === 'password'

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(),
roundIds: z.array(z.string()).optional(),
excludeStates: z.array(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),
@@ -27,12 +28,19 @@ export const messageRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Resolve recipients based on type // Normalize: prefer roundIds array, fall back to single roundId
const recipientUserIds = await resolveRecipients( const effectiveRoundIds = input.roundIds?.length
? input.roundIds
: input.roundId
? [input.roundId]
: []
// Resolve recipients based on type (union across all selected rounds)
const recipientUserIds = await resolveRecipientsMultiRound(
ctx.prisma, ctx.prisma,
input.recipientType, input.recipientType,
input.recipientFilter, input.recipientFilter,
input.roundId, effectiveRoundIds,
input.excludeStates input.excludeStates
) )
@@ -52,7 +60,8 @@ export const messageRouter = router({
senderId: ctx.user.id, senderId: ctx.user.id,
recipientType: input.recipientType, recipientType: input.recipientType,
recipientFilter: input.recipientFilter ?? undefined, recipientFilter: input.recipientFilter ?? undefined,
roundId: input.roundId, roundId: effectiveRoundIds[0] ?? null,
metadata: effectiveRoundIds.length > 1 ? { roundIds: effectiveRoundIds } : undefined,
templateId: input.templateId, templateId: input.templateId,
subject: input.subject, subject: input.subject,
body: input.body, body: input.body,
@@ -416,16 +425,24 @@ 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(),
roundIds: z.array(z.string()).optional(),
excludeStates: z.array(z.string()).optional(), excludeStates: z.array(z.string()).optional(),
})) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const effectiveRoundIds = input.roundIds?.length
? input.roundIds
: input.roundId
? [input.roundId]
: []
// For ROUND_APPLICANTS, return a breakdown by project state // For ROUND_APPLICANTS, return a breakdown by project state
if (input.recipientType === 'ROUND_APPLICANTS' && input.roundId) { if (input.recipientType === 'ROUND_APPLICANTS' && effectiveRoundIds.length > 0) {
const projectStates = await ctx.prisma.projectRoundState.findMany({ const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId }, where: { roundId: { in: effectiveRoundIds } },
select: { select: {
state: true, state: true,
projectId: true, projectId: true,
roundId: true,
project: { project: {
select: { select: {
submittedByUserId: true, submittedByUserId: true,
@@ -458,11 +475,11 @@ export const messageRouter = router({
} }
// For other recipient types, just count resolved users // For other recipient types, just count resolved users
const userIds = await resolveRecipients( const userIds = await resolveRecipientsMultiRound(
ctx.prisma, ctx.prisma,
input.recipientType, input.recipientType,
input.recipientFilter, input.recipientFilter,
input.roundId, effectiveRoundIds,
input.excludeStates input.excludeStates
) )
@@ -482,12 +499,19 @@ 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(),
roundIds: z.array(z.string()).optional(),
excludeStates: z.array(z.string()).optional(), excludeStates: z.array(z.string()).optional(),
})) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
// For ROUND_APPLICANTS, return users grouped by project const effectiveRoundIds = input.roundIds?.length
if (input.recipientType === 'ROUND_APPLICANTS' && input.roundId) { ? input.roundIds
const stateWhere: Record<string, unknown> = { roundId: input.roundId } : input.roundId
? [input.roundId]
: []
// For ROUND_APPLICANTS, return users grouped by project (and round if multi-round)
if (input.recipientType === 'ROUND_APPLICANTS' && effectiveRoundIds.length > 0) {
const stateWhere: Record<string, unknown> = { roundId: { in: effectiveRoundIds } }
if (input.excludeStates && input.excludeStates.length > 0) { if (input.excludeStates && input.excludeStates.length > 0) {
stateWhere.state = { notIn: input.excludeStates } stateWhere.state = { notIn: input.excludeStates }
} }
@@ -495,6 +519,8 @@ export const messageRouter = router({
where: stateWhere, where: stateWhere,
select: { select: {
state: true, state: true,
roundId: true,
round: { select: { id: true, name: true, competition: { select: { name: true } } } },
project: { project: {
select: { select: {
id: true, id: true,
@@ -513,6 +539,8 @@ export const messageRouter = router({
id: ps.project.id, id: ps.project.id,
title: ps.project.title, title: ps.project.title,
state: ps.state, state: ps.state,
roundId: ps.roundId,
roundName: ps.round ? `${ps.round.competition?.name ? ps.round.competition.name + ' - ' : ''}${ps.round.name}` : undefined,
members: [ members: [
...(ps.project.submittedBy ? [ps.project.submittedBy] : []), ...(ps.project.submittedBy ? [ps.project.submittedBy] : []),
...ps.project.teamMembers ...ps.project.teamMembers
@@ -524,11 +552,13 @@ export const messageRouter = router({
} }
} }
// For ROUND_JURY, return users grouped by their assignments // For ROUND_JURY, return users grouped by their assignments (and round if multi-round)
if (input.recipientType === 'ROUND_JURY' && input.roundId) { if (input.recipientType === 'ROUND_JURY' && effectiveRoundIds.length > 0) {
const assignments = await ctx.prisma.assignment.findMany({ const assignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId }, where: { roundId: { in: effectiveRoundIds } },
select: { select: {
roundId: true,
round: { select: { id: true, name: true, competition: { select: { name: true } } } },
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true, email: true } },
project: { select: { id: true, title: true } }, project: { select: { id: true, title: true } },
}, },
@@ -537,13 +567,25 @@ export const messageRouter = router({
const userMap = new Map<string, { const userMap = new Map<string, {
user: { id: string; name: string | null; email: string }; user: { id: string; name: string | null; email: string };
projects: { id: string; title: string }[]; projects: { id: string; title: string }[];
rounds: Set<string>;
roundNames: string[];
}>() }>()
for (const a of assignments) { for (const a of assignments) {
const existing = userMap.get(a.user.id) const existing = userMap.get(a.user.id)
const roundLabel = a.round ? `${a.round.competition?.name ? a.round.competition.name + ' - ' : ''}${a.round.name}` : a.roundId
if (existing) { if (existing) {
existing.projects.push(a.project) existing.projects.push(a.project)
if (!existing.rounds.has(a.roundId)) {
existing.rounds.add(a.roundId)
existing.roundNames.push(roundLabel)
}
} else { } else {
userMap.set(a.user.id, { user: a.user, projects: [a.project] }) userMap.set(a.user.id, {
user: a.user,
projects: [a.project],
rounds: new Set([a.roundId]),
roundNames: [roundLabel],
})
} }
} }
return { return {
@@ -553,16 +595,17 @@ export const messageRouter = router({
...entry.user, ...entry.user,
projectCount: entry.projects.length, projectCount: entry.projects.length,
projectNames: entry.projects.map((p) => p.title).slice(0, 5), projectNames: entry.projects.map((p) => p.title).slice(0, 5),
roundNames: entry.roundNames,
})), })),
} }
} }
// For all other types, just return the user list // For all other types, just return the user list
const userIds = await resolveRecipients( const userIds = await resolveRecipientsMultiRound(
ctx.prisma, ctx.prisma,
input.recipientType, input.recipientType,
input.recipientFilter, input.recipientFilter,
input.roundId, effectiveRoundIds,
input.excludeStates input.excludeStates
) )
if (userIds.length === 0) return { type: 'users' as const, projects: [], users: [] } if (userIds.length === 0) return { type: 'users' as const, projects: [], users: [] }
@@ -615,11 +658,31 @@ export const messageRouter = router({
}) })
// ============================================================================= // =============================================================================
// Helper: Resolve recipient user IDs based on recipientType // Helper: Resolve recipient user IDs based on recipientType (multi-round)
// ============================================================================= // =============================================================================
type PrismaClient = Parameters<Parameters<typeof adminProcedure.mutation>[0]>[0]['ctx']['prisma'] type PrismaClient = Parameters<Parameters<typeof adminProcedure.mutation>[0]>[0]['ctx']['prisma']
async function resolveRecipientsMultiRound(
prisma: PrismaClient,
recipientType: string,
recipientFilter: unknown,
roundIds: string[],
excludeStates?: string[]
): Promise<string[]> {
// For round-based types, union across all selected rounds
if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && roundIds.length > 0) {
const allUserIds = new Set<string>()
for (const rid of roundIds) {
const ids = await resolveRecipients(prisma, recipientType, recipientFilter, rid, excludeStates)
for (const id of ids) allUserIds.add(id)
}
return [...allUserIds]
}
// For non-round types, delegate to single-round resolver
return resolveRecipients(prisma, recipientType, recipientFilter, roundIds[0], excludeStates)
}
async function resolveRecipients( async function resolveRecipients(
prisma: PrismaClient, prisma: PrismaClient,
recipientType: string, recipientType: string,