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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user