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 { generateLogoKey, createStorageProvider, type StorageProviderType } from '@/lib/storage' import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload' import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email' import { logAudit } from '@/server/utils/audit' import { createNotification } from '../services/in-app-notification' import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine' import { EvaluationConfigSchema, MentoringConfigSchema } 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: mark as IN_PROGRESS on file activity, then check completion if (roundId) { await triggerInProgressOnActivity(projectId, roundId, ctx.user.id, ctx.prisma) if (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, profileImageKey: true, profileImageProvider: 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', }) } // Generate presigned avatar URLs for team members with profile images const avatarUrls: Record = {} for (const member of project.teamMembers) { if (member.user.profileImageKey) { const providerType = (member.user.profileImageProvider as StorageProviderType) || 's3' const provider = createStorageProvider(providerType) avatarUrls[member.userId] = await provider.getDownloadUrl(member.user.profileImageKey) } } return { teamMembers: project.teamMembers, submittedBy: project.submittedBy, avatarUrls, } }), /** * 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(), nationality: z.string().optional(), country: z.string().optional(), institution: z.string().optional(), sendInvite: z.boolean().default(true), }) ) .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', roles: ['APPLICANT'], status: 'NONE', nationality: input.nationality, country: input.country, institution: input.institution, }, }) } else { // Update existing user with new profile fields if provided const profileUpdates: Record = {} if (input.nationality && !user.nationality) profileUpdates.nationality = input.nationality if (input.country && !user.country) profileUpdates.country = input.country if (input.institution && !user.institution) profileUpdates.institution = input.institution if (Object.keys(profileUpdates).length > 0) { user = await ctx.prisma.user.update({ where: { id: user.id }, data: profileUpdates, }) } } 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' // If sendInvite is false, skip email entirely and leave user as NONE if (!input.sendInvite) { // No email, no status change — just create team membership below } else 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 let openRounds: Array<{ id: string; name: string; slug: string | null; roundType: string; windowCloseAt: Date | null }> = [] if (programId) { const allActiveRounds = await ctx.prisma.round.findMany({ where: { competition: { programId }, status: 'ROUND_ACTIVE', }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, slug: true, roundType: true, windowCloseAt: true, specialAwardId: true, specialAward: { select: { name: true } }, }, }) // Filter rounds based on award track: only show rounds the project is actually in const projectRoundIds = new Set( (await ctx.prisma.projectRoundState.findMany({ where: { projectId: project.id }, select: { roundId: true }, })).map((prs) => prs.roundId) ) const isInAwardTrack = allActiveRounds.some( (r) => r.specialAwardId && projectRoundIds.has(r.id) ) openRounds = allActiveRounds .filter((r) => { // Award round project isn't in → hide if (r.specialAwardId && !projectRoundIds.has(r.id)) return false // Main round when project is in award track and has no state in this round → hide if (!r.specialAwardId && isInAwardTrack && !projectRoundIds.has(r.id)) return false return true }) .map((r) => ({ id: r.id, name: r.specialAward ? `${r.specialAward.name}: ${r.name}` : r.name, slug: r.slug, roundType: r.roundType, windowCloseAt: r.windowCloseAt, })) } // 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 }, }) // Check if there is an active intake round (applicants can edit project details during intake) const activeIntakeRound = await ctx.prisma.round.findFirst({ where: { competition: { programId: project.programId }, roundType: 'INTAKE', status: 'ROUND_ACTIVE', }, select: { id: true }, }) // Generate presigned logo URL if the project has a logo let logoUrl: string | null = null if (project.logoKey) { const providerType = (project.logoProvider as StorageProviderType) || 's3' const provider = createStorageProvider(providerType) logoUrl = await provider.getDownloadUrl(project.logoKey) } return { project: { ...project, isTeamLead, userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null), }, openRounds, timeline, currentStatus, hasPassedIntake: !!passedIntake, isIntakeOpen: !!activeIntakeRound, logoUrl, } }), /** * 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 // Only consider rounds the project actually participated in (award track filtering) let hasEvaluationRounds = false if (project.programId) { const projectRoundIds = new Set( (await ctx.prisma.projectRoundState.findMany({ where: { projectId: project.id }, select: { roundId: true }, })).map((prs) => prs.roundId) ) const closedEvalRounds = projectRoundIds.size > 0 ? await ctx.prisma.round.findMany({ where: { competition: { programId: project.programId }, roundType: 'EVALUATION', status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] }, id: { in: [...projectRoundIds] }, }, 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 (including award rounds in same competition) 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, specialAwardId: true, specialAward: { select: { name: 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: string status: string windowOpenAt: Date | null windowCloseAt: Date | null projectState: string | null isSynthesizedRejection: boolean } const entries: TimelineEntry[] = [] // Build lookup for filtering rounds const filteringRounds = rounds.filter((r) => r.roundType === 'FILTERING') const liveFinalRounds = rounds.filter((r) => r.roundType === 'LIVE_FINAL') const deliberationRounds = rounds.filter((r) => r.roundType === 'DELIBERATION') // Check if this project is in any SEPARATE_POOL award track const projectAwardRoundIds = new Set( rounds.filter((r) => r.specialAwardId && stateMap.has(r.id)).map((r) => r.id) ) const projectAwardIds = new Set( rounds.filter((r) => r.specialAwardId && stateMap.has(r.id)).map((r) => r.specialAwardId!) ) const isInAwardTrack = projectAwardRoundIds.size > 0 // Process visible rounds: hide FILTERING, LIVE_FINAL, DELIBERATION always. // Also hide MENTORING unless the project is actually participating in it. // For award rounds: only show ones the project is in. For main rounds after // the split point: hide if project isn't in them and is in an award track. const visibleRounds = rounds.filter( (r) => { if (r.roundType === 'FILTERING' || r.roundType === 'LIVE_FINAL' || r.roundType === 'DELIBERATION') return false if (r.roundType === 'MENTORING' && !stateMap.has(r.id)) return false // Award round that project is NOT in → hide if (r.specialAwardId && !stateMap.has(r.id)) return false // Award round for a different award → hide if (r.specialAwardId && !projectAwardIds.has(r.specialAwardId)) return false // Main competition round where project has no state AND project is in award track → hide if (!r.specialAwardId && isInAwardTrack && !stateMap.has(r.id)) return false return true } ) for (const round of visibleRounds) { const actualState = stateMap.get(round.id) ?? null // Check if a FILTERING round before this round rejected the project let projectState = actualState let isSynthesizedRejection = false const roundSortOrder = rounds.findIndex((r) => r.id === round.id) const precedingFilterRounds = filteringRounds.filter((fr) => { const frIdx = rounds.findIndex((r) => r.id === fr.id) return frIdx < roundSortOrder }) 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: round.id, label: round.specialAward ? `${round.specialAward.name}: ${round.name}` : round.name, roundType: round.roundType, status: round.status, windowOpenAt: round.windowOpenAt, windowCloseAt: round.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 — only ones this project participated in const projectRoundIds = new Set( (await ctx.prisma.projectRoundState.findMany({ where: { projectId: project.id }, select: { roundId: true }, })).map((prs) => prs.roundId) ) if (projectRoundIds.size === 0) return [] const evalRounds = await ctx.prisma.round.findMany({ where: { competition: { programId: project.programId }, roundType: 'EVALUATION', status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] }, id: { in: [...projectRoundIds] }, }, 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 (let i = 0; i < evalRounds.length; i++) { const round = evalRounds[i] 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' }, }) // Mask round names: "Evaluation Round 1", "Evaluation Round 2", etc. const maskedName = `Evaluation Round ${i + 1}` results.push({ roundId: round.id, roundName: maskedName, 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: { id: true, 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, specialAwardId: true, specialAward: { select: { name: true } }, }, orderBy: { windowCloseAt: 'asc' }, }) // Filter by award track membership const projectStates = await ctx.prisma.projectRoundState.findMany({ where: { projectId: project.id }, select: { roundId: true, round: { select: { specialAwardId: true } } }, }) const projectRoundIds = new Set(projectStates.map((prs) => prs.roundId)) const isInAwardTrack = projectStates.some((prs) => prs.round.specialAwardId) return rounds .filter((r) => { if (r.specialAwardId && !projectRoundIds.has(r.id)) return false if (!r.specialAwardId && isInAwardTrack && !projectRoundIds.has(r.id)) return false return true }) .map((r) => ({ roundName: r.specialAward ? `${r.specialAward.name}: ${r.name}` : 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 allRounds = await ctx.prisma.round.findMany({ where: { competition: { programId: project.programId }, status: 'ROUND_ACTIVE', fileRequirements: { some: {} }, }, select: { id: true, name: true, specialAwardId: true, specialAward: { select: { name: true } }, fileRequirements: { select: { id: true }, }, }, orderBy: { sortOrder: 'asc' }, }) // Filter by award track membership const projectRoundIds = new Set( (await ctx.prisma.projectRoundState.findMany({ where: { projectId: project.id }, select: { roundId: true }, })).map((prs) => prs.roundId) ) const isInAwardTrack = allRounds.some( (r) => r.specialAwardId && projectRoundIds.has(r.id) ) const rounds = allRounds.filter((r) => { if (r.specialAwardId && !projectRoundIds.has(r.id)) return false if (!r.specialAwardId && isInAwardTrack && !projectRoundIds.has(r.id)) return false return true }) 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.specialAward ? `${round.specialAward.name}: ${round.name}` : round.name, required: requirementIds.length, uploaded, }) } return results }), /** * Get onboarding context for applicant wizard — project info, institution, logo status. */ getOnboardingContext: protectedProcedure.query(async ({ ctx }) => { if (ctx.user.role !== 'APPLICANT') { return null } const project = await ctx.prisma.project.findFirst({ where: { OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }, ], }, select: { id: true, title: true, institution: true, logoKey: true, }, }) if (!project) return null return { projectId: project.id, projectTitle: project.title, institution: project.institution, hasLogo: !!project.logoKey, } }), /** * Get a pre-signed URL for uploading a project logo (applicant access). */ getProjectLogoUploadUrl: protectedProcedure .input( z.object({ projectId: z.string(), fileName: z.string(), contentType: z.string(), }) ) .mutation(async ({ ctx, input }) => { // Verify team membership const isMember = await ctx.prisma.project.findFirst({ where: { id: input.projectId, OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }, ], }, select: { id: true }, }) if (!isMember) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' }) } return getImageUploadUrl( input.projectId, input.fileName, input.contentType, generateLogoKey ) }), /** * Confirm project logo upload (applicant access). */ confirmProjectLogo: protectedProcedure .input( z.object({ projectId: z.string(), key: z.string(), providerType: z.enum(['s3', 'local']), }) ) .mutation(async ({ ctx, input }) => { // Verify team membership const isMember = await ctx.prisma.project.findFirst({ where: { id: input.projectId, OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }, ], }, select: { id: true }, }) if (!isMember) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' }) } const logoConfig: ImageUploadConfig<{ logoKey: string | null; logoProvider: string | null }> = { label: 'logo', generateKey: generateLogoKey, findCurrent: (prisma, entityId) => prisma.project.findUnique({ where: { id: entityId }, select: { logoKey: true, logoProvider: true }, }), getImageKey: (record) => record.logoKey, getProviderType: (record) => (record.logoProvider as StorageProviderType) || 's3', setImage: (prisma, entityId, key, providerType) => prisma.project.update({ where: { id: entityId }, data: { logoKey: key, logoProvider: providerType }, }), clearImage: (prisma, entityId) => prisma.project.update({ where: { id: entityId }, data: { logoKey: null, logoProvider: null }, }), auditEntityType: 'Project', auditFieldName: 'logoKey', } await confirmImageUpload(ctx.prisma, logoConfig, input.projectId, input.key, input.providerType, { userId: ctx.user.id, ip: ctx.ip, userAgent: ctx.userAgent, }) return { success: true } }), /** * Delete project logo (applicant access). */ deleteProjectLogo: protectedProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ ctx, input }) => { const isMember = await ctx.prisma.project.findFirst({ where: { id: input.projectId, OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }, ], }, select: { id: true }, }) if (!isMember) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' }) } const logoConfig: ImageUploadConfig<{ logoKey: string | null; logoProvider: string | null }> = { label: 'logo', generateKey: generateLogoKey, findCurrent: (prisma, entityId) => prisma.project.findUnique({ where: { id: entityId }, select: { logoKey: true, logoProvider: true }, }), getImageKey: (record) => record.logoKey, getProviderType: (record) => (record.logoProvider as StorageProviderType) || 's3', setImage: (prisma, entityId, key, providerType) => prisma.project.update({ where: { id: entityId }, data: { logoKey: key, logoProvider: providerType }, }), clearImage: (prisma, entityId) => prisma.project.update({ where: { id: entityId }, data: { logoKey: null, logoProvider: null }, }), auditEntityType: 'Project', auditFieldName: 'logoKey', } return deleteImage(ctx.prisma, logoConfig, input.projectId, { userId: ctx.user.id, ip: ctx.ip, userAgent: ctx.userAgent, }) }), /** * Get project logo URL (applicant access). */ getProjectLogoUrl: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const logoConfig = { findCurrent: (prisma: typeof ctx.prisma, entityId: string) => prisma.project.findUnique({ where: { id: entityId }, select: { logoKey: true, logoProvider: true }, }), getImageKey: (record: { logoKey: string | null }) => record.logoKey, getProviderType: (record: { logoProvider: string | null }) => (record.logoProvider as StorageProviderType) || 's3' as StorageProviderType, } return getImageUrl(ctx.prisma, logoConfig, input.projectId) }), /** * Withdraw from competition. Only team lead can withdraw. * Finds the current active (non-terminal) ProjectRoundState and transitions to WITHDRAWN. */ /** * Get mentoring request status for a project in a MENTORING round */ getMentoringRequestStatus: protectedProcedure .input(z.object({ projectId: z.string(), roundId: z.string() })) .query(async ({ ctx, input }) => { if (ctx.user.role !== 'APPLICANT') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' }) } const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { id: true, roundType: true, status: true, configJson: true, windowOpenAt: true }, }) if (!round || round.roundType !== 'MENTORING') { return { available: false, requested: false, requestedAt: null, deadline: null, canStillRequest: false } } const config = MentoringConfigSchema.safeParse(round.configJson) const deadlineDays = config.success ? config.data.mentoringRequestDeadlineDays : 14 const deadline = round.windowOpenAt ? new Date(new Date(round.windowOpenAt).getTime() + deadlineDays * 24 * 60 * 60 * 1000) : null const canStillRequest = round.status === 'ROUND_ACTIVE' && (!deadline || new Date() < deadline) const prs = await ctx.prisma.projectRoundState.findUnique({ where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } }, select: { metadataJson: true }, }) const metadata = (prs?.metadataJson as Record) ?? {} const requested = !!metadata.mentoringRequested const requestedAt = metadata.mentoringRequestedAt ? new Date(metadata.mentoringRequestedAt as string) : null return { available: true, requested, requestedAt, deadline, canStillRequest } }), /** * Request or cancel mentoring for the current MENTORING round */ requestMentoring: protectedProcedure .input(z.object({ projectId: z.string(), roundId: z.string(), requesting: z.boolean() })) .mutation(async ({ ctx, input }) => { if (ctx.user.role !== 'APPLICANT') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can request mentoring' }) } // Verify caller is team lead const project = await ctx.prisma.project.findUnique({ where: { id: input.projectId }, select: { id: true, submittedByUserId: true, title: true }, }) if (!project) throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' }) if (project.submittedByUserId !== ctx.user.id) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the team lead can request mentoring' }) } // Verify round is MENTORING and ACTIVE const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { id: true, roundType: true, status: true, configJson: true, windowOpenAt: true }, }) if (!round || round.roundType !== 'MENTORING') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a mentoring round' }) } if (round.status !== 'ROUND_ACTIVE') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Mentoring round is not active' }) } // Check deadline const config = MentoringConfigSchema.safeParse(round.configJson) const deadlineDays = config.success ? config.data.mentoringRequestDeadlineDays : 14 if (round.windowOpenAt) { const deadline = new Date(new Date(round.windowOpenAt).getTime() + deadlineDays * 24 * 60 * 60 * 1000) if (new Date() > deadline) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Mentoring request window has closed' }) } } // Find PRS const prs = await ctx.prisma.projectRoundState.findUnique({ where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } }, }) if (!prs) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Project is not assigned to this round' }) } const existingMeta = (prs.metadataJson as Record) ?? {} // Update metadataJson with mentoring request info await ctx.prisma.projectRoundState.update({ where: { id: prs.id }, data: { metadataJson: { ...existingMeta, mentoringRequested: input.requesting, mentoringRequestedAt: input.requesting ? new Date().toISOString() : null, }, }, }) // If requesting mentoring and currently PASSED (pass-through), transition to IN_PROGRESS if (input.requesting && prs.state === 'PASSED') { await transitionProject( input.projectId, input.roundId, 'IN_PROGRESS' as Parameters[2], ctx.user.id, ctx.prisma, ) } await logAudit({ prisma: ctx.prisma, action: input.requesting ? 'MENTORING_REQUESTED' : 'MENTORING_CANCELLED', entityType: 'Project', entityId: input.projectId, userId: ctx.user.id, detailsJson: { roundId: input.roundId, projectTitle: project.title }, }) return { success: true, requesting: input.requesting } }), withdrawFromCompetition: protectedProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ ctx, input }) => { if (ctx.user.role !== 'APPLICANT') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can withdraw' }) } // Verify caller is team lead (submittedByUserId) const project = await ctx.prisma.project.findUnique({ where: { id: input.projectId }, select: { id: true, submittedByUserId: true, title: true }, }) if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' }) } if (project.submittedByUserId !== ctx.user.id) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the team lead can withdraw from the competition' }) } // Find the active (non-terminal) ProjectRoundState const activePrs = await ctx.prisma.projectRoundState.findFirst({ where: { projectId: input.projectId, round: { status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } }, }, include: { round: { select: { id: true, name: true } } }, orderBy: { round: { sortOrder: 'desc' } }, }) if (!activePrs || isTerminalState(activePrs.state as Parameters[0])) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active round participation to withdraw from' }) } const result = await transitionProject( input.projectId, activePrs.roundId, 'WITHDRAWN' as Parameters[2], ctx.user.id, ctx.prisma, ) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to withdraw', }) } // Audit log await logAudit({ prisma: ctx.prisma, action: 'WITHDRAWAL', entityType: 'Project', entityId: input.projectId, userId: ctx.user.id, detailsJson: { roundId: activePrs.roundId, roundName: activePrs.round.name, projectTitle: project.title }, }) return { success: true, roundId: activePrs.roundId, roundName: activePrs.round.name } }), })