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:
@@ -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 }
|
||||
}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user