import crypto from 'crypto' import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, publicProcedure, protectedProcedure } from '../trpc' import { getPresignedUrl, generateObjectKey } from '@/lib/minio' import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email' import { logAudit } from '@/server/utils/audit' import { createNotification } from '../services/in-app-notification' import { checkRequirementsAndTransition } from '../services/round-engine' import { EvaluationConfigSchema } from '@/types/competition-configs' import type { Prisma } from '@prisma/client' // Bucket for applicant submissions export const SUBMISSIONS_BUCKET = 'mopc-submissions' const TEAM_INVITE_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000 // 30 days function generateInviteToken(): string { return crypto.randomBytes(32).toString('hex') } 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 }) => { const round = await ctx.prisma.round.findFirst({ where: { slug: input.slug }, include: { competition: { include: { program: { select: { id: true, name: true, year: true, description: true } }, }, }, }, }) if (!round) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found', }) } const now = new Date() const isOpen = round.status === 'ROUND_ACTIVE' return { stage: { id: round.id, name: round.name, slug: round.slug, windowCloseAt: null, isOpen, }, program: round.competition.program, } }), /** * Get the current user's submission for a round (as submitter or team member) */ getMySubmission: protectedProcedure .input(z.object({ roundId: z.string().optional(), programId: z.string().optional() })) .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 where: Record = { OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id }, }, }, ], } if (input.roundId) { where.roundAssignments = { some: { roundId: input.roundId } } } if (input.programId) { where.programId = input.programId } const project = await ctx.prisma.project.findFirst({ where, include: { files: true, program: { select: { id: true, name: true, year: true } }, 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({ programId: z.string().optional(), projectId: z.string().optional(), 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), }) ) .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', }) } const now = new Date() const { projectId, submit, programId, 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, }, }) // Update Project status if submitting if (submit) { await ctx.prisma.project.update({ where: { id: projectId }, data: { status: 'SUBMITTED' }, }) } return project } else { if (!programId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'programId is required when creating a new submission', }) } // Create new project const project = await ctx.prisma.project.create({ data: { programId, ...data, metadataJson: metadataJson as unknown ?? undefined, submittedByUserId: ctx.user.id, submittedByEmail: ctx.user.email, submissionSource: 'MANUAL', submittedAt: submit ? now : null, status: 'SUBMITTED', }, }) // Audit log 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, }) 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']), roundId: z.string().optional(), requirementId: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { // Applicants or team members can upload if (ctx.user.role !== 'APPLICANT') { // 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 }, }) if (!teamMembership) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants or team members can upload files', }) } } // Verify project access (owner or team member) const project = await ctx.prisma.project.findFirst({ where: { id: input.projectId, OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }, ], }, }) if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found or you do not have access', }) } // 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(', ')}`, }) } } } let isLate = false // Can't upload if already submitted if (project.submittedAt && !isLate) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot modify a submitted project', }) } // Fetch round name for storage path (if uploading against a round) let roundName: string | undefined if (input.roundId) { const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { name: true }, }) roundName = round?.name } const objectKey = generateObjectKey(project.title, input.fileName, roundName) const url = await getPresignedUrl(SUBMISSIONS_BUCKET, objectKey, 'PUT', 3600) return { url, bucket: SUBMISSIONS_BUCKET, objectKey, isLate, roundId: input.roundId || null, } }), /** * 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(), roundId: z.string().optional(), isLate: z.boolean().optional(), requirementId: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { // Applicants or team members can save files if (ctx.user.role !== 'APPLICANT') { const teamMembership = await ctx.prisma.teamMember.findFirst({ where: { projectId: input.projectId, userId: ctx.user.id }, select: { id: true }, }) if (!teamMembership) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants or team members can save files', }) } } // Verify project access const project = await ctx.prisma.project.findFirst({ where: { id: input.projectId, OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }, ], }, }) if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found or you do not have access', }) } const { projectId, roundId, isLate, requirementId, ...fileData } = input // Delete existing file: by requirementId if provided, otherwise by fileType if (requirementId) { await ctx.prisma.projectFile.deleteMany({ where: { projectId, requirementId, }, }) } else { await ctx.prisma.projectFile.deleteMany({ where: { projectId, fileType: input.fileType, }, }) } // Create new file record const file = await ctx.prisma.projectFile.create({ data: { projectId, ...fileData, roundId: roundId || null, isLate: isLate || false, requirementId: requirementId || null, }, }) // Auto-transition: if uploading against a round requirement, check completion if (roundId && requirementId) { await checkRequirementsAndTransition( projectId, roundId, ctx.user.id, ctx.prisma, ) } // Auto-analyze document (fire-and-forget, delayed for presigned upload) import('../services/document-analyzer').then(({ analyzeFileDelayed }) => analyzeFileDelayed(file.id).catch((err) => console.warn('[DocAnalyzer] Post-upload analysis failed:', err)) ).catch(() => {}) 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 }, include: { project: { include: { teamMembers: { select: { userId: true } } } } }, }) // 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) { 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 } }), /** * 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 */ 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: { program: { select: { id: true, name: true, year: true } }, files: true, teamMembers: { include: { user: { select: { id: true, name: true, email: true }, }, }, }, wonAwards: { select: { id: true, name: true }, }, }, }) if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found', }) } // Get the project status const currentStatus = project.status ?? 'SUBMITTED' // 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() 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 || 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) { // 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, currentStatus, } }), /** * 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: { program: { select: { id: true, name: true, year: true } }, 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, } }), /** * 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 }) => { const normalizedEmail = input.email.trim().toLowerCase() // 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, user: { email: normalizedEmail }, }, }) 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({ where: { email: normalizedEmail }, }) if (!user) { user = await ctx.prisma.user.create({ data: { email: normalizedEmail, name: input.name, role: 'APPLICANT', status: 'NONE', }, }) } 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 || 'https://portal.monaco-opc.com' 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.', }) } // 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 }, }, }, }) 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 } return { teamMember, inviteEmailSent: true, requiresAccountSetup, } }), /** * 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 } }), /** * 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 }), /** * 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: { 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() 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, }) } } const programId = project.programId const openRounds = programId ? await ctx.prisma.round.findMany({ where: { competition: { programId }, status: 'ROUND_ACTIVE', }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, slug: true, windowCloseAt: 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' // 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, isTeamLead, userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null), }, 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 = { 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 }), })