feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s

- processRoundClose EVALUATION uses ranking scores + advanceMode config
  (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED
- Advancement emails generate invite tokens for passwordless users with
  "Create Your Account" CTA; rejection emails have no link
- Finalization UI shows account stats (invite vs dashboard link counts)
- Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson)
- New award pool notification system: getAwardSelectionNotificationTemplate email,
  notifyEligibleProjects mutation with invite token generation,
  "Notify Pool" button on award detail page with custom message dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:14:41 +01:00
parent 7735f3ecdf
commit cfee3bc8a9
48 changed files with 5294 additions and 676 deletions

View File

@@ -4,6 +4,8 @@ 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 } from '@/lib/email'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
import type { PrismaClient } from '@prisma/client'
/**
@@ -1182,6 +1184,181 @@ export const specialAwardRouter = router({
return round
}),
// ─── Pool Notifications ─────────────────────────────────────────────────
/**
* Get account stats for eligible projects (how many need invite vs have account)
*/
getNotificationStats: adminProcedure
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
const eligibilities = await ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, eligible: true },
select: {
project: {
select: {
submittedBy: { select: { id: true, passwordHash: true } },
teamMembers: {
select: { user: { select: { id: true, passwordHash: true } } },
},
},
},
},
})
const seen = new Set<string>()
let needsInvite = 0
let hasAccount = 0
for (const e of eligibilities) {
const submitter = e.project.submittedBy
if (submitter && !seen.has(submitter.id)) {
seen.add(submitter.id)
if (submitter.passwordHash) hasAccount++
else needsInvite++
}
for (const tm of e.project.teamMembers) {
if (!seen.has(tm.user.id)) {
seen.add(tm.user.id)
if (tm.user.passwordHash) hasAccount++
else needsInvite++
}
}
}
return { needsInvite, hasAccount, totalProjects: eligibilities.length }
}),
/**
* Notify eligible projects that they've been selected for an award.
* Generates invite tokens for passwordless users.
*/
notifyEligibleProjects: adminProcedure
.input(z.object({
awardId: z.string(),
customMessage: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
select: { id: true, name: true, description: true, status: true },
})
// Get eligible projects with submitter + team members
const eligibilities = await ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, eligible: true },
select: {
id: true,
projectId: true,
project: {
select: {
id: true,
title: true,
submittedBy: {
select: { id: true, email: true, name: true, passwordHash: true },
},
teamMembers: {
select: {
user: {
select: { id: true, email: true, name: true, passwordHash: true },
},
},
},
},
},
},
})
if (eligibilities.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No eligible projects to notify',
})
}
// Pre-generate invite tokens for passwordless users
const expiryMs = await getInviteExpiryMs(ctx.prisma)
const expiresAt = new Date(Date.now() + expiryMs)
const tokenMap = new Map<string, string>() // userId -> token
const allUsers: Array<{ id: string; passwordHash: string | null }> = []
for (const e of eligibilities) {
if (e.project.submittedBy) allUsers.push(e.project.submittedBy)
for (const tm of e.project.teamMembers) allUsers.push(tm.user)
}
const passwordlessUsers = allUsers.filter((u) => !u.passwordHash)
const uniquePasswordless = [...new Map(passwordlessUsers.map((u) => [u.id, u])).values()]
for (const user of uniquePasswordless) {
const token = generateInviteToken()
tokenMap.set(user.id, token)
await ctx.prisma.user.update({
where: { id: user.id },
data: { inviteToken: token, inviteTokenExpiresAt: expiresAt },
})
}
// Send emails
let emailsSent = 0
let emailsFailed = 0
for (const e of eligibilities) {
const recipients: Array<{ id: string; email: string; name: string | null; passwordHash: 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)) {
recipients.push(tm.user)
}
}
for (const recipient of recipients) {
const token = tokenMap.get(recipient.id)
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
try {
await sendStyledNotificationEmail(
recipient.email,
recipient.name || '',
'AWARD_SELECTION_NOTIFICATION',
{
title: `Selected 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++
}
}
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: input.awardId,
detailsJson: {
action: 'NOTIFY_ELIGIBLE_PROJECTS',
eligibleCount: eligibilities.length,
emailsSent,
emailsFailed,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { notified: eligibilities.length, emailsSent, emailsFailed }
}),
/**
* Delete an award round (only if DRAFT)
*/