Overhaul applicant portal: timeline, evaluations, nav, resources
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m6s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m6s
- Fix programId/competitionId bug in competition timeline - Add applicantVisibility config to EvaluationConfigSchema (JSONB) - Add admin UI card for controlling applicant feedback visibility - Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline, getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness, and extend getMyDashboard with hasPassedIntake - Rewrite competition timeline to show only EVALUATION + Grand Finale, synthesize FILTERING rejections, handle manually-created projects - Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources - Dashboard: conditional timeline, jury feedback card, deadlines, document completeness, conditional mentor tile - New /applicant/evaluations page with anonymous jury feedback - New /applicant/resources pages (clone of jury learning hub) - Rename /applicant/competitions → /applicant/competition - Remove broken /applicant/competitions/[windowId] page - Add permission info banner to team invite dialog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/em
|
||||
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 type { Prisma } from '@prisma/client'
|
||||
|
||||
// Bucket for applicant submissions
|
||||
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
||||
@@ -1278,6 +1280,12 @@ export const applicantRouter = router({
|
||||
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
||||
const isTeamLead = project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD'
|
||||
|
||||
// Check if project has passed intake
|
||||
const passedIntake = await ctx.prisma.projectRoundState.findFirst({
|
||||
where: { projectId: project.id, state: 'PASSED', round: { roundType: 'INTAKE' } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
return {
|
||||
project: {
|
||||
...project,
|
||||
@@ -1287,6 +1295,461 @@ export const applicantRouter = router({
|
||||
openRounds,
|
||||
timeline,
|
||||
currentStatus,
|
||||
hasPassedIntake: !!passedIntake,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Lightweight flags for conditional nav rendering.
|
||||
*/
|
||||
getNavFlags: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
programId: true,
|
||||
mentorAssignment: { select: { id: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
return { hasMentor: false, hasEvaluationRounds: false }
|
||||
}
|
||||
|
||||
// Check if mentor is assigned
|
||||
const hasMentor = !!project.mentorAssignment
|
||||
|
||||
// Check if there are EVALUATION rounds (CLOSED/ARCHIVED) with applicantVisibility.enabled
|
||||
let hasEvaluationRounds = false
|
||||
if (project.programId) {
|
||||
const closedEvalRounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
competition: { programId: project.programId },
|
||||
roundType: 'EVALUATION',
|
||||
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
|
||||
},
|
||||
select: { configJson: true },
|
||||
})
|
||||
hasEvaluationRounds = closedEvalRounds.some((r) => {
|
||||
const parsed = EvaluationConfigSchema.safeParse(r.configJson)
|
||||
return parsed.success && parsed.data.applicantVisibility.enabled
|
||||
})
|
||||
}
|
||||
|
||||
return { hasMentor, hasEvaluationRounds }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Filtered competition timeline showing only EVALUATION + Grand Finale.
|
||||
* Hides FILTERING/INTAKE/SUBMISSION/MENTORING from applicants.
|
||||
*/
|
||||
getMyCompetitionTimeline: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true, programId: true },
|
||||
})
|
||||
|
||||
if (!project?.programId) {
|
||||
return { competitionName: null, entries: [] }
|
||||
}
|
||||
|
||||
// Find competition via programId (fixes the programId/competitionId bug)
|
||||
const competition = await ctx.prisma.competition.findFirst({
|
||||
where: { programId: project.programId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
if (!competition) {
|
||||
return { competitionName: null, entries: [] }
|
||||
}
|
||||
|
||||
// Get all rounds ordered by sortOrder
|
||||
const rounds = await ctx.prisma.round.findMany({
|
||||
where: { competitionId: competition.id },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
roundType: true,
|
||||
status: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Get all ProjectRoundState for this project
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { projectId: project.id },
|
||||
select: { roundId: true, state: true },
|
||||
})
|
||||
const stateMap = new Map(projectStates.map((ps) => [ps.roundId, ps.state]))
|
||||
|
||||
type TimelineEntry = {
|
||||
id: string
|
||||
label: string
|
||||
roundType: 'EVALUATION' | 'GRAND_FINALE'
|
||||
status: string
|
||||
windowOpenAt: Date | null
|
||||
windowCloseAt: Date | null
|
||||
projectState: string | null
|
||||
isSynthesizedRejection: boolean
|
||||
}
|
||||
|
||||
const entries: TimelineEntry[] = []
|
||||
|
||||
// Build lookup for filtering rounds and their next evaluation round
|
||||
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
|
||||
|
||||
// Check if a FILTERING round before this eval 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 precedingFilterRounds = filteringRounds.filter((fr) => {
|
||||
const frIdx = rounds.findIndex((r) => r.id === fr.id)
|
||||
return frIdx < evalSortOrder
|
||||
})
|
||||
|
||||
for (const fr of precedingFilterRounds) {
|
||||
const filterState = stateMap.get(fr.id)
|
||||
if (filterState === 'REJECTED') {
|
||||
projectState = 'REJECTED'
|
||||
isSynthesizedRejection = true
|
||||
break
|
||||
}
|
||||
if ((filterState === 'IN_PROGRESS' || filterState === 'PENDING') && !actualState) {
|
||||
projectState = 'IN_PROGRESS'
|
||||
isSynthesizedRejection = true
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({
|
||||
id: evalRound.id,
|
||||
label: evalRound.name,
|
||||
roundType: 'EVALUATION',
|
||||
status: evalRound.status,
|
||||
windowOpenAt: evalRound.windowOpenAt,
|
||||
windowCloseAt: evalRound.windowCloseAt,
|
||||
projectState,
|
||||
isSynthesizedRejection,
|
||||
})
|
||||
}
|
||||
|
||||
// Grand Finale: combine LIVE_FINAL + DELIBERATION
|
||||
if (liveFinalRounds.length > 0 || deliberationRounds.length > 0) {
|
||||
const grandFinaleRounds = [...liveFinalRounds, ...deliberationRounds]
|
||||
|
||||
// Project state: prefer LIVE_FINAL state, then DELIBERATION
|
||||
let gfState: string | null = null
|
||||
for (const lfr of liveFinalRounds) {
|
||||
const s = stateMap.get(lfr.id)
|
||||
if (s) { gfState = s; break }
|
||||
}
|
||||
if (!gfState) {
|
||||
for (const dr of deliberationRounds) {
|
||||
const s = stateMap.get(dr.id)
|
||||
if (s) { gfState = s; break }
|
||||
}
|
||||
}
|
||||
|
||||
// Status: most advanced status among grouped rounds
|
||||
const statusPriority: Record<string, number> = {
|
||||
ROUND_ARCHIVED: 3,
|
||||
ROUND_CLOSED: 2,
|
||||
ROUND_ACTIVE: 1,
|
||||
ROUND_DRAFT: 0,
|
||||
}
|
||||
let gfStatus = 'ROUND_DRAFT'
|
||||
for (const r of grandFinaleRounds) {
|
||||
if ((statusPriority[r.status] ?? 0) > (statusPriority[gfStatus] ?? 0)) {
|
||||
gfStatus = r.status
|
||||
}
|
||||
}
|
||||
|
||||
// Use earliest window open and latest window close
|
||||
const openDates = grandFinaleRounds.map((r) => r.windowOpenAt).filter(Boolean) as Date[]
|
||||
const closeDates = grandFinaleRounds.map((r) => r.windowCloseAt).filter(Boolean) as Date[]
|
||||
|
||||
// Check if a prior filtering rejection should propagate
|
||||
let isSynthesizedRejection = false
|
||||
const gfSortOrder = Math.min(
|
||||
...grandFinaleRounds.map((r) => rounds.findIndex((rr) => rr.id === r.id))
|
||||
)
|
||||
for (const fr of filteringRounds) {
|
||||
const frIdx = rounds.findIndex((r) => r.id === fr.id)
|
||||
if (frIdx < gfSortOrder && stateMap.get(fr.id) === 'REJECTED') {
|
||||
gfState = 'REJECTED'
|
||||
isSynthesizedRejection = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({
|
||||
id: 'grand-finale',
|
||||
label: 'Grand Finale',
|
||||
roundType: 'GRAND_FINALE',
|
||||
status: gfStatus,
|
||||
windowOpenAt: openDates.length > 0 ? new Date(Math.min(...openDates.map((d) => d.getTime()))) : null,
|
||||
windowCloseAt: closeDates.length > 0 ? new Date(Math.max(...closeDates.map((d) => d.getTime()))) : null,
|
||||
projectState: gfState,
|
||||
isSynthesizedRejection,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle projects manually created at a non-intake round:
|
||||
// If a project has state in a later round but not earlier, mark prior rounds as PASSED.
|
||||
// Find the earliest visible entry (EVALUATION or GRAND_FINALE) that has a real state.
|
||||
const firstEntryWithState = entries.findIndex(
|
||||
(e) => e.projectState !== null && !e.isSynthesizedRejection
|
||||
)
|
||||
if (firstEntryWithState > 0) {
|
||||
// All entries before the first real state should show as PASSED (if the round is closed/archived)
|
||||
for (let i = 0; i < firstEntryWithState; i++) {
|
||||
const entry = entries[i]
|
||||
if (!entry.projectState) {
|
||||
const roundClosed = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
||||
if (roundClosed) {
|
||||
entry.projectState = 'PASSED'
|
||||
entry.isSynthesizedRejection = false // not a rejection, it's a synthesized pass
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the project was rejected in filtering and there are entries after,
|
||||
// null-out states for entries after the rejection point
|
||||
let foundRejection = false
|
||||
for (const entry of entries) {
|
||||
if (foundRejection) {
|
||||
entry.projectState = null
|
||||
}
|
||||
if (entry.projectState === 'REJECTED' && entry.isSynthesizedRejection) {
|
||||
foundRejection = true
|
||||
}
|
||||
}
|
||||
|
||||
return { competitionName: competition.name, entries }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get anonymous jury evaluations visible to the applicant.
|
||||
* Respects per-round applicantVisibility config. NEVER leaks juror identity.
|
||||
*/
|
||||
getMyEvaluations: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true, programId: true },
|
||||
})
|
||||
|
||||
if (!project?.programId) return []
|
||||
|
||||
// Get closed/archived EVALUATION rounds for this competition
|
||||
const evalRounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
competition: { programId: project.programId },
|
||||
roundType: 'EVALUATION',
|
||||
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
configJson: true,
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const results: Array<{
|
||||
roundId: string
|
||||
roundName: string
|
||||
evaluationCount: number
|
||||
evaluations: Array<{
|
||||
id: string
|
||||
submittedAt: Date | null
|
||||
globalScore: number | null
|
||||
criterionScores: Prisma.JsonValue | null
|
||||
feedbackText: string | null
|
||||
criteria: Prisma.JsonValue | null
|
||||
}>
|
||||
}> = []
|
||||
|
||||
for (const round of evalRounds) {
|
||||
const parsed = EvaluationConfigSchema.safeParse(round.configJson)
|
||||
if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue
|
||||
|
||||
const vis = parsed.data.applicantVisibility
|
||||
|
||||
// Get evaluations via assignments — NEVER select userId or user relation
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: {
|
||||
projectId: project.id,
|
||||
roundId: round.id,
|
||||
},
|
||||
status: { in: ['SUBMITTED', 'LOCKED'] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
submittedAt: true,
|
||||
globalScore: vis.showGlobalScore,
|
||||
criterionScoresJson: vis.showCriterionScores,
|
||||
feedbackText: vis.showFeedbackText,
|
||||
form: vis.showCriterionScores ? { select: { criteriaJson: true } } : false,
|
||||
},
|
||||
orderBy: { submittedAt: 'asc' },
|
||||
})
|
||||
|
||||
results.push({
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
evaluationCount: evaluations.length,
|
||||
evaluations: evaluations.map((ev) => ({
|
||||
id: ev.id,
|
||||
submittedAt: ev.submittedAt,
|
||||
globalScore: vis.showGlobalScore ? (ev as { globalScore?: number | null }).globalScore ?? null : null,
|
||||
criterionScores: vis.showCriterionScores ? (ev as { criterionScoresJson?: Prisma.JsonValue }).criterionScoresJson ?? null : null,
|
||||
feedbackText: vis.showFeedbackText ? (ev as { feedbackText?: string | null }).feedbackText ?? null : null,
|
||||
criteria: vis.showCriterionScores ? ((ev as { form?: { criteriaJson: Prisma.JsonValue } | null }).form?.criteriaJson ?? null) : null,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}),
|
||||
|
||||
/**
|
||||
* Upcoming deadlines for dashboard card.
|
||||
*/
|
||||
getUpcomingDeadlines: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { programId: true },
|
||||
})
|
||||
|
||||
if (!project?.programId) return []
|
||||
|
||||
const now = new Date()
|
||||
const rounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
competition: { programId: project.programId },
|
||||
status: 'ROUND_ACTIVE',
|
||||
windowCloseAt: { gt: now },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
windowCloseAt: true,
|
||||
},
|
||||
orderBy: { windowCloseAt: 'asc' },
|
||||
})
|
||||
|
||||
return rounds.map((r) => ({
|
||||
roundName: r.name,
|
||||
windowCloseAt: r.windowCloseAt!,
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Document completeness progress for dashboard card.
|
||||
*/
|
||||
getDocumentCompleteness: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.role !== 'APPLICANT') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
||||
}
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true, programId: true },
|
||||
})
|
||||
|
||||
if (!project?.programId) return []
|
||||
|
||||
// Find active rounds with file requirements
|
||||
const rounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
competition: { programId: project.programId },
|
||||
status: 'ROUND_ACTIVE',
|
||||
fileRequirements: { some: {} },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fileRequirements: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const results: Array<{ roundId: string; roundName: string; required: number; uploaded: number }> = []
|
||||
|
||||
for (const round of rounds) {
|
||||
const requirementIds = round.fileRequirements.map((fr) => fr.id)
|
||||
if (requirementIds.length === 0) continue
|
||||
|
||||
const uploaded = await ctx.prisma.projectFile.count({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
requirementId: { in: requirementIds },
|
||||
},
|
||||
})
|
||||
|
||||
results.push({
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
required: requirementIds.length,
|
||||
uploaded,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user