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

@@ -4,8 +4,10 @@ import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { processEligibilityJob } from '../services/award-eligibility-job'
import { sendStyledNotificationEmail, getAwardSelectionNotificationTemplate } from '@/lib/email'
import { getAwardSelectionNotificationTemplate } from '@/lib/email'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
import type { PrismaClient } from '@prisma/client'
/**
@@ -1270,8 +1272,18 @@ export const specialAwardRouter = router({
})
// Get eligible projects that haven't been notified yet
// Exclude projects that have been rejected at any stage
const eligibilities = await ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, eligible: true, notifiedAt: null },
where: {
awardId: input.awardId,
eligible: true,
notifiedAt: null,
project: {
projectRoundStates: {
none: { state: 'REJECTED' },
},
},
},
select: {
id: true,
projectId: true,
@@ -1324,12 +1336,12 @@ export const specialAwardRouter = router({
})
}
// Send emails
let emailsSent = 0
let emailsFailed = 0
// Build notification items — track which eligibility each email belongs to
const items: NotificationItem[] = []
const eligibilityEmailMap = new Map<string, Set<string>>() // eligibilityId → Set<email>
for (const e of eligibilities) {
const recipients: Array<{ id: string; email: string; name: string | null; passwordHash: string | null }> = []
const recipients: Array<{ id: string; email: string; name: string | null }> = []
if (e.project.submittedBy) recipients.push(e.project.submittedBy)
for (const tm of e.project.teamMembers) {
if (!recipients.some((r) => r.id === tm.user.id)) {
@@ -1337,39 +1349,46 @@ export const specialAwardRouter = router({
}
}
const emails = new Set<string>()
for (const recipient of recipients) {
const token = tokenMap.get(recipient.id)
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
emails.add(recipient.email)
try {
await sendStyledNotificationEmail(
recipient.email,
recipient.name || '',
'AWARD_SELECTION_NOTIFICATION',
{
title: `Under consideration for ${award.name}`,
message: input.customMessage || '',
metadata: {
projectName: e.project.title,
awardName: award.name,
customMessage: input.customMessage,
accountUrl,
},
items.push({
email: recipient.email,
name: recipient.name || '',
type: 'AWARD_SELECTION_NOTIFICATION',
context: {
title: `Under consideration for ${award.name}`,
message: input.customMessage || '',
metadata: {
projectName: e.project.title,
awardName: award.name,
customMessage: input.customMessage,
accountUrl,
},
)
emailsSent++
} catch (err) {
console.error(`[award-notify] Failed to email ${recipient.email}:`, err)
emailsFailed++
}
},
projectId: e.projectId,
userId: recipient.id,
})
}
eligibilityEmailMap.set(e.id, emails)
}
// Stamp notifiedAt on all processed eligibilities to prevent re-notification
const notifiedIds = eligibilities.map((e) => e.id)
if (notifiedIds.length > 0) {
const result = await sendBatchNotifications(items)
// Determine which eligibilities had zero failures
const failedEmails = new Set(result.errors.map((e) => e.email))
const successfulEligibilityIds: string[] = []
for (const [eligId, emails] of eligibilityEmailMap) {
const hasFailure = [...emails].some((email) => failedEmails.has(email))
if (!hasFailure) successfulEligibilityIds.push(eligId)
}
if (successfulEligibilityIds.length > 0) {
await ctx.prisma.awardEligibility.updateMany({
where: { id: { in: notifiedIds } },
where: { id: { in: successfulEligibilityIds } },
data: { notifiedAt: new Date() },
})
}
@@ -1383,14 +1402,15 @@ export const specialAwardRouter = router({
detailsJson: {
action: 'NOTIFY_ELIGIBLE_PROJECTS',
eligibleCount: eligibilities.length,
emailsSent,
emailsFailed,
emailsSent: result.sent,
emailsFailed: result.failed,
failedRecipients: result.errors.length > 0 ? result.errors.map((e) => e.email) : undefined,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { notified: eligibilities.length, emailsSent, emailsFailed }
return { notified: successfulEligibilityIds.length, emailsSent: result.sent, emailsFailed: result.failed }
}),
/**