Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented:
- F1: Email digest notifications with cron endpoint and per-user frequency
- F2: Jury availability windows and workload preferences in smart assignment
- F3: Round templates with save-from-round and CRUD management
- F4: Side-by-side project comparison view for jury members
- F5: Real-time voting dashboard with Server-Sent Events (SSE)
- F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations
- F7: File versioning, inline preview, bulk download with presigned URLs
- F8: Mentor dashboard: milestones, private notes, activity tracking
- F9: Communication hub with broadcasts, templates, and recipient targeting
- F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export
- F11: Applicant draft saving with magic link resume and cron cleanup
- F12: Webhook integration layer with HMAC signing, retry, and delivery logs
- F13: Peer review discussions with anonymized scores and threaded comments
- F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention
- F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher
Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program
New routers: roundTemplate, message, webhook (registered in _app.ts)
New services: email-digest, webhook-dispatcher
New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup
New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download
All features are admin-configurable via SystemSettings or per-model settingsJson fields.
Docker build verified successfully.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
|
|
|
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,
|
Fix build errors: add missing Prisma models/fields and resolve TypeScript type errors
Schema: Add 11 new models (RoundTemplate, MentorNote, MentorMilestone,
MentorMilestoneCompletion, EvaluationDiscussion, DiscussionComment,
Message, MessageRecipient, MessageTemplate, Webhook, WebhookDelivery,
DigestLog) and missing fields on existing models (Project.isDraft,
ProjectFile.version, LiveVotingSession.allowAudienceVotes, User.digestFrequency,
AuditLog.sessionId, MentorAssignment.completionStatus, etc).
Add AUDIT_CONFIG/LOCALIZATION/DIGEST/ANALYTICS enum values.
Code: Fix implicit any types, route type casts, enum casts, null safety,
composite key handling, and relation field names across 11 source files.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:04:02 +01:00
|
|
|
createdById: ctx.user.id,
|
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented:
- F1: Email digest notifications with cron endpoint and per-user frequency
- F2: Jury availability windows and workload preferences in smart assignment
- F3: Round templates with save-from-round and CRUD management
- F4: Side-by-side project comparison view for jury members
- F5: Real-time voting dashboard with Server-Sent Events (SSE)
- F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations
- F7: File versioning, inline preview, bulk download with presigned URLs
- F8: Mentor dashboard: milestones, private notes, activity tracking
- F9: Communication hub with broadcasts, templates, and recipient targeting
- F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export
- F11: Applicant draft saving with magic link resume and cron cleanup
- F12: Webhook integration layer with HMAC signing, retry, and delivery logs
- F13: Peer review discussions with anonymized scores and threaded comments
- F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention
- F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher
Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program
New routers: roundTemplate, message, webhook (registered in _app.ts)
New services: email-digest, webhook-dispatcher
New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup
New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download
All features are admin-configurable via SystemSettings or per-model settingsJson fields.
Docker build verified successfully.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 []
|
|
|
|
|
// Get all applicants with projects in rounds of this program
|
|
|
|
|
const projects = await prisma.project.findMany({
|
|
|
|
|
where: { round: { 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 []
|
|
|
|
|
}
|
|
|
|
|
}
|