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

@@ -3,13 +3,13 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
import { getImageUploadUrl, confirmImageUpload, getImageUrl, type ImageUploadConfig } from '@/server/utils/image-upload'
import { generateLogoKey, createStorageProvider, type StorageProviderType } from '@/lib/storage'
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
import { checkRequirementsAndTransition } from '../services/round-engine'
import { EvaluationConfigSchema } from '@/types/competition-configs'
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
import type { Prisma } from '@prisma/client'
// Bucket for applicant submissions
@@ -415,14 +415,17 @@ export const applicantRouter = router({
},
})
// Auto-transition: if uploading against a round requirement, check completion
if (roundId && requirementId) {
await checkRequirementsAndTransition(
projectId,
roundId,
ctx.user.id,
ctx.prisma,
)
// Auto-transition: mark as IN_PROGRESS on file activity, then check completion
if (roundId) {
await triggerInProgressOnActivity(projectId, roundId, ctx.user.id, ctx.prisma)
if (requirementId) {
await checkRequirementsAndTransition(
projectId,
roundId,
ctx.user.id,
ctx.prisma,
)
}
}
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
@@ -724,6 +727,8 @@ export const applicantRouter = router({
email: true,
status: true,
lastLoginAt: true,
profileImageKey: true,
profileImageProvider: true,
},
},
},
@@ -742,9 +747,20 @@ export const applicantRouter = router({
})
}
// Generate presigned avatar URLs for team members with profile images
const avatarUrls: Record<string, string> = {}
for (const member of project.teamMembers) {
if (member.user.profileImageKey) {
const providerType = (member.user.profileImageProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
avatarUrls[member.userId] = await provider.getDownloadUrl(member.user.profileImageKey)
}
}
return {
teamMembers: project.teamMembers,
submittedBy: project.submittedBy,
avatarUrls,
}
}),
@@ -1296,6 +1312,7 @@ export const applicantRouter = router({
id: true,
name: true,
slug: true,
roundType: true,
windowCloseAt: true,
},
})
@@ -1311,6 +1328,24 @@ export const applicantRouter = router({
select: { id: true },
})
// Check if there is an active intake round (applicants can edit project details during intake)
const activeIntakeRound = await ctx.prisma.round.findFirst({
where: {
competition: { programId: project.programId },
roundType: 'INTAKE',
status: 'ROUND_ACTIVE',
},
select: { id: true },
})
// Generate presigned logo URL if the project has a logo
let logoUrl: string | null = null
if (project.logoKey) {
const providerType = (project.logoProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
logoUrl = await provider.getDownloadUrl(project.logoKey)
}
return {
project: {
...project,
@@ -1321,6 +1356,8 @@ export const applicantRouter = router({
timeline,
currentStatus,
hasPassedIntake: !!passedIntake,
isIntakeOpen: !!activeIntakeRound,
logoUrl,
}
}),
@@ -1430,7 +1467,7 @@ export const applicantRouter = router({
type TimelineEntry = {
id: string
label: string
roundType: 'EVALUATION' | 'GRAND_FINALE'
roundType: string
status: string
windowOpenAt: Date | null
windowCloseAt: Date | null
@@ -1440,25 +1477,32 @@ export const applicantRouter = router({
const entries: TimelineEntry[] = []
// Build lookup for filtering rounds and their next evaluation round
// Build lookup for filtering rounds
const filteringRounds = rounds.filter((r) => r.roundType === 'FILTERING')
const evalRounds = rounds.filter((r) => r.roundType === 'EVALUATION')
const liveFinalRounds = rounds.filter((r) => r.roundType === 'LIVE_FINAL')
const deliberationRounds = rounds.filter((r) => r.roundType === 'DELIBERATION')
// Process EVALUATION rounds
for (const evalRound of evalRounds) {
const actualState = stateMap.get(evalRound.id) ?? null
// Process visible rounds: hide FILTERING, LIVE_FINAL, DELIBERATION always.
// Also hide MENTORING unless the project is actually participating in it.
const visibleRounds = rounds.filter(
(r) => {
if (r.roundType === 'FILTERING' || r.roundType === 'LIVE_FINAL' || r.roundType === 'DELIBERATION') return false
if (r.roundType === 'MENTORING' && !stateMap.has(r.id)) return false
return true
}
)
// Check if a FILTERING round before this eval round rejected the project
for (const round of visibleRounds) {
const actualState = stateMap.get(round.id) ?? null
// Check if a FILTERING round before this round rejected the project
let projectState = actualState
let isSynthesizedRejection = false
// Find FILTERING rounds that come before this eval round in sortOrder
const evalSortOrder = rounds.findIndex((r) => r.id === evalRound.id)
const roundSortOrder = rounds.findIndex((r) => r.id === round.id)
const precedingFilterRounds = filteringRounds.filter((fr) => {
const frIdx = rounds.findIndex((r) => r.id === fr.id)
return frIdx < evalSortOrder
return frIdx < roundSortOrder
})
for (const fr of precedingFilterRounds) {
@@ -1475,12 +1519,12 @@ export const applicantRouter = router({
}
entries.push({
id: evalRound.id,
label: evalRound.name,
roundType: 'EVALUATION',
status: evalRound.status,
windowOpenAt: evalRound.windowOpenAt,
windowCloseAt: evalRound.windowCloseAt,
id: round.id,
label: round.name,
roundType: round.roundType,
status: round.status,
windowOpenAt: round.windowOpenAt,
windowCloseAt: round.windowCloseAt,
projectState,
isSynthesizedRejection,
})
@@ -1913,6 +1957,59 @@ export const applicantRouter = router({
return { success: true }
}),
/**
* Delete project logo (applicant access).
*/
deleteProjectLogo: protectedProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const isMember = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
})
if (!isMember) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
}
const logoConfig: ImageUploadConfig<{ logoKey: string | null; logoProvider: string | null }> = {
label: 'logo',
generateKey: generateLogoKey,
findCurrent: (prisma, entityId) =>
prisma.project.findUnique({
where: { id: entityId },
select: { logoKey: true, logoProvider: true },
}),
getImageKey: (record) => record.logoKey,
getProviderType: (record) =>
(record.logoProvider as StorageProviderType) || 's3',
setImage: (prisma, entityId, key, providerType) =>
prisma.project.update({
where: { id: entityId },
data: { logoKey: key, logoProvider: providerType },
}),
clearImage: (prisma, entityId) =>
prisma.project.update({
where: { id: entityId },
data: { logoKey: null, logoProvider: null },
}),
auditEntityType: 'Project',
auditFieldName: 'logoKey',
}
return deleteImage(ctx.prisma, logoConfig, input.projectId, {
userId: ctx.user.id,
ip: ctx.ip,
userAgent: ctx.userAgent,
})
}),
/**
* Get project logo URL (applicant access).
*/
@@ -1932,4 +2029,196 @@ export const applicantRouter = router({
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
}),
/**
* Withdraw from competition. Only team lead can withdraw.
* Finds the current active (non-terminal) ProjectRoundState and transitions to WITHDRAWN.
*/
/**
* Get mentoring request status for a project in a MENTORING round
*/
getMentoringRequestStatus: protectedProcedure
.input(z.object({ projectId: z.string(), roundId: z.string() }))
.query(async ({ ctx, input }) => {
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
}
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { id: true, roundType: true, status: true, configJson: true, windowOpenAt: true },
})
if (!round || round.roundType !== 'MENTORING') {
return { available: false, requested: false, requestedAt: null, deadline: null, canStillRequest: false }
}
const config = MentoringConfigSchema.safeParse(round.configJson)
const deadlineDays = config.success ? config.data.mentoringRequestDeadlineDays : 14
const deadline = round.windowOpenAt
? new Date(new Date(round.windowOpenAt).getTime() + deadlineDays * 24 * 60 * 60 * 1000)
: null
const canStillRequest = round.status === 'ROUND_ACTIVE' && (!deadline || new Date() < deadline)
const prs = await ctx.prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
select: { metadataJson: true },
})
const metadata = (prs?.metadataJson as Record<string, unknown>) ?? {}
const requested = !!metadata.mentoringRequested
const requestedAt = metadata.mentoringRequestedAt ? new Date(metadata.mentoringRequestedAt as string) : null
return { available: true, requested, requestedAt, deadline, canStillRequest }
}),
/**
* Request or cancel mentoring for the current MENTORING round
*/
requestMentoring: protectedProcedure
.input(z.object({ projectId: z.string(), roundId: z.string(), requesting: z.boolean() }))
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can request mentoring' })
}
// Verify caller is team lead
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, submittedByUserId: true, title: true },
})
if (!project) throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
if (project.submittedByUserId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the team lead can request mentoring' })
}
// Verify round is MENTORING and ACTIVE
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { id: true, roundType: true, status: true, configJson: true, windowOpenAt: true },
})
if (!round || round.roundType !== 'MENTORING') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a mentoring round' })
}
if (round.status !== 'ROUND_ACTIVE') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Mentoring round is not active' })
}
// Check deadline
const config = MentoringConfigSchema.safeParse(round.configJson)
const deadlineDays = config.success ? config.data.mentoringRequestDeadlineDays : 14
if (round.windowOpenAt) {
const deadline = new Date(new Date(round.windowOpenAt).getTime() + deadlineDays * 24 * 60 * 60 * 1000)
if (new Date() > deadline) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Mentoring request window has closed' })
}
}
// Find PRS
const prs = await ctx.prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
})
if (!prs) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project is not assigned to this round' })
}
const existingMeta = (prs.metadataJson as Record<string, unknown>) ?? {}
// Update metadataJson with mentoring request info
await ctx.prisma.projectRoundState.update({
where: { id: prs.id },
data: {
metadataJson: {
...existingMeta,
mentoringRequested: input.requesting,
mentoringRequestedAt: input.requesting ? new Date().toISOString() : null,
},
},
})
// If requesting mentoring and currently PASSED (pass-through), transition to IN_PROGRESS
if (input.requesting && prs.state === 'PASSED') {
await transitionProject(
input.projectId, input.roundId,
'IN_PROGRESS' as Parameters<typeof transitionProject>[2],
ctx.user.id, ctx.prisma,
)
}
await logAudit({
prisma: ctx.prisma,
action: input.requesting ? 'MENTORING_REQUESTED' : 'MENTORING_CANCELLED',
entityType: 'Project',
entityId: input.projectId,
userId: ctx.user.id,
detailsJson: { roundId: input.roundId, projectTitle: project.title },
})
return { success: true, requesting: input.requesting }
}),
withdrawFromCompetition: protectedProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can withdraw' })
}
// Verify caller is team lead (submittedByUserId)
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, submittedByUserId: true, title: true },
})
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
}
if (project.submittedByUserId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the team lead can withdraw from the competition' })
}
// Find the active (non-terminal) ProjectRoundState
const activePrs = await ctx.prisma.projectRoundState.findFirst({
where: {
projectId: input.projectId,
round: { status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } },
},
include: { round: { select: { id: true, name: true } } },
orderBy: { round: { sortOrder: 'desc' } },
})
if (!activePrs || isTerminalState(activePrs.state as Parameters<typeof isTerminalState>[0])) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active round participation to withdraw from' })
}
const result = await transitionProject(
input.projectId,
activePrs.roundId,
'WITHDRAWN' as Parameters<typeof transitionProject>[2],
ctx.user.id,
ctx.prisma,
)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to withdraw',
})
}
// Audit log
await logAudit({
prisma: ctx.prisma,
action: 'WITHDRAWAL',
entityType: 'Project',
entityId: input.projectId,
userId: ctx.user.id,
detailsJson: { roundId: activePrs.roundId, roundName: activePrs.round.name, projectTitle: project.title },
})
return { success: true, roundId: activePrs.roundId, roundName: activePrs.round.name }
}),
})

View File

@@ -9,6 +9,7 @@ import { generateSummary } from '@/server/services/ai-evaluation-summary'
import { quickRank as aiQuickRank } from '../services/ai-ranking'
import type { EvaluationConfig } from '@/types/competition-configs'
import type { PrismaClient } from '@prisma/client'
import { triggerInProgressOnActivity, checkEvaluationCompletionAndTransition } from '../services/round-engine'
/**
* Auto-trigger AI ranking if all required assignments for the round are complete.
@@ -377,6 +378,12 @@ export const evaluationRouter = router({
// Auto-trigger ranking if all assignments complete (fire-and-forget, never awaited)
void triggerAutoRankIfComplete(evaluation.assignment.roundId, ctx.prisma, ctx.user.id)
// Auto-transition: mark project IN_PROGRESS and check if all evaluations are done
const projectId = evaluation.assignment.projectId
const roundIdForTransition = evaluation.assignment.roundId
await triggerInProgressOnActivity(projectId, roundIdForTransition, ctx.user.id, ctx.prisma)
await checkEvaluationCompletionAndTransition(projectId, roundIdForTransition, ctx.user.id, ctx.prisma)
// Audit log
await logAudit({
prisma: ctx.prisma,

View File

@@ -11,6 +11,7 @@ import {
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
import { triggerInProgressOnActivity } from '../services/round-engine'
/**
* Verify the current session user exists in the database.
@@ -204,6 +205,11 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
})
}
// Auto-transition: mark all projects as IN_PROGRESS when filtering starts
for (const pss of projectStates) {
await triggerInProgressOnActivity(pss.projectId, roundId, userId, prisma)
}
// Execute rules — upsert results per batch for streaming to the UI
const results = await executeFilteringRules(rules, projects, userId, roundId, onProgress, async (batchResults) => {
if (batchResults.length === 0) return

View File

@@ -21,6 +21,7 @@ import {
addFileComment as workspaceAddFileComment,
promoteFile as workspacePromoteFile,
} from '../services/mentor-workspace'
import { triggerInProgressOnActivity } from '../services/round-engine'
export const mentorRouter = router({
/**
@@ -211,6 +212,23 @@ export const mentorRouter = router({
},
})
// Auto-transition: mark project IN_PROGRESS in any active MENTORING round
try {
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
where: {
projectId: input.projectId,
round: { roundType: 'MENTORING', status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } },
state: 'PENDING',
},
select: { roundId: true },
})
if (mentoringPrs) {
await triggerInProgressOnActivity(input.projectId, mentoringPrs.roundId, ctx.user.id, ctx.prisma)
}
} catch (e) {
console.error('[Mentor] triggerInProgressOnActivity failed (non-fatal):', e)
}
return assignment
}),

View File

@@ -2,7 +2,7 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { sendStyledNotificationEmail } from '@/lib/email'
import { sendStyledNotificationEmail, getEmailPreviewHtml } from '@/lib/email'
export const messageRouter = router({
/**
@@ -12,7 +12,7 @@ export const messageRouter = router({
send: adminProcedure
.input(
z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'PROGRAM_TEAM', 'ALL']),
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
recipientFilter: z.any().optional(),
roundId: z.string().optional(),
subject: z.string().min(1).max(500),
@@ -371,6 +371,34 @@ export const messageRouter = router({
return template
}),
/**
* Preview styled email HTML for admin compose dialog.
*/
previewEmail: adminProcedure
.input(z.object({ subject: z.string(), body: z.string() }))
.query(({ input }) => {
return { html: getEmailPreviewHtml(input.subject, input.body) }
}),
/**
* Send a test email to the currently logged-in admin.
*/
sendTest: adminProcedure
.input(z.object({ subject: z.string(), body: z.string() }))
.mutation(async ({ ctx, input }) => {
await sendStyledNotificationEmail(
ctx.user.email,
ctx.user.name || '',
'MESSAGE',
{
title: input.subject,
message: input.body,
linkUrl: '/admin/messages',
}
)
return { sent: true, to: ctx.user.email }
}),
})
// =============================================================================
@@ -419,6 +447,35 @@ async function resolveRecipients(
return assignments.map((a) => a.userId)
}
case 'ROUND_APPLICANTS': {
const targetRoundId = roundId || (filter?.roundId as string)
if (!targetRoundId) return []
// Get all projects in this round
const projectStates = await prisma.projectRoundState.findMany({
where: { roundId: targetRoundId },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
if (projectIds.length === 0) return []
// Get team members + submittedByUserId
const [teamMembers, projects] = await Promise.all([
prisma.teamMember.findMany({
where: { projectId: { in: projectIds } },
select: { userId: true },
}),
prisma.project.findMany({
where: { id: { in: projectIds } },
select: { submittedByUserId: true },
}),
])
const userIds = new Set<string>()
for (const tm of teamMembers) userIds.add(tm.userId)
for (const p of projects) {
if (p.submittedByUserId) userIds.add(p.submittedByUserId)
}
return [...userIds]
}
case 'PROGRAM_TEAM': {
const programId = filter?.programId as string
if (!programId) return []

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 }
}),
})

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 }
}),
})

View File

@@ -11,6 +11,11 @@ import {
getProjectRoundStates,
getProjectRoundState,
} from '../services/round-engine'
import {
processRoundClose,
getFinalizationSummary,
confirmFinalization,
} from '../services/round-finalization'
const projectRoundStateEnum = z.enum([
'PENDING',
@@ -318,4 +323,110 @@ export const roundEngineRouter = router({
projectIds: result.projectIds,
}
}),
// ─── Finalization Procedures ────────────────────────────────────────────
getFinalizationSummary: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return getFinalizationSummary(input.roundId, ctx.prisma)
}),
updateProposedOutcome: adminProcedure
.input(z.object({
roundId: z.string(),
projectId: z.string(),
proposedOutcome: projectRoundStateEnum,
}))
.mutation(async ({ ctx, input }) => {
const prs = await ctx.prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
})
if (!prs) throw new TRPCError({ code: 'NOT_FOUND', message: 'Project round state not found' })
return ctx.prisma.projectRoundState.update({
where: { id: prs.id },
data: { proposedOutcome: input.proposedOutcome },
})
}),
batchUpdateProposedOutcomes: adminProcedure
.input(z.object({
roundId: z.string(),
outcomes: z.record(z.string(), projectRoundStateEnum),
}))
.mutation(async ({ ctx, input }) => {
let updated = 0
for (const [projectId, outcome] of Object.entries(input.outcomes)) {
await ctx.prisma.projectRoundState.updateMany({
where: { projectId, roundId: input.roundId },
data: { proposedOutcome: outcome },
})
updated++
}
return { updated }
}),
confirmFinalization: adminProcedure
.input(z.object({
roundId: z.string(),
targetRoundId: z.string().optional(),
advancementMessage: z.string().optional(),
rejectionMessage: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
return confirmFinalization(
input.roundId,
{
targetRoundId: input.targetRoundId,
advancementMessage: input.advancementMessage,
rejectionMessage: input.rejectionMessage,
},
ctx.user.id,
ctx.prisma,
)
}),
endGracePeriod: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId } })
if (round.status !== 'ROUND_CLOSED') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Round must be ROUND_CLOSED' })
}
if (!round.gracePeriodEndsAt) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Round has no grace period set' })
}
// Clear grace period and process
await ctx.prisma.round.update({
where: { id: input.roundId },
data: { gracePeriodEndsAt: new Date() },
})
const result = await processRoundClose(input.roundId, ctx.user.id, ctx.prisma)
return { processed: result.processed }
}),
/**
* Manually trigger processRoundClose for already-closed rounds.
* Used when a round was closed before the finalization system existed,
* or when processRoundClose failed silently on close.
*/
processRoundProjects: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId } })
if (round.status !== 'ROUND_CLOSED' && round.status !== 'ROUND_ARCHIVED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Round must be ROUND_CLOSED or ROUND_ARCHIVED, got ${round.status}`,
})
}
const result = await processRoundClose(input.roundId, ctx.user.id, ctx.prisma)
return { processed: result.processed }
}),
})

View File

@@ -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)
*/

View File

@@ -1,4 +1,3 @@
import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import type { Prisma } from '@prisma/client'
@@ -8,29 +7,7 @@ import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls } from '@/server/utils/avatar-url'
import { logAudit } from '@/server/utils/audit'
const DEFAULT_INVITE_EXPIRY_HOURS = 72 // 3 days
async function getInviteExpiryHours(prisma: import('@prisma/client').PrismaClient): Promise<number> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'invite_link_expiry_hours' },
select: { value: true },
})
const hours = setting?.value ? parseInt(setting.value, 10) : DEFAULT_INVITE_EXPIRY_HOURS
return isNaN(hours) || hours < 1 ? DEFAULT_INVITE_EXPIRY_HOURS : hours
} catch {
return DEFAULT_INVITE_EXPIRY_HOURS
}
}
async function getInviteExpiryMs(prisma: import('@prisma/client').PrismaClient): Promise<number> {
return (await getInviteExpiryHours(prisma)) * 60 * 60 * 1000
}
function generateInviteToken(): string {
return crypto.randomBytes(32).toString('hex')
}
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
export const userRouter = router({
/**
@@ -95,9 +72,21 @@ export const userRouter = router({
return { valid: false, error: 'EXPIRED_TOKEN' as const }
}
// Check if user belongs to a team (was invited as team member)
const teamMembership = await ctx.prisma.teamMember.findFirst({
where: { userId: user.id },
select: {
role: true,
project: { select: { title: true, teamName: true } },
},
})
return {
valid: true,
user: { name: user.name, email: user.email, role: user.role },
team: teamMembership
? { projectTitle: teamMembership.project.title, teamName: teamMembership.project.teamName }
: null,
}
}),

View File

@@ -18,6 +18,7 @@ import type {
Prisma,
} from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
import { triggerInProgressOnActivity } from './round-engine'
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -186,7 +187,7 @@ export async function submitVote(
const runoffRound = params.runoffRound ?? 0
return prisma.deliberationVote.upsert({
const vote = await prisma.deliberationVote.upsert({
where: {
sessionId_juryMemberId_projectId_runoffRound: {
sessionId: params.sessionId,
@@ -208,6 +209,17 @@ export async function submitVote(
isWinnerPick: params.isWinnerPick ?? false,
},
})
// Auto-transition: mark project IN_PROGRESS in the deliberation round
try {
if (session.roundId) {
await triggerInProgressOnActivity(params.projectId, session.roundId, params.juryMemberId, prisma)
}
} catch (e) {
console.error('[Deliberation] triggerInProgressOnActivity failed (non-fatal):', e)
}
return vote
}
// ─── Aggregation ────────────────────────────────────────────────────────────

View File

@@ -15,6 +15,7 @@ import type { PrismaClient, ProjectRoundStateValue, Prisma } from '@prisma/clien
import { logAudit } from '@/server/utils/audit'
import { safeValidateRoundConfig } from '@/types/competition-configs'
import { expireIntentsForRound } from './assignment-intent'
import { processRoundClose } from './round-finalization'
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -55,11 +56,11 @@ const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
}
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
PENDING: ['IN_PROGRESS', 'PASSED', 'REJECTED', 'WITHDRAWN'],
IN_PROGRESS: ['PASSED', 'REJECTED', 'WITHDRAWN'],
PASSED: ['COMPLETED', 'WITHDRAWN'],
PENDING: ['IN_PROGRESS', 'REJECTED', 'WITHDRAWN'],
IN_PROGRESS: ['COMPLETED', 'REJECTED', 'WITHDRAWN'],
COMPLETED: ['PASSED', 'REJECTED'],
PASSED: ['IN_PROGRESS', 'WITHDRAWN'],
REJECTED: ['PENDING'], // re-include
COMPLETED: [], // terminal
WITHDRAWN: ['PENDING'], // re-include
}
@@ -174,13 +175,44 @@ export async function activateRound(
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
const result = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
if (result.transitionedCount > 0) {
console.log(`[RoundEngine] On activation: auto-passed ${result.transitionedCount} projects with complete documents`)
console.log(`[RoundEngine] On activation: auto-completed ${result.transitionedCount} projects with complete documents`)
}
}
} catch (retroError) {
console.error('[RoundEngine] Retroactive document check failed (non-fatal):', retroError)
}
// Mentoring pass-through: for MENTORING rounds with passThroughIfNoRequest,
// auto-set all PENDING projects to PASSED (they pass through unless they request mentoring)
if (round.roundType === 'MENTORING') {
try {
const mentoringConfig = safeValidateRoundConfig('MENTORING', round.configJson as Record<string, unknown>)
if (mentoringConfig.success && mentoringConfig.data.passThroughIfNoRequest) {
const pendingProjects = await prisma.projectRoundState.findMany({
where: { roundId, state: 'PENDING' },
select: { id: true, projectId: true, metadataJson: true },
})
let passedCount = 0
for (const prs of pendingProjects) {
const meta = (prs.metadataJson as Record<string, unknown>) ?? {}
// Only pass-through projects that haven't requested mentoring
if (!meta.mentoringRequested) {
await prisma.projectRoundState.update({
where: { id: prs.id },
data: { state: 'PASSED' },
})
passedCount++
}
}
if (passedCount > 0) {
console.log(`[RoundEngine] Mentoring pass-through: set ${passedCount} projects to PASSED`)
}
}
} catch (mentoringError) {
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
}
}
return {
success: true,
round: { id: updated.id, status: updated.status },
@@ -311,6 +343,26 @@ export async function closeRound(
detailsJson: { name: round.name, roundType: round.roundType },
})
// Grace period / immediate finalization processing
try {
const config = round.configJson ? (round.configJson as Record<string, unknown>) : {}
const gracePeriodHours = (config.gracePeriodHours as number) ?? 0
if (gracePeriodHours > 0) {
const gracePeriodEndsAt = new Date(Date.now() + gracePeriodHours * 60 * 60 * 1000)
await prisma.round.update({
where: { id: roundId },
data: { gracePeriodEndsAt },
})
console.log(`[RoundEngine] Grace period set for round ${roundId}: ${gracePeriodHours}h (until ${gracePeriodEndsAt.toISOString()})`)
} else {
await processRoundClose(roundId, actorId, prisma)
console.log(`[RoundEngine] Processed round close for ${roundId} (no grace period)`)
}
} catch (processError) {
console.error('[RoundEngine] processRoundClose after close failed (non-fatal):', processError)
}
return {
success: true,
round: { id: updated.id, status: updated.status },
@@ -559,10 +611,10 @@ export async function transitionProject(
return { success: false, errors: [`Round ${roundId} not found`] }
}
if (round.status !== 'ROUND_ACTIVE') {
if (round.status !== 'ROUND_ACTIVE' && round.status !== 'ROUND_CLOSED') {
return {
success: false,
errors: [`Round is ${round.status}, must be ROUND_ACTIVE to transition projects`],
errors: [`Round is ${round.status}, must be ROUND_ACTIVE or ROUND_CLOSED to transition projects`],
}
}
@@ -858,12 +910,17 @@ export async function checkRequirementsAndTransition(
return { transitioned: false }
}
// All requirements met — transition to PASSED
const result = await transitionProject(projectId, roundId, 'PASSED' as ProjectRoundStateValue, actorId, prisma)
// If PENDING, first transition to IN_PROGRESS so the state machine path is valid
if (currentState.state === 'PENDING') {
await triggerInProgressOnActivity(projectId, roundId, actorId, prisma)
}
// All requirements met — transition to COMPLETED (finalization will set PASSED/REJECTED)
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
if (result.success) {
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to PASSED in round ${roundId} (all ${requirements.length} requirements met)`)
return { transitioned: true, newState: 'PASSED' }
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length + submissionRequirements.length} requirements met)`)
return { transitioned: true, newState: 'COMPLETED' }
}
return { transitioned: false }
@@ -894,14 +951,85 @@ export async function batchCheckRequirementsAndTransition(
}
if (transitioned.length > 0) {
console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to PASSED in round ${roundId}`)
console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to COMPLETED in round ${roundId}`)
}
return { transitionedCount: transitioned.length, projectIds: transitioned }
}
// ─── Auto-Transition Hooks ──────────────────────────────────────────────────
/**
* Trigger PENDING → IN_PROGRESS when a project has activity.
* Non-fatal: if the project is not PENDING, this is a no-op.
*/
export async function triggerInProgressOnActivity(
projectId: string,
roundId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<void> {
try {
const prs = await prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId, roundId } },
select: { state: true },
})
if (!prs || prs.state !== 'PENDING') return
const result = await transitionProject(projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma)
if (result.success) {
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to IN_PROGRESS in round ${roundId}`)
}
} catch (error) {
console.error('[RoundEngine] triggerInProgressOnActivity failed (non-fatal):', error)
}
}
/**
* Check if all jury assignments for a project in an evaluation round are completed.
* If yes, transition from IN_PROGRESS → COMPLETED.
*/
export async function checkEvaluationCompletionAndTransition(
projectId: string,
roundId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<{ transitioned: boolean }> {
try {
const prs = await prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId, roundId } },
select: { state: true },
})
if (!prs || prs.state !== 'IN_PROGRESS') return { transitioned: false }
// Check all assignments for this project in this round
const assignments = await prisma.assignment.findMany({
where: { projectId, roundId },
select: { isCompleted: true },
})
if (assignments.length === 0) return { transitioned: false }
const allCompleted = assignments.every((a: { isCompleted: boolean }) => a.isCompleted)
if (!allCompleted) return { transitioned: false }
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
if (result.success) {
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${assignments.length} evaluations done)`)
return { transitioned: true }
}
return { transitioned: false }
} catch (error) {
console.error('[RoundEngine] checkEvaluationCompletionAndTransition failed (non-fatal):', error)
return { transitioned: false }
}
}
// ─── Internals ──────────────────────────────────────────────────────────────
function isTerminalState(state: ProjectRoundStateValue): boolean {
return ['PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'].includes(state)
export function isTerminalState(state: ProjectRoundStateValue): boolean {
return ['PASSED', 'REJECTED', 'WITHDRAWN'].includes(state)
}

View File

@@ -0,0 +1,830 @@
/**
* Round Finalization Service
*
* Handles the post-close lifecycle of a round:
* - processRoundClose: auto-sets project states after a round closes
* - getFinalizationSummary: aggregates data for the finalization review UI
* - confirmFinalization: single transaction to apply outcomes, advance projects, send emails
*/
import type { PrismaClient, ProjectRoundStateValue, RoundType, Prisma } from '@prisma/client'
import { transitionProject, isTerminalState } from './round-engine'
import { logAudit } from '@/server/utils/audit'
import {
sendStyledNotificationEmail,
getRejectionNotificationTemplate,
} from '@/lib/email'
import { createBulkNotifications } from '../services/in-app-notification'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
// ─── Types ──────────────────────────────────────────────────────────────────
export type FinalizationSummary = {
roundId: string
roundName: string
roundType: RoundType
isGracePeriodActive: boolean
gracePeriodEndsAt: Date | null
isFinalized: boolean
finalizedAt: Date | null
stats: {
pending: number
inProgress: number
completed: number
passed: number
rejected: number
withdrawn: number
}
projects: Array<{
id: string
title: string
teamName: string | null
category: string | null
country: string | null
currentState: ProjectRoundStateValue
proposedOutcome: ProjectRoundStateValue | null
evaluationScore?: number | null
rankPosition?: number | null
}>
categoryTargets: {
startupTarget: number | null
conceptTarget: number | null
startupProposed: number
conceptProposed: number
}
nextRound: { id: string; name: string } | null
accountStats: {
needsInvite: number
hasAccount: number
}
}
export type ConfirmFinalizationResult = {
advanced: number
rejected: number
emailsSent: number
emailsFailed: number
}
// ─── processRoundClose ──────────────────────────────────────────────────────
/**
* Process project states after a round closes.
* Auto-transitions projects to COMPLETED/REJECTED and sets proposedOutcome defaults.
* Called immediately on close (if no grace period) or after grace period expires.
*/
export async function processRoundClose(
roundId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<{ processed: number }> {
const round = await prisma.round.findUnique({
where: { id: roundId },
include: {
competition: {
select: {
rounds: {
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' as const },
},
},
},
},
})
if (!round) throw new Error(`Round ${roundId} not found`)
const projectStates = await prisma.projectRoundState.findMany({
where: { roundId },
include: {
project: {
select: {
id: true,
competitionCategory: true,
files: { where: { roundId }, select: { id: true, requirementId: true, submissionFileRequirementId: true } },
assignments: { where: { roundId }, select: { isCompleted: true } },
filteringResults: { where: { roundId }, select: { outcome: true, finalOutcome: true } },
},
},
},
})
let processed = 0
// Pre-compute pass set for EVALUATION rounds using ranking scores + config
let evaluationPassSet: Set<string> | null = null
if ((round.roundType as RoundType) === 'EVALUATION') {
evaluationPassSet = new Set<string>()
const snapshot = await prisma.rankingSnapshot.findFirst({
where: { roundId },
orderBy: { createdAt: 'desc' as const },
select: { startupRankingJson: true, conceptRankingJson: true },
})
if (snapshot) {
const config = (round.configJson as Record<string, unknown>) ?? {}
const advanceMode = (config.advanceMode as string) || 'count'
const advanceScoreThreshold = (config.advanceScoreThreshold as number) ?? 6
const startupAdvanceCount = (config.startupAdvanceCount as number) ?? 0
const conceptAdvanceCount = (config.conceptAdvanceCount as number) ?? 0
type RankEntry = { projectId: string; avgGlobalScore: number | null; rank: number }
const startupRanked = (snapshot.startupRankingJson ?? []) as RankEntry[]
const conceptRanked = (snapshot.conceptRankingJson ?? []) as RankEntry[]
if (advanceMode === 'threshold') {
for (const r of [...startupRanked, ...conceptRanked]) {
if (r.avgGlobalScore != null && r.avgGlobalScore >= advanceScoreThreshold) {
evaluationPassSet.add(r.projectId)
}
}
} else {
// 'count' mode — top N per category by rank
const sortedStartup = [...startupRanked].sort((a, b) => a.rank - b.rank)
const sortedConcept = [...conceptRanked].sort((a, b) => a.rank - b.rank)
for (let i = 0; i < Math.min(startupAdvanceCount, sortedStartup.length); i++) {
evaluationPassSet.add(sortedStartup[i].projectId)
}
for (let i = 0; i < Math.min(conceptAdvanceCount, sortedConcept.length); i++) {
evaluationPassSet.add(sortedConcept[i].projectId)
}
}
}
}
for (const prs of projectStates) {
// Skip already-terminal states
if (isTerminalState(prs.state)) {
// Set proposed outcome to match current state for display
if (!prs.proposedOutcome) {
await prisma.projectRoundState.update({
where: { id: prs.id },
data: { proposedOutcome: prs.state },
})
}
processed++
continue
}
let targetState: ProjectRoundStateValue = prs.state
let proposedOutcome: ProjectRoundStateValue = 'PASSED'
switch (round.roundType as RoundType) {
case 'INTAKE':
case 'SUBMISSION': {
// Projects with activity → COMPLETED, purely PENDING → REJECTED
if (prs.state === 'PENDING') {
targetState = 'REJECTED' as ProjectRoundStateValue
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
} else if (prs.state === 'IN_PROGRESS' || prs.state === 'COMPLETED') {
if (prs.state === 'IN_PROGRESS') targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = 'PASSED' as ProjectRoundStateValue
}
break
}
case 'EVALUATION': {
// Use ranking scores to determine pass/reject
const hasEvals = prs.project.assignments.some((a: { isCompleted: boolean }) => a.isCompleted)
const shouldPass = evaluationPassSet?.has(prs.projectId) ?? false
if (prs.state === 'IN_PROGRESS' || (prs.state === 'PENDING' && hasEvals)) {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = (shouldPass ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
} else if (prs.state === 'PENDING') {
targetState = 'REJECTED' as ProjectRoundStateValue
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
} else if (prs.state === 'COMPLETED') {
proposedOutcome = (shouldPass ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
}
break
}
case 'FILTERING': {
// Use FilteringResult to determine outcome for each project
const fr = prs.project.filteringResults?.[0] as { outcome: string; finalOutcome: string | null } | undefined
const effectiveOutcome = fr?.finalOutcome || fr?.outcome
const filterPassed = effectiveOutcome !== 'FILTERED_OUT'
if (prs.state === 'COMPLETED') {
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
} else if (prs.state === 'IN_PROGRESS') {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
} else if (prs.state === 'PENDING') {
// PENDING projects in filtering: check FilteringResult
if (fr) {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
} else {
// No filtering result at all → reject
targetState = 'REJECTED' as ProjectRoundStateValue
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
}
}
break
}
case 'MENTORING': {
// Projects already PASSED (pass-through) stay PASSED
if (prs.state === 'PASSED') {
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'IN_PROGRESS') {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'COMPLETED') {
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'PENDING') {
// Pending = never requested mentoring, pass through
proposedOutcome = 'PASSED' as ProjectRoundStateValue
targetState = 'COMPLETED' as ProjectRoundStateValue
}
break
}
case 'LIVE_FINAL': {
// All presented projects → COMPLETED
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'COMPLETED') {
proposedOutcome = 'PASSED' as ProjectRoundStateValue
}
break
}
case 'DELIBERATION': {
// All voted projects → COMPLETED
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'COMPLETED') {
proposedOutcome = 'PASSED' as ProjectRoundStateValue
}
break
}
}
// Transition project if needed (admin override for non-standard paths)
if (targetState !== prs.state && !isTerminalState(prs.state)) {
// Need to handle multi-step transitions
if (prs.state === 'PENDING' && targetState === 'COMPLETED') {
await transitionProject(prs.projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
await transitionProject(prs.projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
} else if (prs.state === 'PENDING' && targetState === 'REJECTED') {
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
} else {
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
}
}
// Set proposed outcome
await prisma.projectRoundState.update({
where: { id: prs.id },
data: { proposedOutcome },
})
processed++
}
return { processed }
}
// ─── getFinalizationSummary ─────────────────────────────────────────────────
export async function getFinalizationSummary(
roundId: string,
prisma: PrismaClient | any,
): Promise<FinalizationSummary> {
const round = await prisma.round.findUniqueOrThrow({
where: { id: roundId },
include: {
competition: {
select: {
rounds: {
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' as const },
},
},
},
},
})
const now = new Date()
const isGracePeriodActive = !!(round.gracePeriodEndsAt && new Date(round.gracePeriodEndsAt) > now && !round.finalizedAt)
const isFinalized = !!round.finalizedAt
// Get config for category targets
const config = (round.configJson as Record<string, unknown>) ?? {}
// Find next round
const rounds = round.competition.rounds
const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId)
const nextRound = currentIdx >= 0 && currentIdx < rounds.length - 1
? rounds[currentIdx + 1]
: null
// Get all project states with project details
const projectStates = await prisma.projectRoundState.findMany({
where: { roundId },
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
country: true,
},
},
},
orderBy: { createdAt: 'asc' as const },
})
// Compute stats
const stats = { pending: 0, inProgress: 0, completed: 0, passed: 0, rejected: 0, withdrawn: 0 }
for (const prs of projectStates) {
switch (prs.state) {
case 'PENDING': stats.pending++; break
case 'IN_PROGRESS': stats.inProgress++; break
case 'COMPLETED': stats.completed++; break
case 'PASSED': stats.passed++; break
case 'REJECTED': stats.rejected++; break
case 'WITHDRAWN': stats.withdrawn++; break
}
}
// Get evaluation scores if this is an evaluation round
let scoreMap = new Map<string, number>()
let rankMap = new Map<string, number>()
if (round.roundType === 'EVALUATION') {
// Get latest ranking snapshot (per-category fields)
const snapshot = await prisma.rankingSnapshot.findFirst({
where: { roundId },
orderBy: { createdAt: 'desc' as const },
select: { startupRankingJson: true, conceptRankingJson: true },
})
if (snapshot) {
type RankEntry = { projectId: string; avgGlobalScore?: number; compositeScore?: number; rank?: number }
const allRanked = [
...((snapshot.startupRankingJson ?? []) as RankEntry[]),
...((snapshot.conceptRankingJson ?? []) as RankEntry[]),
]
for (const r of allRanked) {
if (r.avgGlobalScore != null) scoreMap.set(r.projectId, r.avgGlobalScore)
else if (r.compositeScore != null) scoreMap.set(r.projectId, r.compositeScore)
if (r.rank != null) rankMap.set(r.projectId, r.rank)
}
}
}
// Build project list
const projects = projectStates.map((prs: any) => ({
id: prs.project.id,
title: prs.project.title,
teamName: prs.project.teamName,
category: prs.project.competitionCategory,
country: prs.project.country,
currentState: prs.state as ProjectRoundStateValue,
proposedOutcome: prs.proposedOutcome as ProjectRoundStateValue | null,
evaluationScore: scoreMap.get(prs.project.id) ?? null,
rankPosition: rankMap.get(prs.project.id) ?? null,
}))
// Category target progress
const startupTarget = (config.startupAdvanceCount as number | undefined) ?? null
const conceptTarget = (config.conceptAdvanceCount as number | undefined) ?? null
let startupProposed = 0
let conceptProposed = 0
for (const p of projects) {
if (p.proposedOutcome === 'PASSED') {
if (p.category === 'STARTUP') startupProposed++
else if (p.category === 'BUSINESS_CONCEPT') conceptProposed++
}
}
// Account stats: count how many advancing projects need invite vs already have accounts
let needsInvite = 0
let hasAccount = 0
const passedProjectIds = projects.filter((p: { proposedOutcome: string | null }) => p.proposedOutcome === 'PASSED').map((p: { id: string }) => p.id)
if (passedProjectIds.length > 0) {
const passedProjects = await prisma.project.findMany({
where: { id: { in: passedProjectIds } },
select: {
id: true,
submittedBy: { select: { passwordHash: true } },
teamMembers: { select: { user: { select: { passwordHash: true } } } },
},
})
for (const p of passedProjects) {
// Check team members first, then submittedBy
const users = p.teamMembers.length > 0
? p.teamMembers.map((tm: any) => tm.user)
: p.submittedBy ? [p.submittedBy] : []
const anyHasPassword = users.some((u: any) => !!u.passwordHash)
if (anyHasPassword) hasAccount++
else needsInvite++
}
}
return {
roundId,
roundName: round.name,
roundType: round.roundType,
isGracePeriodActive,
gracePeriodEndsAt: round.gracePeriodEndsAt,
isFinalized,
finalizedAt: round.finalizedAt,
stats,
projects,
categoryTargets: {
startupTarget,
conceptTarget,
startupProposed,
conceptProposed,
},
nextRound: nextRound ? { id: nextRound.id, name: nextRound.name } : null,
accountStats: { needsInvite, hasAccount },
}
}
// ─── confirmFinalization ────────────────────────────────────────────────────
export async function confirmFinalization(
roundId: string,
options: {
targetRoundId?: string
advancementMessage?: string
rejectionMessage?: string
},
actorId: string,
prisma: PrismaClient | any,
): Promise<ConfirmFinalizationResult> {
// Validate: round is CLOSED, not already finalized, grace period expired
const round = await prisma.round.findUniqueOrThrow({
where: { id: roundId },
include: {
competition: {
select: {
id: true,
rounds: {
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' as const },
},
},
},
},
})
if (round.status !== 'ROUND_CLOSED') {
throw new Error(`Round must be ROUND_CLOSED to finalize, got ${round.status}`)
}
if (round.finalizedAt) {
throw new Error('Round is already finalized')
}
const now = new Date()
if (round.gracePeriodEndsAt && new Date(round.gracePeriodEndsAt) > now) {
throw new Error('Cannot finalize: grace period is still active')
}
// Determine target round
const rounds = round.competition.rounds
const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId)
const targetRoundId = options.targetRoundId
?? (currentIdx >= 0 && currentIdx < rounds.length - 1
? rounds[currentIdx + 1].id
: undefined)
const targetRoundName = targetRoundId
? rounds.find((r: { id: string }) => r.id === targetRoundId)?.name ?? 'Next Round'
: 'Next Round'
// Execute finalization in a transaction
const result = await prisma.$transaction(async (tx: any) => {
const projectStates = await tx.projectRoundState.findMany({
where: { roundId, proposedOutcome: { not: null } },
include: {
project: {
select: {
id: true,
title: true,
status: true,
},
},
},
})
let advanced = 0
let rejected = 0
for (const prs of projectStates) {
const proposed = prs.proposedOutcome as ProjectRoundStateValue
// Skip if already in the proposed state
if (prs.state === proposed) {
if (proposed === 'PASSED') advanced++
else if (proposed === 'REJECTED') rejected++
continue
}
// Transition to proposed outcome
if (proposed === 'PASSED' || proposed === 'REJECTED') {
// Ensure we're in COMPLETED before transitioning to PASSED/REJECTED
if (prs.state !== 'COMPLETED' && prs.state !== 'PASSED' && prs.state !== 'REJECTED') {
// Force through intermediate states
if (prs.state === 'PENDING') {
await tx.projectRoundState.update({
where: { id: prs.id },
data: { state: 'IN_PROGRESS' },
})
}
if (prs.state === 'PENDING' || prs.state === 'IN_PROGRESS') {
await tx.projectRoundState.update({
where: { id: prs.id },
data: { state: 'COMPLETED' },
})
}
}
// Now transition to final state
await tx.projectRoundState.update({
where: { id: prs.id },
data: {
state: proposed,
exitedAt: now,
},
})
if (proposed === 'PASSED') {
advanced++
// Create ProjectRoundState in target round (if exists)
if (targetRoundId) {
await tx.projectRoundState.upsert({
where: {
projectId_roundId: {
projectId: prs.projectId,
roundId: targetRoundId,
},
},
create: {
projectId: prs.projectId,
roundId: targetRoundId,
state: 'PENDING',
enteredAt: now,
},
update: {}, // skip if already exists
})
}
// Update Project.status to ASSIGNED
await tx.project.update({
where: { id: prs.projectId },
data: { status: 'ASSIGNED' },
})
// Create ProjectStatusHistory
await tx.projectStatusHistory.create({
data: {
projectId: prs.projectId,
status: 'ASSIGNED',
changedBy: actorId,
reason: `Advanced from round "${round.name}" via finalization`,
},
})
} else {
rejected++
}
// Audit log per project
await tx.decisionAuditLog.create({
data: {
eventType: 'finalization.project_outcome',
entityType: 'ProjectRoundState',
entityId: prs.id,
actorId,
detailsJson: {
projectId: prs.projectId,
roundId,
previousState: prs.state,
outcome: proposed,
targetRoundId: proposed === 'PASSED' ? targetRoundId : null,
} as Prisma.InputJsonValue,
snapshotJson: {
timestamp: now.toISOString(),
emittedBy: 'round-finalization',
},
},
})
}
}
// Mark round as finalized
await tx.round.update({
where: { id: roundId },
data: {
finalizedAt: now,
finalizedBy: actorId,
},
})
// Finalization audit
await tx.decisionAuditLog.create({
data: {
eventType: 'round.finalized',
entityType: 'Round',
entityId: roundId,
actorId,
detailsJson: {
roundName: round.name,
advanced,
rejected,
targetRoundId,
hasCustomAdvancementMessage: !!options.advancementMessage,
hasCustomRejectionMessage: !!options.rejectionMessage,
} as Prisma.InputJsonValue,
snapshotJson: {
timestamp: now.toISOString(),
emittedBy: 'round-finalization',
},
},
})
return { advanced, rejected }
})
// Send emails outside transaction (non-fatal)
let emailsSent = 0
let emailsFailed = 0
try {
// Get all projects that were finalized
const finalizedStates = await prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PASSED', 'REJECTED'] } },
include: {
project: {
select: {
id: true,
title: true,
submittedByEmail: true,
submittedByUserId: true,
submittedBy: { select: { id: true, email: true, name: true, passwordHash: true } },
teamMembers: {
select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
},
},
},
},
})
// Pre-generate invite tokens for passwordless users on advancing projects
const inviteTokenMap = new Map<string, string>() // userId → token
const expiryMs = await getInviteExpiryMs(prisma)
for (const prs of finalizedStates) {
if (prs.state !== 'PASSED') continue
const users = prs.project.teamMembers.length > 0
? prs.project.teamMembers.map((tm: any) => tm.user)
: prs.project.submittedBy ? [prs.project.submittedBy] : []
for (const user of users) {
if (user && !user.passwordHash && !inviteTokenMap.has(user.id)) {
const token = generateInviteToken()
inviteTokenMap.set(user.id, token)
await prisma.user.update({
where: { id: user.id },
data: {
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
})
}
}
}
const advancedUserIds = new Set<string>()
const rejectedUserIds = new Set<string>()
for (const prs of finalizedStates) {
type Recipient = { email: string; name: string | null; userId: string | null }
const recipients: Recipient[] = []
for (const tm of prs.project.teamMembers) {
if (tm.user.email) {
recipients.push({ email: tm.user.email, name: tm.user.name, userId: tm.user.id })
if (prs.state === 'PASSED') advancedUserIds.add(tm.user.id)
else rejectedUserIds.add(tm.user.id)
}
}
if (recipients.length === 0 && prs.project.submittedBy?.email) {
recipients.push({
email: prs.project.submittedBy.email,
name: prs.project.submittedBy.name,
userId: prs.project.submittedBy.id,
})
if (prs.state === 'PASSED') advancedUserIds.add(prs.project.submittedBy.id)
else rejectedUserIds.add(prs.project.submittedBy.id)
} else if (recipients.length === 0 && prs.project.submittedByEmail) {
recipients.push({ email: prs.project.submittedByEmail, name: null, userId: null })
}
for (const recipient of recipients) {
try {
if (prs.state === 'PASSED') {
// Build account creation URL for passwordless users
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
await sendStyledNotificationEmail(
recipient.email,
recipient.name || '',
'ADVANCEMENT_NOTIFICATION',
{
title: 'Your project has advanced!',
message: '',
linkUrl: accountUrl || '/applicant',
metadata: {
projectName: prs.project.title,
fromRoundName: round.name,
toRoundName: targetRoundName,
customMessage: options.advancementMessage || undefined,
accountUrl,
},
},
)
} else {
await sendStyledNotificationEmail(
recipient.email,
recipient.name || '',
'REJECTION_NOTIFICATION',
{
title: `Update on your application: "${prs.project.title}"`,
message: '',
metadata: {
projectName: prs.project.title,
roundName: round.name,
customMessage: options.rejectionMessage || undefined,
},
},
)
}
emailsSent++
} catch (err) {
console.error(`[Finalization] Email failed for ${recipient.email}:`, err)
emailsFailed++
}
}
}
// Create in-app notifications
if (advancedUserIds.size > 0) {
void createBulkNotifications({
userIds: [...advancedUserIds],
type: 'project_advanced',
title: 'Your project has advanced!',
message: `Your project has advanced from "${round.name}" to "${targetRoundName}".`,
linkUrl: '/applicant',
linkLabel: 'View Dashboard',
icon: 'Trophy',
priority: 'high',
})
}
if (rejectedUserIds.size > 0) {
void createBulkNotifications({
userIds: [...rejectedUserIds],
type: 'project_rejected',
title: 'Competition Update',
message: `Your project did not advance past "${round.name}".`,
linkUrl: '/applicant',
linkLabel: 'View Dashboard',
icon: 'Info',
priority: 'normal',
})
}
} catch (emailError) {
console.error('[Finalization] Email batch failed (non-fatal):', emailError)
}
// External audit log
await logAudit({
userId: actorId,
action: 'ROUND_FINALIZED',
entityType: 'Round',
entityId: roundId,
detailsJson: {
roundName: round.name,
advanced: result.advanced,
rejected: result.rejected,
emailsSent,
emailsFailed,
},
})
return {
advanced: result.advanced,
rejected: result.rejected,
emailsSent,
emailsFailed,
}
}

View File

@@ -0,0 +1,25 @@
import crypto from 'crypto'
import type { PrismaClient } from '@prisma/client'
const DEFAULT_INVITE_EXPIRY_HOURS = 72 // 3 days
export function generateInviteToken(): string {
return crypto.randomBytes(32).toString('hex')
}
export async function getInviteExpiryHours(prisma: PrismaClient): Promise<number> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'invite_link_expiry_hours' },
select: { value: true },
})
const hours = setting?.value ? parseInt(setting.value, 10) : DEFAULT_INVITE_EXPIRY_HOURS
return isNaN(hours) || hours < 1 ? DEFAULT_INVITE_EXPIRY_HOURS : hours
} catch {
return DEFAULT_INVITE_EXPIRY_HOURS
}
}
export async function getInviteExpiryMs(prisma: PrismaClient): Promise<number> {
return (await getInviteExpiryHours(prisma)) * 60 * 60 * 1000
}