feat: revamp communication hub with recipient preview and state filtering
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
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:
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
excludeStates: z.array(z.string()).optional(),
|
||||
subject: z.string().min(1).max(500),
|
||||
body: z.string().min(1),
|
||||
deliveryChannels: z.array(z.string()).min(1),
|
||||
@@ -30,7 +31,8 @@ export const messageRouter = router({
|
||||
ctx.prisma,
|
||||
input.recipientType,
|
||||
input.recipientFilter,
|
||||
input.roundId
|
||||
input.roundId,
|
||||
input.excludeStates
|
||||
)
|
||||
|
||||
if (recipientUserIds.length === 0) {
|
||||
@@ -385,6 +387,72 @@ export const messageRouter = router({
|
||||
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.
|
||||
*/
|
||||
@@ -415,7 +483,8 @@ async function resolveRecipients(
|
||||
prisma: PrismaClient,
|
||||
recipientType: string,
|
||||
recipientFilter: unknown,
|
||||
roundId?: string
|
||||
roundId?: string,
|
||||
excludeStates?: string[]
|
||||
): Promise<string[]> {
|
||||
const filter = recipientFilter as Record<string, unknown> | undefined
|
||||
|
||||
@@ -454,9 +523,13 @@ async function resolveRecipients(
|
||||
case 'ROUND_APPLICANTS': {
|
||||
const targetRoundId = roundId || (filter?.roundId as string)
|
||||
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({
|
||||
where: { roundId: targetRoundId },
|
||||
where: stateWhere,
|
||||
select: { projectId: true },
|
||||
})
|
||||
const projectIds = projectStates.map((ps) => ps.projectId)
|
||||
|
||||
Reference in New Issue
Block a user