feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
- processRoundClose EVALUATION uses ranking scores + advanceMode config (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED - Advancement emails generate invite tokens for passwordless users with "Create Your Account" CTA; rejection emails have no link - Finalization UI shows account stats (invite vs dashboard link counts) - Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson) - New award pool notification system: getAwardSelectionNotificationTemplate email, notifyEligibleProjects mutation with invite token generation, "Notify Pool" button on award detail page with custom message dialog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user