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