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', '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 }, }) 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: input.subject, message: input.body, 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', '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 = {} 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() 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, } }), /** * 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', '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 = { 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; 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 }) => { await sendStyledNotificationEmail( ctx.user.email, ctx.user.name || '', 'MESSAGE', { title: input.subject, message: 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[0]>[0]['ctx']['prisma'] async function resolveRecipientsMultiRound( prisma: PrismaClient, recipientType: string, recipientFilter: unknown, roundIds: string[], excludeStates?: string[] ): Promise { // For round-based types, union across all selected rounds if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && roundIds.length > 0) { const allUserIds = new Set() 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 { const filter = recipientFilter as Record | 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 = { 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() 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 'ALL': { const users = await prisma.user.findMany({ where: { status: 'ACTIVE' }, select: { id: true }, }) return users.map((u) => u.id) } default: return [] } }