All checks were successful
Build and Push Docker Image / build (push) Successful in 9m33s
- Rewrite ctaButton to use td-background pattern (works in all clients including Outlook, Gmail, Yahoo, Apple Mail) instead of VML/conditional comments that broke link clicking in Outlook desktop - Add plaintext fallback URL below every CTA button so users always have a working link even if the button fails - Add getBaseUrl() and ensureAbsoluteUrl() helpers in email.ts to guarantee all email links are absolute https:// URLs - Apply ensureAbsoluteUrl safety net in sendStyledNotificationEmail and sendNotificationEmail so relative paths can never reach email templates - Standardize all NEXTAUTH_URL fallbacks to https://portal.monaco-opc.com (was inconsistently http://localhost:3000 or https://monaco-opc.com) - Fix legacy notification.ts: wrong argument order in sendJuryInvitationEmail (URL was passed as name parameter) - Fix legacy notification.ts: missing NEXTAUTH_URL fallback for evaluation reminder URL construction - Change tooltip styling from red bg to white bg with black text Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
445 lines
13 KiB
TypeScript
445 lines
13 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 } 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://portal.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 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 {}
|
|
|
|
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<Parameters<typeof adminProcedure.mutation>[0]>[0]['ctx']['prisma']
|
|
|
|
async function resolveRecipients(
|
|
prisma: PrismaClient,
|
|
recipientType: string,
|
|
recipientFilter: unknown,
|
|
roundId?: 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 '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 []
|
|
}
|
|
}
|