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

@@ -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 }
}),