feat: revamp communication hub with recipient preview and state filtering
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- New previewRecipients query shows live project/applicant counts as you
  compose, with per-state breakdown for Round Applicants
- Exclude Rejected/Withdrawn checkboxes filter out terminal-state projects
- Compose form now has 2/3 + 1/3 layout with always-visible recipient
  summary sidebar showing project counts, applicant counts, state badges
- Preview dialog enlarged (max-w-3xl) with split layout: email preview
  on left, recipient/delivery summary on right
- Send button now shows recipient count ("Send to N Recipients")
- resolveRecipients accepts excludeStates param for ROUND_APPLICANTS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 10:32:03 +01:00
parent ea46d7293f
commit 34fc0b81e0
2 changed files with 672 additions and 332 deletions

File diff suppressed because it is too large Load Diff

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(),
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),
deliveryChannels: z.array(z.string()).min(1), deliveryChannels: z.array(z.string()).min(1),
@@ -30,7 +31,8 @@ export const messageRouter = router({
ctx.prisma, ctx.prisma,
input.recipientType, input.recipientType,
input.recipientFilter, input.recipientFilter,
input.roundId input.roundId,
input.excludeStates
) )
if (recipientUserIds.length === 0) { if (recipientUserIds.length === 0) {
@@ -385,6 +387,72 @@ export const messageRouter = router({
return { html: getEmailPreviewHtml(input.subject, input.body) } return { html: getEmailPreviewHtml(input.subject, input.body) }
}), }),
/**
* Preview recipient counts for a given recipient type + filters.
* Returns project breakdown by state for ROUND_APPLICANTS, or total user count for others.
*/
previewRecipients: adminProcedure
.input(z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
recipientFilter: z.any().optional(),
roundId: z.string().optional(),
excludeStates: z.array(z.string()).optional(),
}))
.query(async ({ ctx, input }) => {
// For ROUND_APPLICANTS, return a breakdown by project state
if (input.recipientType === 'ROUND_APPLICANTS' && input.roundId) {
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId },
select: {
state: true,
projectId: true,
project: {
select: {
submittedByUserId: true,
teamMembers: { select: { userId: true } },
},
},
},
})
// Count projects per state
const stateBreakdown: Record<string, number> = {}
for (const ps of projectStates) {
stateBreakdown[ps.state] = (stateBreakdown[ps.state] || 0) + 1
}
// Compute total unique users respecting exclusions
const excludeSet = new Set(input.excludeStates ?? [])
const includedUserIds = new Set<string>()
for (const ps of projectStates) {
if (excludeSet.has(ps.state)) continue
if (ps.project.submittedByUserId) includedUserIds.add(ps.project.submittedByUserId)
for (const tm of ps.project.teamMembers) includedUserIds.add(tm.userId)
}
return {
totalProjects: projectStates.length,
totalApplicants: includedUserIds.size,
stateBreakdown,
}
}
// For other recipient types, just count resolved users
const userIds = await resolveRecipients(
ctx.prisma,
input.recipientType,
input.recipientFilter,
input.roundId,
input.excludeStates
)
return {
totalProjects: 0,
totalApplicants: userIds.length,
stateBreakdown: {} as Record<string, number>,
}
}),
/** /**
* Send a test email to the currently logged-in admin. * Send a test email to the currently logged-in admin.
*/ */
@@ -415,7 +483,8 @@ async function resolveRecipients(
prisma: PrismaClient, prisma: PrismaClient,
recipientType: string, recipientType: string,
recipientFilter: unknown, recipientFilter: unknown,
roundId?: string roundId?: string,
excludeStates?: string[]
): Promise<string[]> { ): Promise<string[]> {
const filter = recipientFilter as Record<string, unknown> | undefined const filter = recipientFilter as Record<string, unknown> | undefined
@@ -454,9 +523,13 @@ async function resolveRecipients(
case 'ROUND_APPLICANTS': { case 'ROUND_APPLICANTS': {
const targetRoundId = roundId || (filter?.roundId as string) const targetRoundId = roundId || (filter?.roundId as string)
if (!targetRoundId) return [] if (!targetRoundId) return []
// Get all projects in this round // Get all projects in this round, optionally excluding certain states
const stateWhere: Record<string, unknown> = { roundId: targetRoundId }
if (excludeStates && excludeStates.length > 0) {
stateWhere.state = { notIn: excludeStates }
}
const projectStates = await prisma.projectRoundState.findMany({ const projectStates = await prisma.projectRoundState.findMany({
where: { roundId: targetRoundId }, where: stateWhere,
select: { projectId: true }, select: { projectId: true },
}) })
const projectIds = projectStates.map((ps) => ps.projectId) const projectIds = projectStates.map((ps) => ps.projectId)