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:
@@ -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
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user