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

@@ -6,7 +6,14 @@ import { logAudit } from '@/server/utils/audit'
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
import { generateShortlist } from '../services/ai-shortlist'
import { createBulkNotifications } from '../services/in-app-notification'
import { sendAnnouncementEmail } from '@/lib/email'
import {
getAdvancementNotificationTemplate,
getRejectionNotificationTemplate,
sendStyledNotificationEmail,
sendInvitationEmail,
getBaseUrl,
} from '@/lib/email'
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
import {
openWindow,
closeWindow,
@@ -417,95 +424,6 @@ export const roundRouter = router({
userAgent: ctx.userAgent,
})
// Fix 5: notifyOnEntry — notify team members when projects enter target round
try {
const targetConfig = (targetRound.configJson as Record<string, unknown>) || {}
if (targetConfig.notifyOnEntry) {
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { projectId: { in: idsToAdvance! } },
select: { userId: true },
})
const userIds = [...new Set(teamMembers.map((tm) => tm.userId))]
if (userIds.length > 0) {
void createBulkNotifications({
userIds,
type: 'round_entry',
title: `Projects entered: ${targetRound.name}`,
message: `Your project has been advanced to the round "${targetRound.name}".`,
linkUrl: '/dashboard',
linkLabel: 'View Dashboard',
icon: 'ArrowRight',
})
}
}
} catch (notifyErr) {
console.error('[advanceProjects] notifyOnEntry notification failed (non-fatal):', notifyErr)
}
// Fix 6: notifyOnAdvance — notify applicants from source round that projects advanced
try {
const sourceConfig = (currentRound.configJson as Record<string, unknown>) || {}
if (sourceConfig.notifyOnAdvance) {
const projects = await ctx.prisma.project.findMany({
where: { id: { in: idsToAdvance! } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true } } },
},
},
})
// Collect unique user IDs for in-app notifications
const applicantUserIds = new Set<string>()
for (const project of projects) {
for (const tm of project.teamMembers) {
applicantUserIds.add(tm.user.id)
}
}
if (applicantUserIds.size > 0) {
void createBulkNotifications({
userIds: [...applicantUserIds],
type: 'project_advanced',
title: 'Your project has advanced!',
message: `Congratulations! Your project has advanced from "${currentRound.name}" to "${targetRound.name}".`,
linkUrl: '/dashboard',
linkLabel: 'View Dashboard',
icon: 'Trophy',
priority: 'high',
})
}
// Send emails to team members (fire-and-forget)
for (const project of projects) {
const recipients = new Map<string, string | null>()
for (const tm of project.teamMembers) {
if (tm.user.email) recipients.set(tm.user.email, tm.user.name)
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
}
for (const [email, name] of recipients) {
void sendAnnouncementEmail(
email,
name,
`Your project has advanced to: ${targetRound.name}`,
`Congratulations! Your project "${project.title}" has advanced from "${currentRound.name}" to "${targetRound.name}" in the Monaco Ocean Protection Challenge.`,
'View Your Dashboard',
`${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/dashboard`,
).catch((err) => {
console.error(`[advanceProjects] notifyOnAdvance email failed for ${email}:`, err)
})
}
}
}
} catch (notifyErr) {
console.error('[advanceProjects] notifyOnAdvance notification failed (non-fatal):', notifyErr)
}
return {
advancedCount: idsToAdvance!.length,
autoPassedCount,
@@ -883,4 +801,477 @@ export const roundRouter = router({
})
return round ?? null
}),
// =========================================================================
// Notification Procedures
// =========================================================================
previewAdvancementEmail: adminProcedure
.input(
z.object({
roundId: z.string(),
targetRoundId: z.string().optional(),
customMessage: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const { roundId, targetRoundId, customMessage } = input
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } },
})
// Determine target round name
const rounds = currentRound.competition.rounds
const currentIdx = rounds.findIndex((r) => r.id === roundId)
const targetRound = targetRoundId
? rounds.find((r) => r.id === targetRoundId)
: rounds[currentIdx + 1]
const toRoundName = targetRound?.name ?? 'Next Round'
// Count recipients: team members of PASSED or COMPLETED projects in this round
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PASSED', 'COMPLETED'] } },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
let recipientCount = 0
if (projectIds.length > 0) {
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { projectId: { in: projectIds } },
select: { user: { select: { email: true } } },
})
const emails = new Set(teamMembers.map((tm) => tm.user.email).filter(Boolean))
// Also count submittedByEmail for projects without team member emails
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { submittedByEmail: true, teamMembers: { select: { user: { select: { email: true } } } } },
})
for (const p of projects) {
const hasTeamEmail = p.teamMembers.some((tm) => tm.user.email)
if (!hasTeamEmail && p.submittedByEmail) {
emails.add(p.submittedByEmail)
}
}
recipientCount = emails.size
}
// Build preview HTML
const template = getAdvancementNotificationTemplate(
'Team Member',
'Your Project',
currentRound.name,
toRoundName,
customMessage || undefined
)
return { html: template.html, subject: template.subject, recipientCount }
}),
sendAdvancementNotifications: adminProcedure
.input(
z.object({
roundId: z.string(),
targetRoundId: z.string().optional(),
customMessage: z.string().optional(),
projectIds: z.array(z.string()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, targetRoundId, customMessage } = input
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } },
})
const rounds = currentRound.competition.rounds
const currentIdx = rounds.findIndex((r) => r.id === roundId)
const targetRound = targetRoundId
? rounds.find((r) => r.id === targetRoundId)
: rounds[currentIdx + 1]
const toRoundName = targetRound?.name ?? 'Next Round'
// Get target projects
let projectIds = input.projectIds
if (!projectIds) {
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PASSED', 'COMPLETED'] } },
select: { projectId: true },
})
projectIds = projectStates.map((ps) => ps.projectId)
}
if (projectIds.length === 0) {
return { sent: 0, failed: 0 }
}
// Fetch projects with team members
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true } } },
},
},
})
let sent = 0
let failed = 0
const allUserIds = new Set<string>()
for (const project of projects) {
const recipients = new Map<string, string | null>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
allUserIds.add(tm.user.id)
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
}
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++
}
}
}
// Create in-app notifications
if (allUserIds.size > 0) {
void createBulkNotifications({
userIds: [...allUserIds],
type: 'project_advanced',
title: 'Your project has advanced!',
message: `Your project has advanced from "${currentRound.name}" to "${toRoundName}".`,
linkUrl: '/applicant',
linkLabel: 'View Dashboard',
icon: 'Trophy',
priority: 'high',
})
}
// Audit
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'SEND_ADVANCEMENT_NOTIFICATIONS',
entityType: 'Round',
entityId: roundId,
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, failed }
}),
previewRejectionEmail: adminProcedure
.input(
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const { roundId, customMessage } = input
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: 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)
let recipientCount = 0
if (projectIds.length > 0) {
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { submittedByEmail: true, teamMembers: { select: { user: { select: { email: true } } } } },
})
const emails = new Set<string>()
for (const p of projects) {
const hasTeamEmail = p.teamMembers.some((tm) => tm.user.email)
if (hasTeamEmail) {
for (const tm of p.teamMembers) {
if (tm.user.email) emails.add(tm.user.email)
}
} else if (p.submittedByEmail) {
emails.add(p.submittedByEmail)
}
}
recipientCount = emails.size
}
const template = getRejectionNotificationTemplate(
'Team Member',
'Your Project',
round.name,
customMessage || undefined
)
return { html: template.html, subject: template.subject, recipientCount }
}),
sendRejectionNotifications: adminProcedure
.input(
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, customMessage } = input
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true },
})
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
if (projectIds.length === 0) {
return { sent: 0, failed: 0 }
}
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true } } },
},
},
})
let sent = 0
let failed = 0
const allUserIds = new Set<string>()
for (const project of projects) {
const recipients = new Map<string, string | null>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
allUserIds.add(tm.user.id)
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
}
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++
}
}
}
// In-app notifications
if (allUserIds.size > 0) {
void createBulkNotifications({
userIds: [...allUserIds],
type: 'NOT_SELECTED',
title: 'Update on your application',
message: `Your project was not selected to advance from "${round.name}".`,
linkUrl: '/applicant',
linkLabel: 'View Dashboard',
icon: 'Info',
})
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'SEND_REJECTION_NOTIFICATIONS',
entityType: 'Round',
entityId: roundId,
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, failed }
}),
getBulkInvitePreview: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const { roundId } = input
// Get all projects in this round
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
if (projectIds.length === 0) {
return { uninvitedCount: 0, totalTeamMembers: 0, alreadyInvitedCount: 0 }
}
// Get all team members for these projects
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { projectId: { in: projectIds } },
select: { user: { select: { id: true, status: true } } },
})
// Deduplicate by user ID
const userMap = new Map<string, string>()
for (const tm of teamMembers) {
userMap.set(tm.user.id, tm.user.status)
}
let uninvitedCount = 0
let alreadyInvitedCount = 0
for (const [, status] of userMap) {
if (status === 'ACTIVE' || status === 'INVITED') {
alreadyInvitedCount++
} else {
uninvitedCount++
}
}
return {
uninvitedCount,
totalTeamMembers: userMap.size,
alreadyInvitedCount,
}
}),
bulkInviteTeamMembers: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const { roundId } = input
// Get all projects in this round
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
if (projectIds.length === 0) {
return { invited: 0, skipped: 0, failed: 0 }
}
// Get all team members with user details
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { projectId: { in: projectIds } },
select: {
user: { select: { id: true, email: true, name: true, status: true, role: true } },
},
})
// Deduplicate by user ID
const users = new Map<string, { id: string; email: string; name: string | null; status: string; role: string }>()
for (const tm of teamMembers) {
if (tm.user.email && !users.has(tm.user.id)) {
users.set(tm.user.id, tm.user)
}
}
const baseUrl = getBaseUrl()
const expiryHours = await getInviteExpiryHours(ctx.prisma as unknown as import('@prisma/client').PrismaClient)
const expiryMs = expiryHours * 60 * 60 * 1000
let invited = 0
let skipped = 0
let failed = 0
for (const [, user] of users) {
if (user.status === 'ACTIVE' || user.status === 'INVITED') {
skipped++
continue
}
try {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
invited++
} catch (err) {
console.error(`[bulkInviteTeamMembers] Failed for ${user.email}:`, err)
failed++
}
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'BULK_INVITE_TEAM_MEMBERS',
entityType: 'Round',
entityId: roundId,
detailsJson: { invited, skipped, failed, totalUsers: users.size },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { invited, skipped, failed }
}),
})