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:
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendNotificationEmail } from '@/lib/email'
|
||||
import { sendStyledNotificationEmail } from '@/lib/email'
|
||||
|
||||
// Notification priority levels
|
||||
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'
|
||||
@@ -218,7 +218,7 @@ export async function createNotification(
|
||||
})
|
||||
|
||||
// Check if we should also send an email
|
||||
await maybeSendEmail(userId, type, title, message, linkUrl)
|
||||
await maybeSendEmail(userId, type, title, message, linkUrl, metadata)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,7 +267,7 @@ export async function createBulkNotifications(params: {
|
||||
|
||||
// Check email settings and send emails
|
||||
for (const userId of userIds) {
|
||||
await maybeSendEmail(userId, type, title, message, linkUrl)
|
||||
await maybeSendEmail(userId, type, title, message, linkUrl, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +373,8 @@ async function maybeSendEmail(
|
||||
type: string,
|
||||
title: string,
|
||||
message: string,
|
||||
linkUrl?: string
|
||||
linkUrl?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if email is enabled for this notification type
|
||||
@@ -396,16 +397,21 @@ async function maybeSendEmail(
|
||||
return
|
||||
}
|
||||
|
||||
// Send the email
|
||||
const subject = emailSetting.emailSubject || title
|
||||
const body = emailSetting.emailTemplate
|
||||
? emailSetting.emailTemplate
|
||||
.replace('{title}', title)
|
||||
.replace('{message}', message)
|
||||
.replace('{link}', linkUrl || '')
|
||||
: message
|
||||
|
||||
await sendNotificationEmail(user.email, user.name || 'User', subject, body, linkUrl)
|
||||
// Send styled email with full context
|
||||
// The styled template will use metadata for rich content
|
||||
// Subject can be overridden by admin settings
|
||||
await sendStyledNotificationEmail(
|
||||
user.email,
|
||||
user.name || 'User',
|
||||
type,
|
||||
{
|
||||
title,
|
||||
message,
|
||||
linkUrl,
|
||||
metadata,
|
||||
},
|
||||
emailSetting.emailSubject || undefined
|
||||
)
|
||||
} catch (error) {
|
||||
// Log but don't fail the notification creation
|
||||
console.error('[Notification] Failed to send email:', error)
|
||||
|
||||
Reference in New Issue
Block a user