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

@@ -2,72 +2,6 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { sendAnnouncementEmail } from '@/lib/email'
import type { PrismaClient } from '@prisma/client'
/**
* Send round-entry notification emails to project team members.
* Fire-and-forget: errors are logged but never block the assignment.
*/
async function sendRoundEntryEmails(
prisma: PrismaClient,
projectIds: string[],
roundName: string,
) {
try {
// Fetch projects with team members' user emails + fallback submittedByEmail
const projects = await prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: {
user: { select: { email: true, name: true } },
},
},
},
})
const emailPromises: Promise<void>[] = []
for (const project of projects) {
// Collect unique emails for this project
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)
}
}
// Fallback: if no team members have emails, use submittedByEmail
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
}
for (const [email, name] of recipients) {
emailPromises.push(
sendAnnouncementEmail(
email,
name,
`Your project has entered: ${roundName}`,
`Your project "${project.title}" has been added to the round "${roundName}" in the Monaco Ocean Protection Challenge. You will receive further instructions as the round progresses.`,
'View Your Dashboard',
`${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/dashboard`,
).catch((err) => {
console.error(`[round-entry-email] Failed to send to ${email}:`, err)
}),
)
}
}
await Promise.allSettled(emailPromises)
} catch (err) {
console.error('[round-entry-email] Failed to send round entry emails:', err)
}
}
/**
* Project Pool Router
@@ -199,7 +133,7 @@ export const projectPoolRouter = router({
assignToRound: adminProcedure
.input(
z.object({
projectIds: z.array(z.string()).min(1).max(200),
projectIds: z.array(z.string()).min(1).max(1000),
roundId: z.string(),
})
)
@@ -228,10 +162,10 @@ export const projectPoolRouter = router({
})
}
// Verify round exists and get config
// Verify round exists
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true, configJson: true },
select: { id: true, name: true },
})
// Step 2: Perform bulk assignment in a transaction
@@ -284,12 +218,6 @@ export const projectPoolRouter = router({
userAgent: ctx.userAgent,
})
// Send round-entry notification emails if enabled (fire-and-forget)
const config = (round.configJson as Record<string, unknown>) || {}
if (config.notifyOnEntry) {
void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name)
}
return {
success: true,
assignedCount: result.count,
@@ -313,10 +241,10 @@ export const projectPoolRouter = router({
.mutation(async ({ ctx, input }) => {
const { programId, roundId, competitionCategory, unassignedOnly } = input
// Verify round exists and get config
const round = await ctx.prisma.round.findUniqueOrThrow({
// Verify round exists
await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true, configJson: true },
select: { id: true },
})
// Find projects to assign
@@ -388,12 +316,213 @@ export const projectPoolRouter = router({
userAgent: ctx.userAgent,
})
// Send round-entry notification emails if enabled (fire-and-forget)
const config = (round.configJson as Record<string, unknown>) || {}
if (config.notifyOnEntry) {
void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name)
}
return { success: true, assignedCount: result.count, roundId }
}),
/**
* List projects in a specific round (for import-from-round picker).
*/
getProjectsInRound: adminProcedure
.input(
z.object({
roundId: z.string(),
states: z.array(z.string()).optional(),
search: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const { roundId, states, search } = input
const where: Record<string, unknown> = { roundId }
if (states && states.length > 0) {
where.state = { in: states }
}
if (search?.trim()) {
where.project = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
],
}
}
const projectStates = await ctx.prisma.projectRoundState.findMany({
where,
select: {
projectId: true,
state: true,
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
country: true,
},
},
},
orderBy: { project: { title: 'asc' } },
})
return projectStates.map((ps) => ({
id: ps.project.id,
title: ps.project.title,
teamName: ps.project.teamName,
competitionCategory: ps.project.competitionCategory,
country: ps.project.country,
state: ps.state,
}))
}),
/**
* Import projects from an earlier round into a later round.
* Fills intermediate rounds with COMPLETED states to keep history clean.
*/
importFromRound: adminProcedure
.input(
z.object({
sourceRoundId: z.string(),
targetRoundId: z.string(),
projectIds: z.array(z.string()).min(1).max(1000),
})
)
.mutation(async ({ ctx, input }) => {
const { sourceRoundId, targetRoundId, projectIds } = input
// Validate both rounds exist and belong to the same competition
const [sourceRound, targetRound] = await Promise.all([
ctx.prisma.round.findUniqueOrThrow({
where: { id: sourceRoundId },
select: { id: true, name: true, competitionId: true, sortOrder: true },
}),
ctx.prisma.round.findUniqueOrThrow({
where: { id: targetRoundId },
select: { id: true, name: true, competitionId: true, sortOrder: true },
}),
])
if (sourceRound.competitionId !== targetRound.competitionId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Source and target rounds must belong to the same competition',
})
}
if (sourceRound.sortOrder >= targetRound.sortOrder) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Source round must come before target round',
})
}
// Validate all projectIds exist in the source round
const sourceStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: sourceRoundId, projectId: { in: projectIds } },
select: { projectId: true, state: true },
})
if (sourceStates.length !== projectIds.length) {
const foundIds = new Set(sourceStates.map((s) => s.projectId))
const missing = projectIds.filter((id) => !foundIds.has(id))
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${missing.length} project(s) not found in source round`,
})
}
// Find intermediate rounds
const intermediateRounds = await ctx.prisma.round.findMany({
where: {
competitionId: sourceRound.competitionId,
sortOrder: { gt: sourceRound.sortOrder, lt: targetRound.sortOrder },
},
select: { id: true },
orderBy: { sortOrder: 'asc' },
})
// Check which projects are already in the target round
const existingInTarget = await ctx.prisma.projectRoundState.findMany({
where: { roundId: targetRoundId, projectId: { in: projectIds } },
select: { projectId: true },
})
const alreadyInTarget = new Set(existingInTarget.map((e) => e.projectId))
const toImport = projectIds.filter((id) => !alreadyInTarget.has(id))
if (toImport.length === 0) {
return { imported: 0, skipped: projectIds.length }
}
await ctx.prisma.$transaction(async (tx) => {
// Update source round states to COMPLETED (if PASSED or PENDING)
await tx.projectRoundState.updateMany({
where: {
roundId: sourceRoundId,
projectId: { in: toImport },
state: { in: ['PASSED', 'PENDING', 'IN_PROGRESS'] },
},
data: { state: 'COMPLETED' },
})
// Create COMPLETED states for intermediate rounds
if (intermediateRounds.length > 0) {
const intermediateData = intermediateRounds.flatMap((round) =>
toImport.map((projectId) => ({
projectId,
roundId: round.id,
state: 'COMPLETED' as const,
}))
)
await tx.projectRoundState.createMany({
data: intermediateData,
skipDuplicates: true,
})
}
// Create PENDING states in the target round
await tx.projectRoundState.createMany({
data: toImport.map((projectId) => ({
projectId,
roundId: targetRoundId,
})),
skipDuplicates: true,
})
// Update project status to ASSIGNED
await tx.project.updateMany({
where: { id: { in: toImport } },
data: { status: 'ASSIGNED' },
})
// Create status history records
await tx.projectStatusHistory.createMany({
data: toImport.map((projectId) => ({
projectId,
status: 'ASSIGNED',
changedBy: ctx.user?.id,
})),
})
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'IMPORT_FROM_ROUND',
entityType: 'Project',
detailsJson: {
sourceRoundId,
sourceRoundName: sourceRound.name,
targetRoundId,
targetRoundName: targetRound.name,
importedCount: toImport.length,
skippedCount: alreadyInTarget.size,
intermediateRounds: intermediateRounds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { imported: toImport.length, skipped: alreadyInTarget.size }
}),
})