Files
MOPC-Portal/src/server/routers/message.ts

579 lines
18 KiB
TypeScript
Raw Normal View History

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(),
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(),
})
)
.mutation(async ({ ctx, input }) => {
// Resolve recipients based on type
const recipientUserIds = await resolveRecipients(
ctx.prisma,
input.recipientType,
input.recipientFilter,
input.roundId,
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: 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 (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 },
})
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
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: `${baseUrl}/messages`,
},
}))
// 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 {}
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(),
},
})
}),
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
/**
* 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
}),
/**
* 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(),
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.
*/
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
// =============================================================================
type PrismaClient = Parameters<Parameters<typeof adminProcedure.mutation>[0]>[0]['ctx']['prisma']
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 'ALL': {
const users = await prisma.user.findMany({
where: { status: 'ACTIVE' },
select: { id: true },
})
return users.map((u) => u.id)
}
default:
return []
}
}