Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal

Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)

Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)

Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)

Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)

Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 21:58:27 +01:00
parent 002a9dbfc3
commit 699248e40b
38 changed files with 5437 additions and 533 deletions

View File

@@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
// Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
@@ -231,6 +232,7 @@ export const applicantRouter = router({
fileName: z.string(),
mimeType: z.string(),
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
roundId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -248,6 +250,9 @@ export const applicantRouter = router({
id: input.projectId,
submittedByUserId: ctx.user.id,
},
include: {
round: { select: { id: true, votingStartAt: true, settingsJson: true } },
},
})
if (!project) {
@@ -257,8 +262,38 @@ export const applicantRouter = router({
})
}
// Can't upload if already submitted
if (project.submittedAt) {
// Check round upload deadline policy if roundId provided
let isLate = false
const targetRoundId = input.roundId || project.roundId
if (targetRoundId) {
const round = input.roundId
? await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { votingStartAt: true, settingsJson: true },
})
: project.round
if (round) {
const settings = round.settingsJson as Record<string, unknown> | null
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
const now = new Date()
const roundStarted = round.votingStartAt && now > round.votingStartAt
if (roundStarted && uploadPolicy === 'BLOCK') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Uploads are blocked after the round has started',
})
}
if (roundStarted && uploadPolicy === 'ALLOW_LATE') {
isLate = true
}
}
}
// Can't upload if already submitted (unless round allows it)
if (project.submittedAt && !isLate) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot modify a submitted project',
@@ -275,6 +310,8 @@ export const applicantRouter = router({
url,
bucket: SUBMISSIONS_BUCKET,
objectKey,
isLate,
roundId: targetRoundId,
}
}),
@@ -291,6 +328,8 @@ export const applicantRouter = router({
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
bucket: z.string(),
objectKey: z.string(),
roundId: z.string().optional(),
isLate: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -317,13 +356,14 @@ export const applicantRouter = router({
})
}
const { projectId, ...fileData } = input
const { projectId, roundId, isLate, ...fileData } = input
// Delete existing file of same type if exists
// Delete existing file of same type, scoped by roundId if provided
await ctx.prisma.projectFile.deleteMany({
where: {
projectId,
fileType: input.fileType,
...(roundId ? { roundId } : {}),
},
})
@@ -332,6 +372,8 @@ export const applicantRouter = router({
data: {
projectId,
...fileData,
roundId: roundId || null,
isLate: isLate || false,
},
})
@@ -380,6 +422,48 @@ export const applicantRouter = router({
return { success: true }
}),
/**
* Get status timeline from ProjectStatusHistory
*/
getStatusTimeline: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify user has access to this project
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
select: { id: true },
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found',
})
}
const history = await ctx.prisma.projectStatusHistory.findMany({
where: { projectId: input.projectId },
orderBy: { changedAt: 'asc' },
select: {
status: true,
changedAt: true,
changedBy: true,
},
})
return history
}),
/**
* Get submission status timeline
*/
@@ -412,6 +496,9 @@ export const applicantRouter = router({
},
},
},
wonAwards: {
select: { id: true, name: true },
},
},
})
@@ -425,40 +512,89 @@ export const applicantRouter = router({
// Get the project status
const currentStatus = project.status ?? 'SUBMITTED'
// Build timeline
// Fetch actual status history
const statusHistory = await ctx.prisma.projectStatusHistory.findMany({
where: { projectId: input.projectId },
orderBy: { changedAt: 'asc' },
select: { status: true, changedAt: true },
})
// Build a map of status -> earliest changedAt
const statusDateMap = new Map<string, Date>()
for (const entry of statusHistory) {
if (!statusDateMap.has(entry.status)) {
statusDateMap.set(entry.status, entry.changedAt)
}
}
const isRejected = currentStatus === 'REJECTED'
const hasWonAward = project.wonAwards.length > 0
// Build timeline - handle REJECTED as terminal state
const timeline = [
{
status: 'CREATED',
label: 'Application Started',
date: project.createdAt,
completed: true,
isTerminal: false,
},
{
status: 'SUBMITTED',
label: 'Application Submitted',
date: project.submittedAt,
completed: !!project.submittedAt,
date: project.submittedAt || statusDateMap.get('SUBMITTED') || null,
completed: !!project.submittedAt || statusDateMap.has('SUBMITTED'),
isTerminal: false,
},
{
status: 'UNDER_REVIEW',
label: 'Under Review',
date: currentStatus === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null,
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
},
{
status: 'SEMIFINALIST',
label: 'Semi-finalist',
date: null, // Would need status change tracking
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
},
{
status: 'FINALIST',
label: 'Finalist',
date: null,
completed: ['FINALIST', 'WINNER'].includes(currentStatus),
date: statusDateMap.get('ELIGIBLE') || statusDateMap.get('ASSIGNED') ||
(currentStatus !== 'SUBMITTED' && project.submittedAt ? project.submittedAt : null),
completed: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(currentStatus),
isTerminal: false,
},
]
if (isRejected) {
// For rejected projects, show REJECTED as the terminal red step
timeline.push({
status: 'REJECTED',
label: 'Not Selected',
date: statusDateMap.get('REJECTED') || null,
completed: true,
isTerminal: true,
})
} else {
// Normal progression
timeline.push(
{
status: 'SEMIFINALIST',
label: 'Semi-finalist',
date: statusDateMap.get('SEMIFINALIST') || null,
completed: ['SEMIFINALIST', 'FINALIST'].includes(currentStatus) || hasWonAward,
isTerminal: false,
},
{
status: 'FINALIST',
label: 'Finalist',
date: statusDateMap.get('FINALIST') || null,
completed: currentStatus === 'FINALIST' || hasWonAward,
isTerminal: false,
},
)
if (hasWonAward) {
timeline.push({
status: 'WINNER',
label: `Winner${project.wonAwards.length > 0 ? ` - ${project.wonAwards[0].name}` : ''}`,
date: null,
completed: true,
isTerminal: false,
})
}
}
return {
project,
timeline,
@@ -714,4 +850,130 @@ export const applicantRouter = router({
return { success: true }
}),
/**
* Send a message to the assigned mentor
*/
sendMentorMessage: protectedProcedure
.input(
z.object({
projectId: z.string(),
message: z.string().min(1).max(5000),
})
)
.mutation(async ({ ctx, input }) => {
// Verify user is part of this project team
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
include: {
mentorAssignment: { select: { mentorId: true } },
},
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found or you do not have access',
})
}
if (!project.mentorAssignment) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No mentor assigned to this project',
})
}
const mentorMessage = await ctx.prisma.mentorMessage.create({
data: {
projectId: input.projectId,
senderId: ctx.user.id,
message: input.message,
},
include: {
sender: {
select: { id: true, name: true, email: true },
},
},
})
// Notify the mentor
await createNotification({
userId: project.mentorAssignment.mentorId,
type: 'MENTOR_MESSAGE',
title: 'New Message',
message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`,
linkUrl: `/mentor/projects/${input.projectId}`,
linkLabel: 'View Message',
priority: 'normal',
metadata: {
projectId: input.projectId,
projectName: project.title,
},
})
return mentorMessage
}),
/**
* Get mentor messages for a project (applicant side)
*/
getMentorMessages: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify user is part of this project team
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
select: { id: true },
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found or you do not have access',
})
}
const messages = await ctx.prisma.mentorMessage.findMany({
where: { projectId: input.projectId },
include: {
sender: {
select: { id: true, name: true, email: true, role: true },
},
},
orderBy: { createdAt: 'asc' },
})
// Mark unread messages from mentor as read
await ctx.prisma.mentorMessage.updateMany({
where: {
projectId: input.projectId,
senderId: { not: ctx.user.id },
isRead: false,
},
data: { isRead: true },
})
return messages
}),
})