feat: multi-round messaging, login logo, applicant seed user
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m40s
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:
@@ -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
|
||||||
|
|||||||
@@ -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 !== undefined && (
|
||||||
{user.projectCount} project{user.projectCount !== 1 ? 's' : ''} assigned
|
<>{user.projectCount} project{user.projectCount !== 1 ? 's' : ''} assigned</>
|
||||||
</span>
|
)}
|
||||||
)}
|
{user.roundNames && user.roundNames.length > 1 && (
|
||||||
|
<> · {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 }>
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user