import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, publicProcedure, protectedProcedure } from '../trpc' import { getPresignedUrl } from '@/lib/minio' // Bucket for applicant submissions export const SUBMISSIONS_BUCKET = 'mopc-submissions' export const applicantRouter = router({ /** * Get submission info for an applicant (by round slug) */ getSubmissionBySlug: publicProcedure .input(z.object({ slug: z.string() })) .query(async ({ ctx, input }) => { // Find the round by slug const round = await ctx.prisma.round.findFirst({ where: { slug: input.slug }, include: { program: { select: { id: true, name: true, year: true, description: true } }, }, }) if (!round) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found', }) } // Check if submissions are open const now = new Date() const isOpen = round.submissionDeadline ? now < round.submissionDeadline : round.status === 'ACTIVE' return { round: { id: round.id, name: round.name, slug: round.slug, submissionDeadline: round.submissionDeadline, isOpen, }, program: round.program, } }), /** * Get the current user's submission for a round (as submitter or team member) */ getMySubmission: protectedProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { // Only applicants can use this if (ctx.user.role !== 'APPLICANT') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access submissions', }) } const project = await ctx.prisma.project.findFirst({ where: { roundId: input.roundId, OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id }, }, }, ], }, include: { files: true, round: { include: { program: { select: { 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({ roundId: z.string(), projectId: z.string().optional(), // If updating existing title: z.string().min(1).max(500), teamName: z.string().optional(), description: z.string().optional(), tags: z.array(z.string()).optional(), metadataJson: z.record(z.unknown()).optional(), submit: z.boolean().default(false), // Whether to submit or just save draft }) ) .mutation(async ({ ctx, input }) => { // Only applicants can use this if (ctx.user.role !== 'APPLICANT') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can submit projects', }) } // Check if the round is open for submissions const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, }) const now = new Date() if (round.submissionDeadline && now > round.submissionDeadline) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Submission deadline has passed', }) } const { projectId, submit, roundId, metadataJson, ...data } = input if (projectId) { // Update existing const existing = await ctx.prisma.project.findFirst({ where: { id: projectId, submittedByUserId: ctx.user.id, }, }) if (!existing) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found or you do not have access', }) } // Can't update if already submitted if (existing.submittedAt && !submit) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot modify a submitted project', }) } const project = await ctx.prisma.project.update({ where: { id: projectId }, data: { ...data, metadataJson: metadataJson as unknown ?? undefined, submittedAt: submit && !existing.submittedAt ? now : existing.submittedAt, status: submit ? 'SUBMITTED' : existing.status, }, }) return project } else { // Create new const project = await ctx.prisma.project.create({ data: { roundId, ...data, metadataJson: metadataJson as unknown ?? undefined, submittedByUserId: ctx.user.id, submittedByEmail: ctx.user.email, submissionSource: 'MANUAL', status: 'SUBMITTED', submittedAt: submit ? now : null, }, }) // Audit log await ctx.prisma.auditLog.create({ data: { 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']), }) ) .mutation(async ({ ctx, input }) => { // Only applicants can use this if (ctx.user.role !== 'APPLICANT') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can upload files', }) } // Verify project ownership const project = await ctx.prisma.project.findFirst({ where: { id: input.projectId, submittedByUserId: ctx.user.id, }, }) if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found or you do not have access', }) } // Can't upload if already submitted if (project.submittedAt) { 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, } }), /** * 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(), }) ) .mutation(async ({ ctx, input }) => { // Only applicants can use this if (ctx.user.role !== 'APPLICANT') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can save files', }) } // Verify project ownership const project = await ctx.prisma.project.findFirst({ where: { id: input.projectId, submittedByUserId: ctx.user.id, }, }) if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found or you do not have access', }) } const { projectId, ...fileData } = input // Delete existing file of same type if exists await ctx.prisma.projectFile.deleteMany({ where: { projectId, fileType: input.fileType, }, }) // Create new file record const file = await ctx.prisma.projectFile.create({ data: { projectId, ...fileData, }, }) return file }), /** * Delete a file from submission */ deleteFile: protectedProcedure .input(z.object({ fileId: z.string() })) .mutation(async ({ ctx, input }) => { // Only applicants can use this if (ctx.user.role !== 'APPLICANT') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can delete files', }) } const file = await ctx.prisma.projectFile.findUniqueOrThrow({ where: { id: input.fileId }, include: { project: true }, }) // Verify ownership if (file.project.submittedByUserId !== ctx.user.id) { 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 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: { round: { include: { program: { select: { name: true, year: true } }, }, }, files: true, teamMembers: { include: { user: { select: { id: true, name: true, email: true }, }, }, }, }, }) if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found', }) } // Build timeline const timeline = [ { status: 'CREATED', label: 'Application Started', date: project.createdAt, completed: true, }, { status: 'SUBMITTED', label: 'Application Submitted', date: project.submittedAt, completed: !!project.submittedAt, }, { status: 'UNDER_REVIEW', label: 'Under Review', date: project.status === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null, completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status), }, { status: 'SEMIFINALIST', label: 'Semi-finalist', date: null, // Would need status change tracking completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status), }, { status: 'FINALIST', label: 'Finalist', date: null, completed: ['FINALIST', 'WINNER'].includes(project.status), }, ] return { project, timeline, currentStatus: project.status, } }), /** * 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: { round: { include: { program: { select: { 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 }) => { // 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: input.email }, }, }) 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: input.email }, }) if (!user) { user = await ctx.prisma.user.create({ data: { email: input.email, name: input.name, role: 'APPLICANT', status: 'INVITED', }, }) } // 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 }, }, }, }) // TODO: Send invitation email to the new team member return teamMember }), /** * 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 } }), })