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:
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user