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

@@ -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<string, unknown> = { 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<string, unknown> = { 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<string, {
user: { id: string; name: string | null; email: string };
projects: { id: string; title: string }[];
rounds: Set<string>;
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<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(
prisma: PrismaClient,
recipientType: string,