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