From e7b99fff633a5fbd9eb4ef61041b1e17c19c50fb Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 6 Mar 2026 12:22:01 +0100 Subject: [PATCH] feat: multi-round messaging, login logo, applicant seed user - 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 --- prisma/seed.ts | 15 +-- src/app/(admin)/admin/messages/page.tsx | 152 +++++++++++++++++------- src/app/(auth)/login/page.tsx | 10 ++ src/server/routers/message.ts | 99 ++++++++++++--- 4 files changed, 205 insertions(+), 71 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index 2ec3d3d..3bb9a79 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -316,6 +316,7 @@ async function main() { const staffAccounts = [ { 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: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' }, ] @@ -323,10 +324,10 @@ async function main() { const staffUsers: Record = {} for (const account of staffAccounts) { 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({ where: { email: account.email }, - update: isSuperAdmin + update: needsPassword ? { status: UserStatus.ACTIVE, passwordHash, @@ -348,11 +349,11 @@ async function main() { name: account.name, role: account.role, roles: [account.role], - status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE, - passwordHash: isSuperAdmin ? passwordHash : null, - mustSetPassword: !isSuperAdmin, - passwordSetAt: isSuperAdmin ? new Date() : null, - onboardingCompletedAt: isSuperAdmin ? new Date() : null, + status: needsPassword ? UserStatus.ACTIVE : UserStatus.NONE, + passwordHash: needsPassword ? passwordHash : null, + mustSetPassword: !needsPassword, + passwordSetAt: needsPassword ? new Date() : null, + onboardingCompletedAt: needsPassword ? new Date() : null, }, }) staffUsers[account.email] = user.id diff --git a/src/app/(admin)/admin/messages/page.tsx b/src/app/(admin)/admin/messages/page.tsx index afc6c1f..c3422a2 100644 --- a/src/app/(admin)/admin/messages/page.tsx +++ b/src/app/(admin)/admin/messages/page.tsx @@ -109,7 +109,7 @@ const STATE_BADGE_VARIANT: Record('ALL') const [selectedRole, setSelectedRole] = useState('') - const [roundId, setRoundId] = useState('') + const [roundIds, setRoundIds] = useState([]) const [selectedProgramId, setSelectedProgramId] = useState('') const [selectedUserId, setSelectedUserId] = useState('') const [subject, setSubject] = useState('') @@ -155,14 +155,14 @@ export default function MessagesPage() { { recipientType, recipientFilter: buildRecipientFilterValue(), - roundId: roundId || undefined, + roundIds: roundIds.length > 0 ? roundIds : undefined, excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined, }, { enabled: recipientType === 'ROUND_APPLICANTS' - ? !!roundId + ? roundIds.length > 0 : recipientType === 'ROUND_JURY' - ? !!roundId + ? roundIds.length > 0 : recipientType === 'ROLE' ? !!selectedRole : recipientType === 'USER' @@ -179,15 +179,15 @@ export default function MessagesPage() { { recipientType, recipientFilter: buildRecipientFilterValue(), - roundId: roundId || undefined, + roundIds: roundIds.length > 0 ? roundIds : undefined, excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined, }, { enabled: showRecipientDetails && ( recipientType === 'ROUND_APPLICANTS' - ? !!roundId + ? roundIds.length > 0 : recipientType === 'ROUND_JURY' - ? !!roundId + ? roundIds.length > 0 : recipientType === 'ROLE' ? !!selectedRole : recipientType === 'USER' @@ -224,7 +224,7 @@ export default function MessagesPage() { setBody('') setSelectedTemplateId('') setSelectedRole('') - setRoundId('') + setRoundIds([]) setSelectedProgramId('') setSelectedUserId('') setIsScheduled(false) @@ -276,18 +276,24 @@ export default function MessagesPage() { return roleLabel ? `All ${roleLabel}s` : 'By Role (none selected)' } case 'ROUND_JURY': { - if (!roundId) return 'Stage Jury (none selected)' - const stage = rounds?.find((r) => r.id === roundId) - return stage - ? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}` - : 'Stage Jury' + if (roundIds.length === 0) return 'Stage Jury (none selected)' + const selectedJuryRounds = rounds?.filter((r) => roundIds.includes(r.id)) + if (!selectedJuryRounds?.length) return 'Stage Jury' + if (selectedJuryRounds.length === 1) { + const s = selectedJuryRounds[0] + return `Jury of ${s.program ? `${s.program.name} - ` : ''}${s.name}` + } + return `Jury across ${selectedJuryRounds.length} rounds` } case 'ROUND_APPLICANTS': { - if (!roundId) return 'Round Applicants (none selected)' - const appRound = rounds?.find((r) => r.id === roundId) - return appRound - ? `Applicants in ${appRound.program ? `${appRound.program.name} - ` : ''}${appRound.name}` - : 'Round Applicants' + if (roundIds.length === 0) return 'Round Applicants (none selected)' + const selectedAppRounds = rounds?.filter((r) => roundIds.includes(r.id)) + if (!selectedAppRounds?.length) return 'Round Applicants' + if (selectedAppRounds.length === 1) { + const ar = selectedAppRounds[0] + return `Applicants in ${ar.program ? `${ar.program.name} - ` : ''}${ar.name}` + } + return `Applicants across ${selectedAppRounds.length} rounds` } case 'PROGRAM_TEAM': { if (!selectedProgramId) return 'Program Team (none selected)' @@ -324,8 +330,8 @@ export default function MessagesPage() { toast.error('Please select a role') return false } - if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && !roundId) { - toast.error('Please select a round') + if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && roundIds.length === 0) { + toast.error('Please select at least one round') return false } if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) { @@ -348,7 +354,7 @@ export default function MessagesPage() { sendMutation.mutate({ recipientType, recipientFilter: buildRecipientFilterValue(), - roundId: roundId || undefined, + roundIds: roundIds.length > 0 ? roundIds : undefined, excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined, subject: subject.trim(), body: body.trim(), @@ -412,7 +418,7 @@ export default function MessagesPage() { onValueChange={(v) => { setRecipientType(v as RecipientType) setSelectedRole('') - setRoundId('') + setRoundIds([]) setSelectedProgramId('') setSelectedUserId('') }} @@ -451,24 +457,44 @@ export default function MessagesPage() { {(recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && (
- - + +
+ {rounds?.length === 0 && ( +

No rounds available

+ )} + {rounds?.map((round) => { + const label = round.program ? `${round.program.name} - ${round.name}` : round.name + const isChecked = roundIds.includes(round.id) + return ( +
+ { + setRoundIds((prev) => + checked + ? [...prev, round.id] + : prev.filter((id) => id !== round.id) + ) + }} + /> + +
+ ) + })} +
+ {roundIds.length > 0 && ( +

+ {roundIds.length} round{roundIds.length > 1 ? 's' : ''} selected +

+ )}
)} {/* Exclude filters for Round Applicants */} - {recipientType === 'ROUND_APPLICANTS' && roundId && ( + {recipientType === 'ROUND_APPLICANTS' && roundIds.length > 0 && (
@@ -1119,6 +1145,8 @@ type RecipientDetailsData = { id: string title: string state: string + roundId?: string + roundName?: string members: Array<{ id: string; name: string | null; email: string }> }> users: Array<{ @@ -1129,6 +1157,7 @@ type RecipientDetailsData = { projectNames?: string[] projectName?: string | null role?: string + roundNames?: string[] }> } @@ -1162,12 +1191,38 @@ function RecipientDetailsList({ ) : !data || (data.projects.length === 0 && data.users.length === 0) ? (

No recipients found.

) : data.type === 'projects' ? ( - // ROUND_APPLICANTS: projects with their members - data.projects.map((project) => ( - - )) + // ROUND_APPLICANTS: projects grouped by round (if multi-round) + (() => { + const roundGroups = new Map() + 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) => ( +
+ {isMultiRound && ( +
+ {group.roundName} ({group.projects.length}) +
+ )} + {group.projects.map((project) => ( + + ))} +
+ )) + })() ) : data.type === 'jurors' ? ( - // ROUND_JURY: jurors with project counts + // ROUND_JURY: jurors with project counts and round info data.users.map((user) => (
@@ -1177,11 +1232,14 @@ function RecipientDetailsList({ > {user.name || user.email} - {user.projectCount !== undefined && ( - - {user.projectCount} project{user.projectCount !== 1 ? 's' : ''} assigned - - )} + + {user.projectCount !== undefined && ( + <>{user.projectCount} project{user.projectCount !== 1 ? 's' : ''} assigned + )} + {user.roundNames && user.roundNames.length > 1 && ( + <> · {user.roundNames.length} rounds + )} +
)) @@ -1219,6 +1277,8 @@ function ProjectRecipientRow({ project }: { id: string title: string state: string + roundId?: string + roundName?: string members: Array<{ id: string; name: string | null; email: string }> } }) { diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 5a907aa..8c6e5d8 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import type { Route } from 'next' import { useSearchParams, useRouter } from 'next/navigation' import { signIn } from 'next-auth/react' +import Image from 'next/image' import Link from 'next/link' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -165,6 +166,15 @@ export default function LoginPage() {
+
+ MOPC +
Welcome back {mode === 'password' diff --git a/src/server/routers/message.ts b/src/server/routers/message.ts index 510e037..0da3290 100644 --- a/src/server/routers/message.ts +++ b/src/server/routers/message.ts @@ -17,6 +17,7 @@ export const messageRouter = router({ recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']), recipientFilter: z.any().optional(), roundId: z.string().optional(), + roundIds: z.array(z.string()).optional(), excludeStates: z.array(z.string()).optional(), subject: z.string().min(1).max(500), body: z.string().min(1), @@ -27,12 +28,19 @@ export const messageRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - // Resolve recipients based on type - const recipientUserIds = await resolveRecipients( + // Normalize: prefer roundIds array, fall back to single roundId + 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, input.recipientType, input.recipientFilter, - input.roundId, + effectiveRoundIds, input.excludeStates ) @@ -52,7 +60,8 @@ export const messageRouter = router({ senderId: ctx.user.id, recipientType: input.recipientType, recipientFilter: input.recipientFilter ?? undefined, - roundId: input.roundId, + roundId: effectiveRoundIds[0] ?? null, + metadata: effectiveRoundIds.length > 1 ? { roundIds: effectiveRoundIds } : undefined, templateId: input.templateId, subject: input.subject, body: input.body, @@ -416,16 +425,24 @@ export const messageRouter = router({ recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']), recipientFilter: z.any().optional(), roundId: z.string().optional(), + roundIds: z.array(z.string()).optional(), excludeStates: z.array(z.string()).optional(), })) .query(async ({ ctx, input }) => { + const effectiveRoundIds = input.roundIds?.length + ? input.roundIds + : input.roundId + ? [input.roundId] + : [] + // 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({ - where: { roundId: input.roundId }, + where: { roundId: { in: effectiveRoundIds } }, select: { state: true, projectId: true, + roundId: true, project: { select: { submittedByUserId: true, @@ -458,11 +475,11 @@ export const messageRouter = router({ } // For other recipient types, just count resolved users - const userIds = await resolveRecipients( + const userIds = await resolveRecipientsMultiRound( ctx.prisma, input.recipientType, input.recipientFilter, - input.roundId, + effectiveRoundIds, input.excludeStates ) @@ -482,12 +499,19 @@ export const messageRouter = router({ recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']), recipientFilter: z.any().optional(), roundId: z.string().optional(), + roundIds: z.array(z.string()).optional(), excludeStates: z.array(z.string()).optional(), })) .query(async ({ ctx, input }) => { - // For ROUND_APPLICANTS, return users grouped by project - if (input.recipientType === 'ROUND_APPLICANTS' && input.roundId) { - const stateWhere: Record = { roundId: input.roundId } + const effectiveRoundIds = input.roundIds?.length + ? input.roundIds + : 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 = { roundId: { in: effectiveRoundIds } } if (input.excludeStates && input.excludeStates.length > 0) { stateWhere.state = { notIn: input.excludeStates } } @@ -495,6 +519,8 @@ export const messageRouter = router({ where: stateWhere, select: { state: true, + roundId: true, + round: { select: { id: true, name: true, competition: { select: { name: true } } } }, project: { select: { id: true, @@ -513,6 +539,8 @@ export const messageRouter = router({ id: ps.project.id, title: ps.project.title, state: ps.state, + roundId: ps.roundId, + roundName: ps.round ? `${ps.round.competition?.name ? ps.round.competition.name + ' - ' : ''}${ps.round.name}` : undefined, members: [ ...(ps.project.submittedBy ? [ps.project.submittedBy] : []), ...ps.project.teamMembers @@ -524,11 +552,13 @@ export const messageRouter = router({ } } - // For ROUND_JURY, return users grouped by their assignments - if (input.recipientType === 'ROUND_JURY' && input.roundId) { + // For ROUND_JURY, return users grouped by their assignments (and round if multi-round) + if (input.recipientType === 'ROUND_JURY' && effectiveRoundIds.length > 0) { const assignments = await ctx.prisma.assignment.findMany({ - where: { roundId: input.roundId }, + where: { roundId: { in: effectiveRoundIds } }, select: { + roundId: true, + round: { select: { id: true, name: true, competition: { select: { name: true } } } }, user: { select: { id: true, name: true, email: true } }, project: { select: { id: true, title: true } }, }, @@ -537,13 +567,25 @@ export const messageRouter = router({ const userMap = new Map; + roundNames: string[]; }>() for (const a of assignments) { 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) { existing.projects.push(a.project) + if (!existing.rounds.has(a.roundId)) { + existing.rounds.add(a.roundId) + existing.roundNames.push(roundLabel) + } } 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 { @@ -553,16 +595,17 @@ export const messageRouter = router({ ...entry.user, projectCount: entry.projects.length, projectNames: entry.projects.map((p) => p.title).slice(0, 5), + roundNames: entry.roundNames, })), } } // For all other types, just return the user list - const userIds = await resolveRecipients( + const userIds = await resolveRecipientsMultiRound( ctx.prisma, input.recipientType, input.recipientFilter, - input.roundId, + effectiveRoundIds, input.excludeStates ) 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[0]>[0]['ctx']['prisma'] +async function resolveRecipientsMultiRound( + prisma: PrismaClient, + recipientType: string, + recipientFilter: unknown, + roundIds: string[], + excludeStates?: string[] +): Promise { + // For round-based types, union across all selected rounds + if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && roundIds.length > 0) { + const allUserIds = new Set() + 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( prisma: PrismaClient, recipientType: string,