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

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