feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
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:
@@ -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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user