Files
MOPC-Portal/src/server/routers/message.ts
Matt b867c45114 feat: Email Team button + custom-email dialog on project page
Adds a PROJECT_TEAM recipient type to the message router (resolver
returns team members + project lead) and an "Email Team" button on
the admin project detail page that opens a self-contained dialog
matching the look of /admin/messages: subject, body (pre-filled
with "Hello [Project Title] team,\n\n"), live HTML preview iframe,
"Send test to me" + "Send to N" actions.

The composer reuses the existing message.previewEmail and
message.send tRPC procedures end-to-end — no parallel email
infrastructure introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:29:42 +02:00

873 lines
29 KiB
TypeScript

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { sendStyledNotificationEmail, getEmailPreviewHtml } from '@/lib/email'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
export const messageRouter = router({
/**
* Send a message to recipients.
* Resolves recipient list based on recipientType and delivers via specified channels.
*/
send: adminProcedure
.input(
z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'PROJECT_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),
deliveryChannels: z.array(z.string()).min(1),
scheduledAt: z.string().datetime().optional(),
templateId: z.string().optional(),
linkType: z.enum(['NONE', 'MESSAGES', 'LOGIN', 'INVITE']).default('MESSAGES'),
})
)
.mutation(async ({ ctx, input }) => {
// 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,
effectiveRoundIds,
input.excludeStates
)
if (recipientUserIds.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No recipients found for the given criteria',
})
}
const isScheduled = !!input.scheduledAt
const now = new Date()
// Create message
const message = await ctx.prisma.message.create({
data: {
senderId: ctx.user.id,
recipientType: input.recipientType,
recipientFilter: input.recipientFilter ?? undefined,
roundId: effectiveRoundIds[0] ?? null,
metadata: effectiveRoundIds.length > 1 ? { roundIds: effectiveRoundIds } : undefined,
templateId: input.templateId,
subject: input.subject,
body: input.body,
deliveryChannels: input.deliveryChannels,
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : undefined,
sentAt: isScheduled ? undefined : now,
recipients: {
create: recipientUserIds.flatMap((userId) =>
input.deliveryChannels.map((channel) => ({
userId,
channel,
}))
),
},
},
include: {
recipients: true,
},
})
// If not scheduled, deliver immediately for EMAIL channel (batched to avoid overloading SMTP)
if (!isScheduled && input.deliveryChannels.includes('EMAIL')) {
const users = await ctx.prisma.user.findMany({
where: { id: { in: recipientUserIds } },
select: {
id: true, name: true, email: true, passwordHash: true, inviteToken: true,
teamMemberships: {
select: { project: { select: { title: true } } },
take: 1,
},
},
})
// Fetch round & program context for template variable substitution
let roundName = ''
let programName = ''
let deadline = ''
if (effectiveRoundIds.length > 0) {
const rounds = await ctx.prisma.round.findMany({
where: { id: { in: effectiveRoundIds } },
select: {
name: true,
windowCloseAt: true,
competition: {
select: {
program: { select: { name: true } },
},
},
},
})
if (rounds.length > 0) {
roundName = rounds.map((r) => r.name).join(', ')
programName = rounds[0].competition?.program?.name ?? ''
// Use the earliest upcoming deadline across selected rounds
const deadlines = rounds
.map((r) => r.windowCloseAt)
.filter((d): d is Date => d !== null)
.sort((a, b) => a.getTime() - b.getTime())
if (deadlines.length > 0) {
deadline = deadlines[0].toLocaleDateString('en-GB', {
day: 'numeric', month: 'long', year: 'numeric',
})
}
}
}
/** Substitute template variables in a string for a specific user */
function substituteVariables(
text: string,
user: { name: string | null; teamMemberships: { project: { title: string } }[] }
): string {
return text
.replace(/\{\{userName\}\}/g, user.name || '')
.replace(/\{\{projectName\}\}/g, user.teamMemberships[0]?.project?.title || '')
.replace(/\{\{roundName\}\}/g, roundName)
.replace(/\{\{programName\}\}/g, programName)
.replace(/\{\{deadline\}\}/g, deadline)
}
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
function getLinkUrl(user: { id: string; passwordHash: string | null; inviteToken: string | null }): string | undefined {
switch (input.linkType) {
case 'NONE':
return undefined
case 'LOGIN':
return `${baseUrl}/login`
case 'INVITE':
// Users who haven't set a password yet — provide invite/accept link if token exists
if (user.inviteToken && !user.passwordHash) {
return `${baseUrl}/accept-invite?token=${user.inviteToken}`
}
// Already activated — just link to login
return `${baseUrl}/login`
case 'MESSAGES':
default:
return `${baseUrl}/messages`
}
}
const items: NotificationItem[] = users.map((user) => ({
email: user.email,
name: user.name || '',
type: 'MESSAGE',
userId: user.id,
context: {
name: user.name || undefined,
title: substituteVariables(input.subject, user),
message: substituteVariables(input.body, user),
linkUrl: getLinkUrl(user),
},
}))
// Fire-and-forget: batch send in background so the mutation returns quickly
sendBatchNotifications(items).then((result) => {
console.log(`[Message] Batch ${result.batchId}: ${result.sent} sent, ${result.failed} failed`)
}).catch((err) => {
console.error('[Message] Batch send error:', err)
})
}
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SEND_MESSAGE',
entityType: 'Message',
entityId: message.id,
detailsJson: {
recipientType: input.recipientType,
recipientCount: recipientUserIds.length,
channels: input.deliveryChannels,
scheduled: isScheduled,
},
})
} catch (err) {
console.error('[Message] Audit log failed:', err)
}
return {
...message,
recipientCount: recipientUserIds.length,
}
}),
/**
* Get the current user's inbox (messages sent to them).
*/
inbox: protectedProcedure
.input(
z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const page = input?.page ?? 1
const pageSize = input?.pageSize ?? 20
const skip = (page - 1) * pageSize
const [items, total] = await Promise.all([
ctx.prisma.messageRecipient.findMany({
where: { userId: ctx.user.id },
include: {
message: {
include: {
sender: {
select: { id: true, name: true, email: true },
},
},
},
},
orderBy: { message: { createdAt: 'desc' } },
skip,
take: pageSize,
}),
ctx.prisma.messageRecipient.count({
where: { userId: ctx.user.id },
}),
])
return {
items,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
}),
/**
* Mark a message as read.
*/
markRead: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const recipient = await ctx.prisma.messageRecipient.findUnique({
where: { id: input.id },
})
if (!recipient || recipient.userId !== ctx.user.id) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Message not found',
})
}
return ctx.prisma.messageRecipient.update({
where: { id: input.id },
data: {
isRead: true,
readAt: new Date(),
},
})
}),
/**
* Get messages sent by the current admin user.
*/
sent: adminProcedure
.input(
z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const page = input?.page ?? 1
const pageSize = input?.pageSize ?? 20
const skip = (page - 1) * pageSize
const [items, total] = await Promise.all([
ctx.prisma.message.findMany({
where: { senderId: ctx.user.id },
include: {
_count: { select: { recipients: true } },
},
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
ctx.prisma.message.count({
where: { senderId: ctx.user.id },
}),
])
return {
items,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
}),
/**
* Get unread message count for the current user.
*/
getUnreadCount: protectedProcedure.query(async ({ ctx }) => {
const count = await ctx.prisma.messageRecipient.count({
where: {
userId: ctx.user.id,
isRead: false,
},
})
return { count }
}),
// =========================================================================
// Template procedures
// =========================================================================
/**
* List all message templates.
*/
listTemplates: adminProcedure
.input(
z.object({
category: z.string().optional(),
activeOnly: z.boolean().default(true),
}).optional()
)
.query(async ({ ctx, input }) => {
return ctx.prisma.messageTemplate.findMany({
where: {
...(input?.category ? { category: input.category } : {}),
...(input?.activeOnly !== false ? { isActive: true } : {}),
},
orderBy: { createdAt: 'desc' },
})
}),
/**
* Create a message template.
*/
createTemplate: adminProcedure
.input(
z.object({
name: z.string().min(1).max(200),
category: z.string().min(1).max(100),
subject: z.string().min(1).max(500),
body: z.string().min(1),
variables: z.any().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const template = await ctx.prisma.messageTemplate.create({
data: {
name: input.name,
category: input.category,
subject: input.subject,
body: input.body,
variables: input.variables ?? undefined,
createdBy: ctx.user.id,
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_MESSAGE_TEMPLATE',
entityType: 'MessageTemplate',
entityId: template.id,
detailsJson: { name: input.name, category: input.category },
})
} catch (err) {
console.error('[Message] Audit log failed:', err)
}
return template
}),
/**
* Update a message template.
*/
updateTemplate: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(200).optional(),
category: z.string().min(1).max(100).optional(),
subject: z.string().min(1).max(500).optional(),
body: z.string().min(1).optional(),
variables: z.any().optional(),
isActive: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const template = await ctx.prisma.messageTemplate.update({
where: { id },
data: {
...(data.name !== undefined ? { name: data.name } : {}),
...(data.category !== undefined ? { category: data.category } : {}),
...(data.subject !== undefined ? { subject: data.subject } : {}),
...(data.body !== undefined ? { body: data.body } : {}),
...(data.variables !== undefined ? { variables: data.variables } : {}),
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_MESSAGE_TEMPLATE',
entityType: 'MessageTemplate',
entityId: id,
detailsJson: { updatedFields: Object.keys(data) },
})
} catch (err) {
console.error('[Message] Audit log failed:', err)
}
return template
}),
/**
* Soft-delete a message template (set isActive=false).
*/
deleteTemplate: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const template = await ctx.prisma.messageTemplate.update({
where: { id: input.id },
data: { isActive: false },
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE_MESSAGE_TEMPLATE',
entityType: 'MessageTemplate',
entityId: input.id,
})
} catch (err) {
console.error('[Message] Audit log failed:', err)
}
return template
}),
/**
* Preview styled email HTML for admin compose dialog.
*/
previewEmail: adminProcedure
.input(z.object({ subject: z.string(), body: z.string() }))
.query(({ input }) => {
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', 'PROJECT_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' && effectiveRoundIds.length > 0) {
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: { in: effectiveRoundIds } },
select: {
state: true,
projectId: true,
roundId: 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 resolveRecipientsMultiRound(
ctx.prisma,
input.recipientType,
input.recipientFilter,
effectiveRoundIds,
input.excludeStates
)
return {
totalProjects: 0,
totalApplicants: userIds.length,
stateBreakdown: {} as Record<string, number>,
}
}),
/**
* Get detailed recipient list with names and project info.
* Used for the expandable recipient breakdown in the compose sidebar.
*/
listRecipientDetails: adminProcedure
.input(z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'PROJECT_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 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 }
}
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: stateWhere,
select: {
state: true,
roundId: true,
round: { select: { id: true, name: true, competition: { select: { name: true } } } },
project: {
select: {
id: true,
title: true,
submittedBy: { select: { id: true, name: true, email: true } },
teamMembers: {
select: { user: { select: { id: true, name: true, email: true } } },
},
},
},
},
})
return {
type: 'projects' as const,
projects: projectStates.map((ps) => ({
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
.map((tm) => tm.user)
.filter((u) => u.id !== ps.project.submittedBy?.id),
],
})),
users: [],
}
}
// 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: { 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 } },
},
})
// Group by user
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],
rounds: new Set([a.roundId]),
roundNames: [roundLabel],
})
}
}
return {
type: 'jurors' as const,
projects: [],
users: Array.from(userMap.values()).map((entry) => ({
...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 resolveRecipientsMultiRound(
ctx.prisma,
input.recipientType,
input.recipientFilter,
effectiveRoundIds,
input.excludeStates
)
if (userIds.length === 0) return { type: 'users' as const, projects: [], users: [] }
const users = await ctx.prisma.user.findMany({
where: { id: { in: userIds } },
select: {
id: true,
name: true,
email: true,
role: true,
teamMemberships: {
select: { project: { select: { id: true, title: true } } },
take: 1,
},
},
})
return {
type: 'users' as const,
projects: [],
users: users.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role,
projectName: u.teamMemberships[0]?.project?.title ?? null,
})),
}
}),
/**
* Send a test email to the currently logged-in admin.
*/
sendTest: adminProcedure
.input(z.object({ subject: z.string(), body: z.string() }))
.mutation(async ({ ctx, input }) => {
const userName = ctx.user.name || ''
/** Substitute template variables with admin name + placeholder values for test emails */
function substituteTestVariables(text: string): string {
return text
.replace(/\{\{userName\}\}/g, userName)
.replace(/\{\{projectName\}\}/g, '[Project Name]')
.replace(/\{\{roundName\}\}/g, '[Round Name]')
.replace(/\{\{programName\}\}/g, '[Program Name]')
.replace(/\{\{deadline\}\}/g, '[Deadline]')
}
await sendStyledNotificationEmail(
ctx.user.email,
userName,
'MESSAGE',
{
title: substituteTestVariables(input.subject),
message: substituteTestVariables(input.body),
linkUrl: '/admin/messages',
}
)
return { sent: true, to: ctx.user.email }
}),
})
// =============================================================================
// 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,
recipientFilter: unknown,
roundId?: string,
excludeStates?: string[]
): Promise<string[]> {
const filter = recipientFilter as Record<string, unknown> | undefined
switch (recipientType) {
case 'USER': {
const userId = filter?.userId as string
if (!userId) return []
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
})
return user ? [user.id] : []
}
case 'ROLE': {
const role = filter?.role as string
if (!role) return []
const users = await prisma.user.findMany({
where: { role: role as any, status: 'ACTIVE' },
select: { id: true },
})
return users.map((u) => u.id)
}
case 'ROUND_JURY': {
const targetRoundId = roundId || (filter?.roundId as string)
if (!targetRoundId) return []
const assignments = await prisma.assignment.findMany({
where: { roundId: targetRoundId },
select: { userId: true },
distinct: ['userId'],
})
return assignments.map((a) => a.userId)
}
case 'ROUND_APPLICANTS': {
const targetRoundId = roundId || (filter?.roundId as string)
if (!targetRoundId) return []
// 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: stateWhere,
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
if (projectIds.length === 0) return []
// Get team members + submittedByUserId
const [teamMembers, projects] = await Promise.all([
prisma.teamMember.findMany({
where: { projectId: { in: projectIds } },
select: { userId: true },
}),
prisma.project.findMany({
where: { id: { in: projectIds } },
select: { submittedByUserId: true },
}),
])
const userIds = new Set<string>()
for (const tm of teamMembers) userIds.add(tm.userId)
for (const p of projects) {
if (p.submittedByUserId) userIds.add(p.submittedByUserId)
}
return [...userIds]
}
case 'PROGRAM_TEAM': {
const programId = filter?.programId as string
if (!programId) return []
const projects = await prisma.project.findMany({
where: { programId },
select: { submittedByUserId: true },
})
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
return [...ids]
}
case 'PROJECT_TEAM': {
const projectId = filter?.projectId as string
if (!projectId) return []
const [teamMembers, project] = await Promise.all([
prisma.teamMember.findMany({
where: { projectId },
select: { userId: true },
}),
prisma.project.findUnique({
where: { id: projectId },
select: { submittedByUserId: true },
}),
])
const ids = new Set<string>()
for (const tm of teamMembers) ids.add(tm.userId)
if (project?.submittedByUserId) ids.add(project.submittedByUserId)
return [...ids]
}
case 'ALL': {
const users = await prisma.user.findMany({
where: { status: 'ACTIVE' },
select: { id: true },
})
return users.map((u) => u.id)
}
default:
return []
}
}