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