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']),
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user