2026-02-11 13:20:52 +01:00
|
|
|
import crypto from 'crypto'
|
2026-01-30 13:41:32 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
|
|
|
|
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
|
|
|
|
import { getPresignedUrl } from '@/lib/minio'
|
2026-02-11 13:20:52 +01:00
|
|
|
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
2026-02-05 21:09:06 +01:00
|
|
|
import { logAudit } from '@/server/utils/audit'
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
import { createNotification } from '../services/in-app-notification'
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
// Bucket for applicant submissions
|
|
|
|
|
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
2026-02-11 13:20:52 +01:00
|
|
|
const TEAM_INVITE_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
|
|
|
|
|
|
|
|
|
|
function generateInviteToken(): string {
|
|
|
|
|
return crypto.randomBytes(32).toString('hex')
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
export const applicantRouter = router({
|
|
|
|
|
/**
|
|
|
|
|
* Get submission info for an applicant (by round slug)
|
|
|
|
|
*/
|
|
|
|
|
getSubmissionBySlug: publicProcedure
|
|
|
|
|
.input(z.object({ slug: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
// Find the round by slug
|
|
|
|
|
const round = await ctx.prisma.round.findFirst({
|
|
|
|
|
where: { slug: input.slug },
|
|
|
|
|
include: {
|
|
|
|
|
program: { select: { id: true, name: true, year: true, description: true } },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!round) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Round not found',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if submissions are open
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const isOpen = round.submissionDeadline
|
|
|
|
|
? now < round.submissionDeadline
|
|
|
|
|
: round.status === 'ACTIVE'
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
round: {
|
|
|
|
|
id: round.id,
|
|
|
|
|
name: round.name,
|
|
|
|
|
slug: round.slug,
|
|
|
|
|
submissionDeadline: round.submissionDeadline,
|
|
|
|
|
isOpen,
|
|
|
|
|
},
|
|
|
|
|
program: round.program,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the current user's submission for a round (as submitter or team member)
|
|
|
|
|
*/
|
|
|
|
|
getMySubmission: protectedProcedure
|
|
|
|
|
.input(z.object({ roundId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
// Only applicants can use this
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only applicants can access submissions',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
2026-02-04 14:15:06 +01:00
|
|
|
roundId: input.roundId,
|
2026-01-30 13:41:32 +01:00
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: { userId: ctx.user.id },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
files: true,
|
2026-02-04 14:15:06 +01:00
|
|
|
round: {
|
2026-01-30 13:41:32 +01:00
|
|
|
include: {
|
2026-02-04 14:15:06 +01:00
|
|
|
program: { select: { name: true, year: true } },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
teamMembers: {
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (project) {
|
|
|
|
|
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
|
|
|
|
return {
|
|
|
|
|
...project,
|
|
|
|
|
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
|
|
|
|
|
isTeamLead: project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create or update a submission (draft or submitted)
|
|
|
|
|
*/
|
|
|
|
|
saveSubmission: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
projectId: z.string().optional(), // If updating existing
|
|
|
|
|
title: z.string().min(1).max(500),
|
|
|
|
|
teamName: z.string().optional(),
|
|
|
|
|
description: z.string().optional(),
|
|
|
|
|
tags: z.array(z.string()).optional(),
|
|
|
|
|
metadataJson: z.record(z.unknown()).optional(),
|
|
|
|
|
submit: z.boolean().default(false), // Whether to submit or just save draft
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Only applicants can use this
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only applicants can submit projects',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if the round is open for submissions
|
|
|
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
if (round.submissionDeadline && now > round.submissionDeadline) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Submission deadline has passed',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { projectId, submit, roundId, metadataJson, ...data } = input
|
|
|
|
|
|
|
|
|
|
if (projectId) {
|
|
|
|
|
// Update existing
|
|
|
|
|
const existing = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: projectId,
|
|
|
|
|
submittedByUserId: ctx.user.id,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Project not found or you do not have access',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Can't update if already submitted
|
|
|
|
|
if (existing.submittedAt && !submit) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Cannot modify a submitted project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const project = await ctx.prisma.project.update({
|
|
|
|
|
where: { id: projectId },
|
|
|
|
|
data: {
|
|
|
|
|
...data,
|
|
|
|
|
metadataJson: metadataJson as unknown ?? undefined,
|
|
|
|
|
submittedAt: submit && !existing.submittedAt ? now : existing.submittedAt,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-04 14:15:06 +01:00
|
|
|
// Update Project status if submitting
|
2026-02-02 22:33:55 +01:00
|
|
|
if (submit) {
|
2026-02-04 14:15:06 +01:00
|
|
|
await ctx.prisma.project.update({
|
|
|
|
|
where: { id: projectId },
|
2026-02-02 22:33:55 +01:00
|
|
|
data: { status: 'SUBMITTED' },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
return project
|
|
|
|
|
} else {
|
2026-02-02 22:33:55 +01:00
|
|
|
// Get the round to find the programId
|
|
|
|
|
const roundForCreate = await ctx.prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: roundId },
|
|
|
|
|
select: { programId: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Create new project
|
2026-01-30 13:41:32 +01:00
|
|
|
const project = await ctx.prisma.project.create({
|
|
|
|
|
data: {
|
Add dynamic apply wizard customization with admin settings UI
- Create wizard config types, utilities, and defaults (wizard-config.ts)
- Add admin apply settings page with drag-and-drop step ordering, dropdown
option management, feature toggles, welcome message customization, and
custom field builder with select/multiselect options editor
- Build dynamic apply wizard component with animated step transitions,
mobile-first responsive design, and config-driven form validation
- Update step components to accept dynamic config (categories, ocean issues,
field visibility, feature flags)
- Replace hardcoded enum validation with string-based validation for
admin-configurable dropdown values, with safe enum casting at storage layer
- Add wizard template system (model, router, admin UI) with built-in
MOPC Classic preset
- Add program wizard config CRUD procedures to program router
- Update application router getConfig to return wizardConfig, submit handler
to store custom field data in metadataJson
- Add edition-based apply page, project pool page, and supporting routers
- Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea),
safe area insets for notched phones, buildStepsArray field visibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:18:20 +01:00
|
|
|
programId: roundForCreate.programId,
|
2026-02-04 14:15:06 +01:00
|
|
|
roundId,
|
2026-01-30 13:41:32 +01:00
|
|
|
...data,
|
|
|
|
|
metadataJson: metadataJson as unknown ?? undefined,
|
|
|
|
|
submittedByUserId: ctx.user.id,
|
|
|
|
|
submittedByEmail: ctx.user.email,
|
|
|
|
|
submissionSource: 'MANUAL',
|
|
|
|
|
submittedAt: submit ? now : null,
|
2026-02-02 22:33:55 +01:00
|
|
|
status: 'SUBMITTED',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
// Audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CREATE',
|
|
|
|
|
entityType: 'Project',
|
|
|
|
|
entityId: project.id,
|
|
|
|
|
detailsJson: { title: input.title, source: 'applicant_portal' },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return project
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get upload URL for a submission file
|
|
|
|
|
*/
|
|
|
|
|
getUploadUrl: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
fileName: z.string(),
|
|
|
|
|
mimeType: z.string(),
|
|
|
|
|
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
roundId: z.string().optional(),
|
2026-02-08 23:01:33 +01:00
|
|
|
requirementId: z.string().optional(),
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
2026-02-08 23:01:33 +01:00
|
|
|
// Applicants or team members can upload
|
2026-01-30 13:41:32 +01:00
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
2026-02-08 23:01:33 +01:00
|
|
|
// Check if user is a team member of the project
|
|
|
|
|
const teamMembership = await ctx.prisma.teamMember.findFirst({
|
|
|
|
|
where: { projectId: input.projectId, userId: ctx.user.id },
|
|
|
|
|
select: { id: true },
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
2026-02-08 23:01:33 +01:00
|
|
|
if (!teamMembership) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only applicants or team members can upload files',
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 23:01:33 +01:00
|
|
|
// Verify project access (owner or team member)
|
2026-01-30 13:41:32 +01:00
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
2026-02-08 23:01:33 +01:00
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
include: {
|
|
|
|
|
round: { select: { id: true, votingStartAt: true, settingsJson: true } },
|
|
|
|
|
},
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Project not found or you do not have access',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 23:01:33 +01:00
|
|
|
// If uploading against a requirement, validate mime type and size
|
|
|
|
|
if (input.requirementId) {
|
|
|
|
|
const requirement = await ctx.prisma.fileRequirement.findUnique({
|
|
|
|
|
where: { id: input.requirementId },
|
|
|
|
|
})
|
|
|
|
|
if (!requirement) {
|
|
|
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'File requirement not found' })
|
|
|
|
|
}
|
|
|
|
|
// Validate mime type
|
|
|
|
|
if (requirement.acceptedMimeTypes.length > 0) {
|
|
|
|
|
const accepted = requirement.acceptedMimeTypes.some((pattern) => {
|
|
|
|
|
if (pattern.endsWith('/*')) {
|
|
|
|
|
return input.mimeType.startsWith(pattern.replace('/*', '/'))
|
|
|
|
|
}
|
|
|
|
|
return input.mimeType === pattern
|
|
|
|
|
})
|
|
|
|
|
if (!accepted) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: `File type ${input.mimeType} is not accepted. Accepted types: ${requirement.acceptedMimeTypes.join(', ')}`,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
// 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) {
|
2026-01-30 13:41:32 +01:00
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Cannot modify a submitted project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const timestamp = Date.now()
|
|
|
|
|
const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
|
|
|
|
|
const objectKey = `${project.id}/${input.fileType}/${timestamp}-${sanitizedName}`
|
|
|
|
|
|
|
|
|
|
const url = await getPresignedUrl(SUBMISSIONS_BUCKET, objectKey, 'PUT', 3600)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
url,
|
|
|
|
|
bucket: SUBMISSIONS_BUCKET,
|
|
|
|
|
objectKey,
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
isLate,
|
|
|
|
|
roundId: targetRoundId,
|
2026-01-30 13:41:32 +01:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save file metadata after upload
|
|
|
|
|
*/
|
|
|
|
|
saveFileMetadata: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
fileName: z.string(),
|
|
|
|
|
mimeType: z.string(),
|
|
|
|
|
size: z.number().int(),
|
|
|
|
|
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
|
|
|
|
bucket: z.string(),
|
|
|
|
|
objectKey: z.string(),
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
roundId: z.string().optional(),
|
|
|
|
|
isLate: z.boolean().optional(),
|
2026-02-08 23:01:33 +01:00
|
|
|
requirementId: z.string().optional(),
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
2026-02-08 23:01:33 +01:00
|
|
|
// Applicants or team members can save files
|
2026-01-30 13:41:32 +01:00
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
2026-02-08 23:01:33 +01:00
|
|
|
const teamMembership = await ctx.prisma.teamMember.findFirst({
|
|
|
|
|
where: { projectId: input.projectId, userId: ctx.user.id },
|
|
|
|
|
select: { id: true },
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
2026-02-08 23:01:33 +01:00
|
|
|
if (!teamMembership) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only applicants or team members can save files',
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 23:01:33 +01:00
|
|
|
// Verify project access
|
2026-01-30 13:41:32 +01:00
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
2026-02-08 23:01:33 +01:00
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Project not found or you do not have access',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 23:01:33 +01:00
|
|
|
const { projectId, roundId, isLate, requirementId, ...fileData } = input
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-08 23:01:33 +01:00
|
|
|
// Delete existing file: by requirementId if provided, otherwise by fileType+roundId
|
|
|
|
|
if (requirementId) {
|
|
|
|
|
await ctx.prisma.projectFile.deleteMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectId,
|
|
|
|
|
requirementId,
|
|
|
|
|
...(roundId ? { roundId } : {}),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
await ctx.prisma.projectFile.deleteMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectId,
|
|
|
|
|
fileType: input.fileType,
|
|
|
|
|
...(roundId ? { roundId } : {}),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
// Create new file record
|
|
|
|
|
const file = await ctx.prisma.projectFile.create({
|
|
|
|
|
data: {
|
|
|
|
|
projectId,
|
|
|
|
|
...fileData,
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
roundId: roundId || null,
|
|
|
|
|
isLate: isLate || false,
|
2026-02-08 23:01:33 +01:00
|
|
|
requirementId: requirementId || null,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return file
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a file from submission
|
|
|
|
|
*/
|
|
|
|
|
deleteFile: protectedProcedure
|
|
|
|
|
.input(z.object({ fileId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const file = await ctx.prisma.projectFile.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.fileId },
|
2026-02-08 23:01:33 +01:00
|
|
|
include: { project: { include: { teamMembers: { select: { userId: true } } } } },
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
2026-02-08 23:01:33 +01:00
|
|
|
// Verify ownership or team membership
|
|
|
|
|
const isOwner = file.project.submittedByUserId === ctx.user.id
|
|
|
|
|
const isTeamMember = file.project.teamMembers.some((tm) => tm.userId === ctx.user.id)
|
|
|
|
|
|
|
|
|
|
if (!isOwner && !isTeamMember) {
|
2026-01-30 13:41:32 +01:00
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'You do not have access to this file',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Can't delete if project is submitted
|
|
|
|
|
if (file.project.submittedAt) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Cannot modify a submitted project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.projectFile.delete({
|
|
|
|
|
where: { id: input.fileId },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true }
|
|
|
|
|
}),
|
|
|
|
|
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}),
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
/**
|
|
|
|
|
* Get submission status timeline
|
|
|
|
|
*/
|
|
|
|
|
getSubmissionStatus: protectedProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: { userId: ctx.user.id },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
2026-02-04 14:15:06 +01:00
|
|
|
round: {
|
2026-01-30 13:41:32 +01:00
|
|
|
include: {
|
2026-02-04 14:15:06 +01:00
|
|
|
program: { select: { name: true, year: true } },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
files: true,
|
|
|
|
|
teamMembers: {
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
wonAwards: {
|
|
|
|
|
select: { id: true, name: true },
|
|
|
|
|
},
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Project not found',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 14:15:06 +01:00
|
|
|
// Get the project status
|
|
|
|
|
const currentStatus = project.status ?? 'SUBMITTED'
|
2026-02-02 22:33:55 +01:00
|
|
|
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
// 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
|
2026-01-30 13:41:32 +01:00
|
|
|
const timeline = [
|
|
|
|
|
{
|
|
|
|
|
status: 'CREATED',
|
|
|
|
|
label: 'Application Started',
|
|
|
|
|
date: project.createdAt,
|
|
|
|
|
completed: true,
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
isTerminal: false,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
status: 'SUBMITTED',
|
|
|
|
|
label: 'Application Submitted',
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
date: project.submittedAt || statusDateMap.get('SUBMITTED') || null,
|
|
|
|
|
completed: !!project.submittedAt || statusDateMap.has('SUBMITTED'),
|
|
|
|
|
isTerminal: false,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
status: 'UNDER_REVIEW',
|
|
|
|
|
label: 'Under Review',
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
date: statusDateMap.get('ELIGIBLE') || statusDateMap.get('ASSIGNED') ||
|
|
|
|
|
(currentStatus !== 'SUBMITTED' && project.submittedAt ? project.submittedAt : null),
|
|
|
|
|
completed: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(currentStatus),
|
|
|
|
|
isTerminal: false,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
return {
|
|
|
|
|
project,
|
|
|
|
|
timeline,
|
2026-02-02 22:33:55 +01:00
|
|
|
currentStatus,
|
2026-01-30 13:41:32 +01:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List all submissions for current user (including as team member)
|
|
|
|
|
*/
|
|
|
|
|
listMySubmissions: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only applicants can access submissions',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find projects where user is either the submitter OR a team member
|
|
|
|
|
const projects = await ctx.prisma.project.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: { userId: ctx.user.id },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
2026-02-04 14:15:06 +01:00
|
|
|
round: {
|
2026-01-30 13:41:32 +01:00
|
|
|
include: {
|
2026-02-04 14:15:06 +01:00
|
|
|
program: { select: { name: true, year: true } },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
files: true,
|
|
|
|
|
teamMembers: {
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Add user's role in each project
|
|
|
|
|
return projects.map((project) => {
|
|
|
|
|
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
|
|
|
|
return {
|
|
|
|
|
...project,
|
|
|
|
|
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
|
|
|
|
|
isTeamLead: project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD',
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get team members for a project
|
|
|
|
|
*/
|
|
|
|
|
getTeamMembers: 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 },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
teamMembers: {
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
status: true,
|
|
|
|
|
lastLoginAt: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { joinedAt: 'asc' },
|
|
|
|
|
},
|
|
|
|
|
submittedBy: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Project not found or you do not have access',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
teamMembers: project.teamMembers,
|
|
|
|
|
submittedBy: project.submittedBy,
|
2026-02-08 23:01:33 +01:00
|
|
|
roundId: project.roundId,
|
2026-01-30 13:41:32 +01:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Invite a new team member
|
|
|
|
|
*/
|
|
|
|
|
inviteTeamMember: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
email: z.string().email(),
|
|
|
|
|
name: z.string().min(1),
|
|
|
|
|
role: z.enum(['MEMBER', 'ADVISOR']),
|
|
|
|
|
title: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
2026-02-11 13:20:52 +01:00
|
|
|
const normalizedEmail = input.email.trim().toLowerCase()
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
// Verify user is team lead
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
role: 'LEAD',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only team leads can invite new members',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if already a team member
|
|
|
|
|
const existingMember = await ctx.prisma.teamMember.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
2026-02-11 13:20:52 +01:00
|
|
|
user: { email: normalizedEmail },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (existingMember) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'CONFLICT',
|
|
|
|
|
message: 'This person is already a team member',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find or create user
|
|
|
|
|
let user = await ctx.prisma.user.findUnique({
|
2026-02-11 13:20:52 +01:00
|
|
|
where: { email: normalizedEmail },
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
user = await ctx.prisma.user.create({
|
|
|
|
|
data: {
|
2026-02-11 13:20:52 +01:00
|
|
|
email: normalizedEmail,
|
2026-01-30 13:41:32 +01:00
|
|
|
name: input.name,
|
|
|
|
|
role: 'APPLICANT',
|
2026-02-10 23:08:00 +01:00
|
|
|
status: 'NONE',
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 13:20:52 +01:00
|
|
|
if (user.status === 'SUSPENDED') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'This user account is suspended and cannot be invited',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const teamLeadName = ctx.user.name?.trim() || 'A team lead'
|
|
|
|
|
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
|
|
|
|
const requiresAccountSetup = user.status !== 'ACTIVE'
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (requiresAccountSetup) {
|
|
|
|
|
const token = generateInviteToken()
|
|
|
|
|
await ctx.prisma.user.update({
|
|
|
|
|
where: { id: user.id },
|
|
|
|
|
data: {
|
|
|
|
|
status: 'INVITED',
|
|
|
|
|
inviteToken: token,
|
|
|
|
|
inviteTokenExpiresAt: new Date(Date.now() + TEAM_INVITE_TOKEN_EXPIRY_MS),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
|
|
|
|
await sendTeamMemberInviteEmail(
|
|
|
|
|
user.email,
|
|
|
|
|
user.name || input.name,
|
|
|
|
|
project.title,
|
|
|
|
|
teamLeadName,
|
|
|
|
|
inviteUrl
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
await sendStyledNotificationEmail(
|
|
|
|
|
user.email,
|
|
|
|
|
user.name || input.name,
|
|
|
|
|
'TEAM_INVITATION',
|
|
|
|
|
{
|
|
|
|
|
title: 'You were added to a project team',
|
|
|
|
|
message: `${teamLeadName} added you to the project "${project.title}".`,
|
|
|
|
|
linkUrl: `${baseUrl}/applicant/team`,
|
|
|
|
|
linkLabel: 'Open Team',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
projectName: project.title,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
`You've been added to "${project.title}"`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
try {
|
|
|
|
|
await ctx.prisma.notificationLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
channel: 'EMAIL',
|
|
|
|
|
provider: 'SMTP',
|
|
|
|
|
type: 'TEAM_INVITATION',
|
|
|
|
|
status: 'FAILED',
|
|
|
|
|
errorMsg: error instanceof Error ? error.message : 'Unknown error',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
// Never fail on notification logging
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'INTERNAL_SERVER_ERROR',
|
|
|
|
|
message: 'Failed to send invitation email. Please try again.',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
// Create team membership
|
|
|
|
|
const teamMember = await ctx.prisma.teamMember.create({
|
|
|
|
|
data: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
userId: user.id,
|
|
|
|
|
role: input.role,
|
|
|
|
|
title: input.title,
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: { id: true, name: true, email: true, status: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-11 13:20:52 +01:00
|
|
|
try {
|
|
|
|
|
await ctx.prisma.notificationLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
channel: 'EMAIL',
|
|
|
|
|
provider: 'SMTP',
|
|
|
|
|
type: 'TEAM_INVITATION',
|
|
|
|
|
status: 'SENT',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
// Never fail on notification logging
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await createNotification({
|
|
|
|
|
userId: user.id,
|
|
|
|
|
type: 'TEAM_INVITATION',
|
|
|
|
|
title: 'Team Invitation',
|
|
|
|
|
message: `${teamLeadName} added you to "${project.title}"`,
|
|
|
|
|
linkUrl: '/applicant/team',
|
|
|
|
|
linkLabel: 'View Team',
|
|
|
|
|
priority: 'normal',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
projectName: project.title,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
// Never fail invitation flow on in-app notification issues
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-11 13:20:52 +01:00
|
|
|
return {
|
|
|
|
|
teamMember,
|
|
|
|
|
inviteEmailSent: true,
|
|
|
|
|
requiresAccountSetup,
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove a team member
|
|
|
|
|
*/
|
|
|
|
|
removeTeamMember: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
userId: z.string(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Verify user is team lead
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
role: 'LEAD',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only team leads can remove members',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Can't remove the original submitter
|
|
|
|
|
if (project.submittedByUserId === input.userId) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Cannot remove the original applicant from the team',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.teamMember.deleteMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
userId: input.userId,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true }
|
|
|
|
|
}),
|
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>
2026-02-05 21:58:27 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}),
|
Performance optimization, applicant portal, and missing DB migration
Performance:
- Convert admin dashboard from SSR to client-side tRPC (fixes 503/ChunkLoadError)
- New dashboard.getStats tRPC endpoint batches 16 queries into single response
- Parallelize jury dashboard queries (assignments + gracePeriods via Promise.all)
- Add project.getFullDetail combined endpoint (project + assignments + stats)
- Configure Prisma connection pool (connection_limit=20, pool_timeout=10)
- Add optimizePackageImports for lucide-react tree-shaking
- Increase React Query staleTime from 1min to 5min
Applicant portal:
- Add applicant layout, nav, dashboard, documents, team, and mentor pages
- Add applicant router with document and team management endpoints
- Add chunk error recovery utility
- Update role nav and auth redirect for applicant role
Database:
- Add migration for missing schema elements (SpecialAward job tracking
columns, WizardTemplate table, missing indexes)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:04:26 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the applicant's dashboard data: their project (latest edition),
|
|
|
|
|
* team members, open rounds for document submission, and status timeline.
|
|
|
|
|
*/
|
|
|
|
|
getMyDashboard: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only applicants can access this',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find the applicant's project (most recent, from active edition if possible)
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
round: {
|
|
|
|
|
include: {
|
|
|
|
|
program: { select: { id: true, name: true, year: true, status: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
files: {
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
},
|
|
|
|
|
teamMembers: {
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: { id: true, name: true, email: true, status: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { joinedAt: 'asc' },
|
|
|
|
|
},
|
|
|
|
|
submittedBy: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
mentorAssignment: {
|
|
|
|
|
include: {
|
|
|
|
|
mentor: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
wonAwards: {
|
|
|
|
|
select: { id: true, name: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
return { project: null, openRounds: [], timeline: [], currentStatus: null }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentStatus = project.status ?? 'SUBMITTED'
|
|
|
|
|
|
|
|
|
|
// Fetch status history
|
|
|
|
|
const statusHistory = await ctx.prisma.projectStatusHistory.findMany({
|
|
|
|
|
where: { projectId: project.id },
|
|
|
|
|
orderBy: { changedAt: 'asc' },
|
|
|
|
|
select: { status: true, changedAt: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
const timeline = [
|
|
|
|
|
{
|
|
|
|
|
status: 'CREATED',
|
|
|
|
|
label: 'Application Started',
|
|
|
|
|
date: project.createdAt,
|
|
|
|
|
completed: true,
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
status: 'SUBMITTED',
|
|
|
|
|
label: 'Application Submitted',
|
|
|
|
|
date: project.submittedAt || statusDateMap.get('SUBMITTED') || null,
|
|
|
|
|
completed: !!project.submittedAt || statusDateMap.has('SUBMITTED'),
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
status: 'UNDER_REVIEW',
|
|
|
|
|
label: 'Under Review',
|
|
|
|
|
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) {
|
|
|
|
|
timeline.push({
|
|
|
|
|
status: 'REJECTED',
|
|
|
|
|
label: 'Not Selected',
|
|
|
|
|
date: statusDateMap.get('REJECTED') || null,
|
|
|
|
|
completed: true,
|
|
|
|
|
isTerminal: true,
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find open rounds in the same program where documents can be submitted
|
|
|
|
|
const programId = project.round?.programId || project.programId
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
|
|
|
|
const openRounds = programId
|
|
|
|
|
? await ctx.prisma.round.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
programId,
|
|
|
|
|
status: 'ACTIVE',
|
|
|
|
|
},
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
: []
|
|
|
|
|
|
|
|
|
|
// Filter: only rounds that still accept uploads
|
|
|
|
|
const uploadableRounds = openRounds.filter((round) => {
|
|
|
|
|
const settings = round.settingsJson as Record<string, unknown> | null
|
|
|
|
|
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
|
|
|
|
|
const roundStarted = round.votingStartAt && now > round.votingStartAt
|
|
|
|
|
|
|
|
|
|
// If deadline passed and policy is BLOCK, skip
|
|
|
|
|
if (roundStarted && uploadPolicy === 'BLOCK') return false
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Determine user's role in the project
|
|
|
|
|
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
|
|
|
|
const isTeamLead = project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD'
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
project: {
|
|
|
|
|
...project,
|
|
|
|
|
isTeamLead,
|
|
|
|
|
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
|
|
|
|
|
},
|
|
|
|
|
openRounds: uploadableRounds,
|
|
|
|
|
timeline,
|
|
|
|
|
currentStatus,
|
|
|
|
|
}
|
|
|
|
|
}),
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|