import crypto from 'crypto' import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, publicProcedure, protectedProcedure } from '../trpc' import { getPresignedUrl } from '@/lib/minio' import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email' import { logAudit } from '@/server/utils/audit' import { createNotification } from '../services/in-app-notification' // 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 stage = await ctx.prisma.stage.findFirst({ where: { slug: input.slug }, include: { track: { include: { pipeline: { include: { program: { select: { id: true, name: true, year: true, description: true } }, }, }, }, }, }, }) if (!stage) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Stage not found', }) } const now = new Date() const isOpen = stage.windowCloseAt ? now < stage.windowCloseAt : stage.status === 'STAGE_ACTIVE' return { stage: { id: stage.id, name: stage.name, slug: stage.slug, windowCloseAt: stage.windowCloseAt, isOpen, }, program: stage.track.pipeline.program, } }), /** * Get the current user's submission for a round (as submitter or team member) */ getMySubmission: protectedProcedure .input(z.object({ stageId: 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.stageId) { where.stageStates = { some: { stageId: input.stageId } } } 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']), stageId: 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', }) } 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, isLate, stageId: input.stageId || 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(), stageId: 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, stageId, 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 (roundId column kept null for new data) const file = await ctx.prisma.projectFile.create({ data: { projectId, ...fileData, roundId: null, isLate: isLate || false, requirementId: requirementId || null, }, }) 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 || '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.', }) } // 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, openStages: [], 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 openStages = programId ? await ctx.prisma.stage.findMany({ where: { track: { pipeline: { programId } }, status: 'STAGE_ACTIVE', }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, slug: true, stageType: true, windowOpenAt: 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' return { project: { ...project, isTeamLead, userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null), }, openStages, timeline, currentStatus, } }), })