Add styled notification emails and round-attached notifications

- Add 15+ styled email templates matching existing invite email design
- Wire up notification triggers in all routers (assignment, round, project, mentor, application, onboarding)
- Add test email button for each notification type in admin settings
- Add round-attached notifications: admins can configure which notification to send when projects enter a round
- Fall back to status-based notifications when round has no configured notification

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 00:10:51 +01:00
parent 3be6a743ed
commit b0189cad92
13 changed files with 1892 additions and 28 deletions

View File

@@ -2,6 +2,11 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure } from '../trpc'
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
import {
createNotification,
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
// Zod schemas for the application form
const teamMemberSchema = z.object({
@@ -308,6 +313,35 @@ export const applicationRouter = router({
},
})
// Notify applicant of successful submission
await createNotification({
userId: user.id,
type: NotificationTypes.APPLICATION_SUBMITTED,
title: 'Application Received',
message: `Your application for "${data.projectName}" has been successfully submitted to ${round.program.name}.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Application',
metadata: {
projectName: data.projectName,
programName: round.program.name,
},
})
// Notify admins of new application
await notifyAdmins({
type: NotificationTypes.NEW_APPLICATION,
title: 'New Application',
message: `New application received: "${data.projectName}" from ${data.contactName}.`,
linkUrl: `/admin/projects/${project.id}`,
linkLabel: 'Review Application',
metadata: {
projectName: data.projectName,
applicantName: data.contactName,
applicantEmail: data.contactEmail,
programName: round.program.name,
},
})
return {
success: true,
projectId: project.id,

View File

@@ -7,6 +7,11 @@ import {
generateFallbackAssignments,
} from '../services/ai-assignment'
import { isOpenAIConfigured } from '@/lib/openai'
import {
createNotification,
createBulkNotifications,
NotificationTypes,
} from '../services/in-app-notification'
export const assignmentRouter = router({
/**
@@ -193,6 +198,44 @@ export const assignmentRouter = router({
},
})
// Send notification to the assigned jury member
const [project, round] = await Promise.all([
ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { title: true },
}),
ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, votingEndAt: true },
}),
])
if (project && round) {
const deadline = round.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
await createNotification({
userId: input.userId,
type: NotificationTypes.ASSIGNED_TO_PROJECT,
title: 'New Project Assignment',
message: `You have been assigned to evaluate "${project.title}" for ${round.name}.`,
linkUrl: `/jury/assignments`,
linkLabel: 'View Assignment',
metadata: {
projectName: project.title,
roundName: round.name,
deadline,
assignmentId: assignment.id,
},
})
}
return assignment
}),
@@ -233,6 +276,51 @@ export const assignmentRouter = router({
},
})
// Send notifications to assigned jury members (grouped by user)
if (result.count > 0 && input.assignments.length > 0) {
// Group assignments by user to get counts
const userAssignmentCounts = input.assignments.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
return acc
},
{} as Record<string, number>
)
// Get round info for deadline
const roundId = input.assignments[0].roundId
const round = await ctx.prisma.round.findUnique({
where: { id: roundId },
select: { name: true, votingEndAt: true },
})
const deadline = round?.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
// Send batch notification to each user
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({
userId,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
linkUrl: `/jury/assignments`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
roundName: round?.name,
deadline,
},
})
}
}
return { created: result.count }
}),
@@ -602,6 +690,47 @@ export const assignmentRouter = router({
},
})
// Send notifications to assigned jury members
if (created.count > 0) {
const userAssignmentCounts = input.assignments.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
return acc
},
{} as Record<string, number>
)
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, votingEndAt: true },
})
const deadline = round?.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({
userId,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
linkUrl: `/jury/assignments`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
roundName: round?.name,
deadline,
},
})
}
}
return { created: created.count }
}),
@@ -649,6 +778,47 @@ export const assignmentRouter = router({
},
})
// Send notifications to assigned jury members
if (created.count > 0) {
const userAssignmentCounts = input.assignments.reduce(
(acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1
return acc
},
{} as Record<string, number>
)
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, votingEndAt: true },
})
const deadline = round?.votingEndAt
? new Date(round.votingEndAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({
userId,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
linkUrl: `/jury/assignments`,
linkLabel: 'View Assignments',
metadata: {
projectCount,
roundName: round?.name,
deadline,
},
})
}
}
return { created: created.count }
}),
})

View File

@@ -6,6 +6,11 @@ import {
getAIMentorSuggestions,
getRoundRobinMentor,
} from '../services/mentor-matching'
import {
createNotification,
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
export const mentorRouter = router({
/**
@@ -160,6 +165,42 @@ export const mentorRouter = router({
},
})
// Get team lead info for mentor notification
const teamLead = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, role: 'LEAD' },
include: { user: { select: { name: true, email: true } } },
})
// Notify mentor of new mentee
await createNotification({
userId: input.mentorId,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${assignment.project.title}".`,
linkUrl: `/mentor/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
})
// Notify project team of mentor assignment
await notifyProjectTeam(input.projectId, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
})
return assignment
}),
@@ -269,6 +310,42 @@ export const mentorRouter = router({
},
})
// Get team lead info for mentor notification
const teamLead = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, role: 'LEAD' },
include: { user: { select: { name: true, email: true } } },
})
// Notify mentor of new mentee
await createNotification({
userId: mentorId,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${assignment.project.title}".`,
linkUrl: `/mentor/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
})
// Notify project team of mentor assignment
await notifyProjectTeam(input.projectId, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
})
return assignment
}),
@@ -378,7 +455,7 @@ export const mentorRouter = router({
}
if (mentorId) {
await ctx.prisma.mentorAssignment.create({
const assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: project.id,
mentorId,
@@ -388,7 +465,48 @@ export const mentorRouter = router({
expertiseMatchScore,
aiReasoning,
},
include: {
mentor: { select: { name: true } },
project: { select: { title: true } },
},
})
// Get team lead info
const teamLead = await ctx.prisma.teamMember.findFirst({
where: { projectId: project.id, role: 'LEAD' },
include: { user: { select: { name: true, email: true } } },
})
// Notify mentor
await createNotification({
userId: mentorId,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${assignment.project.title}".`,
linkUrl: `/mentor/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
})
// Notify project team
await notifyProjectTeam(project.id, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
})
assigned++
} else {
failed++

View File

@@ -15,6 +15,7 @@ import {
NotificationIcons,
NotificationPriorities,
} from '../services/in-app-notification'
import { sendStyledNotificationEmail, NOTIFICATION_EMAIL_TEMPLATES } from '@/lib/email'
export const notificationRouter = router({
/**
@@ -218,4 +219,146 @@ export const notificationRouter = router({
byPriority: byPriority.map((p) => ({ priority: p.priority, count: p._count })),
}
}),
/**
* Send a test notification email to the current admin
*/
sendTestEmail: adminProcedure
.input(z.object({ notificationType: z.string() }))
.mutation(async ({ ctx, input }) => {
const { notificationType } = input
// Check if this notification type has a styled template
const hasStyledTemplate = notificationType in NOTIFICATION_EMAIL_TEMPLATES
// Get setting for label
const setting = await ctx.prisma.notificationEmailSetting.findUnique({
where: { notificationType },
})
// Sample data for test emails based on category
const sampleData: Record<string, Record<string, unknown>> = {
// Team notifications
ADVANCED_SEMIFINAL: {
projectName: 'Ocean Cleanup Initiative',
programName: 'Monaco Ocean Protection Challenge 2026',
nextSteps: 'Prepare your presentation for the semi-final round.',
},
ADVANCED_FINAL: {
projectName: 'Ocean Cleanup Initiative',
programName: 'Monaco Ocean Protection Challenge 2026',
nextSteps: 'Get ready for the final presentation in Monaco.',
},
MENTOR_ASSIGNED: {
projectName: 'Ocean Cleanup Initiative',
mentorName: 'Dr. Marine Expert',
mentorBio: 'Expert in marine conservation with 20 years of experience.',
},
NOT_SELECTED: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
},
WINNER_ANNOUNCEMENT: {
projectName: 'Ocean Cleanup Initiative',
awardName: 'Grand Prize',
prizeDetails: '€50,000 and mentorship program',
},
// Jury notifications
ASSIGNED_TO_PROJECT: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
deadline: 'Friday, March 15, 2026',
},
BATCH_ASSIGNED: {
projectCount: 5,
roundName: 'Semi-Final Round',
deadline: 'Friday, March 15, 2026',
},
ROUND_NOW_OPEN: {
roundName: 'Semi-Final Round',
projectCount: 12,
deadline: 'Friday, March 15, 2026',
},
REMINDER_24H: {
pendingCount: 3,
roundName: 'Semi-Final Round',
deadline: 'Tomorrow at 5:00 PM',
},
REMINDER_1H: {
pendingCount: 2,
roundName: 'Semi-Final Round',
deadline: 'Today at 5:00 PM',
},
AWARD_VOTING_OPEN: {
awardName: 'Innovation Award',
finalistCount: 6,
deadline: 'Friday, March 15, 2026',
},
// Mentor notifications
MENTEE_ASSIGNED: {
projectName: 'Ocean Cleanup Initiative',
teamLeadName: 'John Smith',
teamLeadEmail: 'john@example.com',
},
MENTEE_ADVANCED: {
projectName: 'Ocean Cleanup Initiative',
roundName: 'Semi-Final Round',
nextRoundName: 'Final Round',
},
MENTEE_WON: {
projectName: 'Ocean Cleanup Initiative',
awardName: 'Innovation Award',
},
// Admin notifications
NEW_APPLICATION: {
projectName: 'New Ocean Project',
applicantName: 'Jane Doe',
applicantEmail: 'jane@example.com',
programName: 'Monaco Ocean Protection Challenge 2026',
},
FILTERING_COMPLETE: {
roundName: 'Initial Review',
passedCount: 45,
flaggedCount: 12,
filteredCount: 8,
},
FILTERING_FAILED: {
roundName: 'Initial Review',
error: 'Connection timeout',
},
}
const metadata = sampleData[notificationType] || {}
const label = setting?.label || notificationType
try {
await sendStyledNotificationEmail(
ctx.user.email,
ctx.user.name || 'Admin',
notificationType,
{
title: `[TEST] ${label}`,
message: `This is a test email for the "${label}" notification type.`,
linkUrl: `${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/admin/settings`,
linkLabel: 'Back to Settings',
metadata,
}
)
return {
success: true,
message: `Test email sent to ${ctx.user.email}`,
hasStyledTemplate,
}
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to send test email',
hasStyledTemplate,
}
}
}),
})

View File

@@ -4,6 +4,11 @@ import { Prisma } from '@prisma/client'
import { router, publicProcedure } from '../trpc'
import { sendApplicationConfirmationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { nanoid } from 'nanoid'
import {
createNotification,
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
// Team member input for submission
const teamMemberInputSchema = z.object({
@@ -389,6 +394,36 @@ export const onboardingRouter = router({
},
})
// In-app notification for applicant
const programName = form.program?.name || form.round?.name || 'the program'
await createNotification({
userId: contactUser.id,
type: NotificationTypes.APPLICATION_SUBMITTED,
title: 'Application Received',
message: `Your application for "${input.projectName}" has been successfully submitted.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Application',
metadata: {
projectName: input.projectName,
programName,
},
})
// Notify admins of new application
await notifyAdmins({
type: NotificationTypes.NEW_APPLICATION,
title: 'New Application',
message: `New application received: "${input.projectName}" from ${input.contactName}.`,
linkUrl: `/admin/projects/${project.id}`,
linkLabel: 'Review Application',
metadata: {
projectName: input.projectName,
applicantName: input.contactName,
applicantEmail: input.contactEmail,
programName,
},
})
return {
success: true,
projectId: project.id,

View File

@@ -3,6 +3,10 @@ import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
import {
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
export const projectRouter = router({
/**
@@ -397,6 +401,90 @@ export const projectRouter = router({
where: { projectId: id, roundId },
data: { status },
})
// Get round details including configured notification type
const round = await ctx.prisma.round.findUnique({
where: { id: roundId },
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
})
// Helper to get notification title based on type
const getNotificationTitle = (type: string): string => {
const titles: Record<string, string> = {
ADVANCED_SEMIFINAL: "Congratulations! You're a Semi-Finalist",
ADVANCED_FINAL: "Amazing News! You're a Finalist",
NOT_SELECTED: 'Application Status Update',
WINNER_ANNOUNCEMENT: 'Congratulations! You Won!',
}
return titles[type] || 'Project Update'
}
// Helper to get notification message based on type
const getNotificationMessage = (type: string, projectName: string): string => {
const messages: Record<string, (name: string) => string> = {
ADVANCED_SEMIFINAL: (name) => `Your project "${name}" has advanced to the semi-finals!`,
ADVANCED_FINAL: (name) => `Your project "${name}" has been selected as a finalist!`,
NOT_SELECTED: (name) => `We regret to inform you that "${name}" was not selected for the next round.`,
WINNER_ANNOUNCEMENT: (name) => `Your project "${name}" has been selected as a winner!`,
}
return messages[type]?.(projectName) || `Update regarding your project "${projectName}".`
}
// Use round's configured notification type, or fall back to status-based defaults
if (round?.entryNotificationType) {
await notifyProjectTeam(id, {
type: round.entryNotificationType,
title: getNotificationTitle(round.entryNotificationType),
message: getNotificationMessage(round.entryNotificationType, project.title),
linkUrl: `/team/projects/${id}`,
linkLabel: 'View Project',
priority: round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round.name,
programName: round.program?.name,
},
})
} else {
// Fall back to hardcoded status-based notifications
const notificationConfig: Record<
string,
{ type: string; title: string; message: string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
title: "Congratulations! You're a Semi-Finalist",
message: `Your project "${project.title}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
title: "Amazing News! You're a Finalist",
message: `Your project "${project.title}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
title: 'Application Status Update',
message: `We regret to inform you that "${project.title}" was not selected for the next round.`,
},
}
const config = notificationConfig[status]
if (config) {
await notifyProjectTeam(id, {
type: config.type,
title: config.title,
message: config.message,
linkUrl: `/team/projects/${id}`,
linkLabel: 'View Project',
priority: status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round?.name,
programName: round?.program?.name,
},
})
}
}
}
// Audit log
@@ -590,6 +678,106 @@ export const projectRouter = router({
},
})
// Get round details including configured notification type
const [projects, round] = await Promise.all([
input.ids.length > 0
? ctx.prisma.project.findMany({
where: { id: { in: input.ids } },
select: { id: true, title: true },
})
: Promise.resolve([]),
ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
}),
])
// Helper to get notification title based on type
const getNotificationTitle = (type: string): string => {
const titles: Record<string, string> = {
ADVANCED_SEMIFINAL: "Congratulations! You're a Semi-Finalist",
ADVANCED_FINAL: "Amazing News! You're a Finalist",
NOT_SELECTED: 'Application Status Update',
WINNER_ANNOUNCEMENT: 'Congratulations! You Won!',
}
return titles[type] || 'Project Update'
}
// Helper to get notification message based on type
const getNotificationMessage = (type: string, projectName: string): string => {
const messages: Record<string, (name: string) => string> = {
ADVANCED_SEMIFINAL: (name) => `Your project "${name}" has advanced to the semi-finals!`,
ADVANCED_FINAL: (name) => `Your project "${name}" has been selected as a finalist!`,
NOT_SELECTED: (name) => `We regret to inform you that "${name}" was not selected for the next round.`,
WINNER_ANNOUNCEMENT: (name) => `Your project "${name}" has been selected as a winner!`,
}
return messages[type]?.(projectName) || `Update regarding your project "${projectName}".`
}
// Notify project teams based on round's configured notification or status-based fallback
if (projects.length > 0) {
if (round?.entryNotificationType) {
// Use round's configured notification type
for (const project of projects) {
await notifyProjectTeam(project.id, {
type: round.entryNotificationType,
title: getNotificationTitle(round.entryNotificationType),
message: getNotificationMessage(round.entryNotificationType, project.title),
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round.name,
programName: round.program?.name,
},
})
}
} else {
// Fall back to hardcoded status-based notifications
const notificationConfig: Record<
string,
{ type: string; titleFn: (name: string) => string; messageFn: (name: string) => string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
titleFn: () => "Congratulations! You're a Semi-Finalist",
messageFn: (name) => `Your project "${name}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
titleFn: () => "Amazing News! You're a Finalist",
messageFn: (name) => `Your project "${name}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
titleFn: () => 'Application Status Update',
messageFn: (name) =>
`We regret to inform you that "${name}" was not selected for the next round.`,
},
}
const config = notificationConfig[input.status]
if (config) {
for (const project of projects) {
await notifyProjectTeam(project.id, {
type: config.type,
title: config.titleFn(project.title),
message: config.messageFn(project.title),
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: input.status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
roundName: round?.name,
programName: round?.program?.name,
},
})
}
}
}
}
return { updated: updated.count }
}),

View File

@@ -2,6 +2,10 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import {
notifyRoundJury,
NotificationTypes,
} from '../services/in-app-notification'
export const roundRouter = router({
/**
@@ -70,6 +74,7 @@ export const roundRouter = router({
settingsJson: z.record(z.unknown()).optional(),
votingStartAt: z.date().optional(),
votingEndAt: z.date().optional(),
entryNotificationType: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -158,6 +163,7 @@ export const roundRouter = router({
votingStartAt: z.date().optional().nullable(),
votingEndAt: z.date().optional().nullable(),
settingsJson: z.record(z.unknown()).optional(),
entryNotificationType: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -254,6 +260,50 @@ export const roundRouter = router({
},
})
// Notify jury members when round is activated
if (input.status === 'ACTIVE' && previousRound.status !== 'ACTIVE') {
// Get round details and assignment counts per user
const roundDetails = await ctx.prisma.round.findUnique({
where: { id: input.id },
include: {
_count: { select: { assignments: true } },
},
})
// Get count of distinct jury members assigned
const juryCount = await ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { roundId: input.id },
_count: true,
})
if (roundDetails && juryCount.length > 0) {
const deadline = roundDetails.votingEndAt
? new Date(roundDetails.votingEndAt).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: undefined
// Notify all jury members with assignments in this round
await notifyRoundJury(input.id, {
type: NotificationTypes.ROUND_NOW_OPEN,
title: `${roundDetails.name} is Now Open`,
message: `The evaluation round is now open. Please review your assigned projects and submit your evaluations before the deadline.`,
linkUrl: `/jury/assignments`,
linkLabel: 'Start Evaluating',
priority: 'high',
metadata: {
roundName: roundDetails.name,
projectCount: roundDetails._count.assignments,
deadline,
},
})
}
}
return round
}),