feat: admin UX improvements — notify buttons, eval config, round finalization

Custom body support for advancement/rejection notification emails, evaluation
config toggle fix, user actions improvements, round finalization with reorder
support, project detail page enhancements, award pool duplicate prevention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 13:29:22 +01:00
parent f24bea3df2
commit 1103d42439
11 changed files with 606 additions and 265 deletions

View File

@@ -9,10 +9,11 @@ import { createBulkNotifications } from '../services/in-app-notification'
import {
getAdvancementNotificationTemplate,
getRejectionNotificationTemplate,
sendStyledNotificationEmail,
sendInvitationEmail,
getBaseUrl,
} from '@/lib/email'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
import {
openWindow,
@@ -812,10 +813,11 @@ export const roundRouter = router({
roundId: z.string(),
targetRoundId: z.string().optional(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
})
)
.query(async ({ ctx, input }) => {
const { roundId, targetRoundId, customMessage } = input
const { roundId, targetRoundId, customMessage, fullCustomBody } = input
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
@@ -865,7 +867,9 @@ export const roundRouter = router({
'Your Project',
currentRound.name,
toRoundName,
customMessage || undefined
customMessage || undefined,
undefined,
fullCustomBody,
)
return { html: template.html, subject: template.subject, recipientCount }
@@ -877,11 +881,12 @@ export const roundRouter = router({
roundId: z.string(),
targetRoundId: z.string().optional(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
projectIds: z.array(z.string()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, targetRoundId, customMessage } = input
const { roundId, targetRoundId, customMessage, fullCustomBody } = input
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
@@ -922,48 +927,47 @@ export const roundRouter = router({
},
})
let sent = 0
let failed = 0
const allUserIds = new Set<string>()
const items: NotificationItem[] = []
for (const project of projects) {
const recipients = new Map<string, string | null>()
const recipients = new Map<string, { name: string | null; userId: string }>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
allUserIds.add(tm.user.id)
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
recipients.set(project.submittedByEmail, { name: null, userId: '' })
}
for (const [email, name] of recipients) {
try {
await sendStyledNotificationEmail(
email,
name || '',
'ADVANCEMENT_NOTIFICATION',
{
title: 'Your project has advanced!',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
fromRoundName: currentRound.name,
toRoundName,
customMessage: customMessage || undefined,
},
}
)
sent++
} catch (err) {
console.error(`[sendAdvancementNotifications] Failed for ${email}:`, err)
failed++
}
for (const [email, { name, userId }] of recipients) {
items.push({
email,
name: name || '',
type: 'ADVANCEMENT_NOTIFICATION',
context: {
title: 'Your project has advanced!',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
fromRoundName: currentRound.name,
toRoundName,
customMessage: customMessage || undefined,
fullCustomBody,
},
},
projectId: project.id,
userId: userId || undefined,
roundId,
})
}
}
const result = await sendBatchNotifications(items)
// Create in-app notifications
if (allUserIds.size > 0) {
void createBulkNotifications({
@@ -985,12 +989,12 @@ export const roundRouter = router({
action: 'SEND_ADVANCEMENT_NOTIFICATIONS',
entityType: 'Round',
entityId: roundId,
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
detailsJson: { sent: result.sent, failed: result.failed, projectCount: projectIds.length, customMessage: !!customMessage },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, failed }
return { sent: result.sent, failed: result.failed }
}),
previewRejectionEmail: adminProcedure
@@ -998,22 +1002,36 @@ export const roundRouter = router({
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
})
)
.query(async ({ ctx, input }) => {
const { roundId, customMessage } = input
const { roundId, customMessage, fullCustomBody } = input
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true },
select: { name: true, roundType: true },
})
// Count recipients: team members of REJECTED projects
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
// For FILTERING rounds, also count projects filtered out via FilteringResult
let projectIds: string[]
if (round.roundType === 'FILTERING') {
const fromPRS = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const fromFR = await ctx.prisma.filteringResult.findMany({
where: { roundId, finalOutcome: 'FILTERED_OUT' },
select: { projectId: true },
})
projectIds = [...new Set([...fromPRS, ...fromFR].map((p) => p.projectId))]
} else {
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
projectIds = projectStates.map((ps) => ps.projectId)
}
let recipientCount = 0
if (projectIds.length > 0) {
@@ -1039,7 +1057,8 @@ export const roundRouter = router({
'Team Member',
'Your Project',
round.name,
customMessage || undefined
customMessage || undefined,
fullCustomBody,
)
return { html: template.html, subject: template.subject, recipientCount }
@@ -1050,21 +1069,36 @@ export const roundRouter = router({
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, customMessage } = input
const { roundId, customMessage, fullCustomBody } = input
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true },
select: { name: true, roundType: true },
})
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
// For FILTERING rounds, also include projects filtered out via FilteringResult
let projectIds: string[]
if (round.roundType === 'FILTERING') {
const fromPRS = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const fromFR = await ctx.prisma.filteringResult.findMany({
where: { roundId, finalOutcome: 'FILTERED_OUT' },
select: { projectId: true },
})
projectIds = [...new Set([...fromPRS, ...fromFR].map((p) => p.projectId))]
} else {
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
projectIds = projectStates.map((ps) => ps.projectId)
}
if (projectIds.length === 0) {
return { sent: 0, failed: 0 }
@@ -1082,47 +1116,46 @@ export const roundRouter = router({
},
})
let sent = 0
let failed = 0
const allUserIds = new Set<string>()
const items: NotificationItem[] = []
for (const project of projects) {
const recipients = new Map<string, string | null>()
const recipients = new Map<string, { name: string | null; userId: string }>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
allUserIds.add(tm.user.id)
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
recipients.set(project.submittedByEmail, { name: null, userId: '' })
}
for (const [email, name] of recipients) {
try {
await sendStyledNotificationEmail(
email,
name || '',
'REJECTION_NOTIFICATION',
{
title: 'Update on your application',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
roundName: round.name,
customMessage: customMessage || undefined,
},
}
)
sent++
} catch (err) {
console.error(`[sendRejectionNotifications] Failed for ${email}:`, err)
failed++
}
for (const [email, { name, userId }] of recipients) {
items.push({
email,
name: name || '',
type: 'REJECTION_NOTIFICATION',
context: {
title: 'Update on your application',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
roundName: round.name,
customMessage: customMessage || undefined,
fullCustomBody,
},
},
projectId: project.id,
userId: userId || undefined,
roundId,
})
}
}
const result = await sendBatchNotifications(items)
// In-app notifications
if (allUserIds.size > 0) {
void createBulkNotifications({
@@ -1142,12 +1175,12 @@ export const roundRouter = router({
action: 'SEND_REJECTION_NOTIFICATIONS',
entityType: 'Round',
entityId: roundId,
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
detailsJson: { sent: result.sent, failed: result.failed, projectCount: projectIds.length, customMessage: !!customMessage },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, failed }
return { sent: result.sent, failed: result.failed }
}),
getBulkInvitePreview: adminProcedure