import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' import { sendStyledNotificationEmail } from '@/lib/email' 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', 'PROGRAM_TEAM', 'ALL']), recipientFilter: z.any().optional(), roundId: 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(), }) ) .mutation(async ({ ctx, input }) => { // Resolve recipients based on type const recipientUserIds = await resolveRecipients( ctx.prisma, input.recipientType, input.recipientFilter, input.roundId ) 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: input.roundId, 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 if (!isScheduled && input.deliveryChannels.includes('EMAIL')) { const users = await ctx.prisma.user.findMany({ where: { id: { in: recipientUserIds } }, select: { id: true, name: true, email: true }, }) const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com' for (const user of users) { try { await sendStyledNotificationEmail( user.email, user.name || '', 'MESSAGE', { name: user.name || undefined, title: input.subject, message: input.body, linkUrl: `${baseUrl}/messages`, } ) } catch (error) { console.error(`[Message] Failed to send email to ${user.email}:`, error) } } } 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 {} 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 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 {} 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 {} 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 {} return template }), }) // ============================================================================= // Helper: Resolve recipient user IDs based on recipientType // ============================================================================= type PrismaClient = Parameters[0]>[0]['ctx']['prisma'] async function resolveRecipients( prisma: PrismaClient, recipientType: string, recipientFilter: unknown, roundId?: 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 'PROGRAM_TEAM': { const programId = filter?.programId as string if (!programId) return [] // Get all applicants with projects in rounds of this program 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 [] } }