import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure } from '../trpc' import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio' import { logAudit } from '../utils/audit' export const fileRouter = router({ /** * Get pre-signed download URL * Checks that the user is authorized to access the file's project */ getDownloadUrl: protectedProcedure .input( z.object({ bucket: z.string(), objectKey: z.string(), }) ) .query(async ({ ctx, input }) => { const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) if (!isAdmin) { const file = await ctx.prisma.projectFile.findFirst({ where: { bucket: input.bucket, objectKey: input.objectKey }, select: { projectId: true, }, }) if (!file) { throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found', }) } const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([ ctx.prisma.assignment.findFirst({ where: { userId: ctx.user.id, projectId: file.projectId }, select: { id: true, stageId: true }, }), ctx.prisma.mentorAssignment.findFirst({ where: { mentorId: ctx.user.id, projectId: file.projectId }, select: { id: true }, }), ctx.prisma.project.findFirst({ where: { id: file.projectId, OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }, ], }, select: { id: true }, }), ]) if (!juryAssignment && !mentorAssignment && !teamMembership) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to this file', }) } if (juryAssignment && !mentorAssignment && !teamMembership) { const assignedStage = await ctx.prisma.stage.findUnique({ where: { id: juryAssignment.stageId }, select: { trackId: true, sortOrder: true }, }) if (assignedStage) { const priorOrCurrentStages = await ctx.prisma.stage.findMany({ where: { trackId: assignedStage.trackId, sortOrder: { lte: assignedStage.sortOrder }, }, select: { id: true }, }) const stageIds = priorOrCurrentStages.map((s) => s.id) const hasFileRequirement = await ctx.prisma.fileRequirement.findFirst({ where: { stageId: { in: stageIds }, files: { some: { bucket: input.bucket, objectKey: input.objectKey } }, }, select: { id: true }, }) if (!hasFileRequirement) { const fileInProject = await ctx.prisma.projectFile.findFirst({ where: { bucket: input.bucket, objectKey: input.objectKey, requirementId: null, }, select: { id: true }, }) if (!fileInProject) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to this file', }) } } } } } const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min // Log file access await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'FILE_DOWNLOADED', entityType: 'ProjectFile', detailsJson: { bucket: input.bucket, objectKey: input.objectKey }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { url } }), /** * Get pre-signed upload URL (admin only) */ getUploadUrl: adminProcedure .input( z.object({ projectId: z.string(), fileName: z.string(), fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']), mimeType: z.string(), size: z.number().int().positive(), stageId: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { // Block dangerous file extensions const dangerousExtensions = ['.exe', '.sh', '.bat', '.cmd', '.ps1', '.php', '.jsp', '.cgi', '.dll', '.msi'] const ext = input.fileName.toLowerCase().slice(input.fileName.lastIndexOf('.')) if (dangerousExtensions.includes(ext)) { throw new TRPCError({ code: 'BAD_REQUEST', message: `File type "${ext}" is not allowed`, }) } let isLate = false if (input.stageId) { const stage = await ctx.prisma.stage.findUnique({ where: { id: input.stageId }, select: { windowCloseAt: true }, }) if (stage?.windowCloseAt) { isLate = new Date() > stage.windowCloseAt } } const bucket = BUCKET_NAME const objectKey = generateObjectKey(input.projectId, input.fileName) const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600) // 1 hour // Create file record const file = await ctx.prisma.projectFile.create({ data: { projectId: input.projectId, fileType: input.fileType, fileName: input.fileName, mimeType: input.mimeType, size: input.size, bucket, objectKey, isLate, }, }) // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPLOAD_FILE', entityType: 'ProjectFile', entityId: file.id, detailsJson: { projectId: input.projectId, fileName: input.fileName, fileType: input.fileType, stageId: input.stageId, isLate, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { uploadUrl, file, } }), /** * Confirm file upload completed */ confirmUpload: adminProcedure .input(z.object({ fileId: z.string() })) .mutation(async ({ ctx, input }) => { // In the future, we could verify the file exists in MinIO // For now, just return the file return ctx.prisma.projectFile.findUniqueOrThrow({ where: { id: input.fileId }, }) }), /** * Delete file (admin only) */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const file = await ctx.prisma.projectFile.delete({ where: { id: input.id }, }) // Delete actual storage object (best-effort, don't fail the operation) try { if (file.bucket && file.objectKey) { await deleteObject(file.bucket, file.objectKey) } } catch (error) { console.error(`[File] Failed to delete storage object ${file.objectKey}:`, error) } // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'DELETE_FILE', entityType: 'ProjectFile', entityId: input.id, detailsJson: { fileName: file.fileName, bucket: file.bucket, objectKey: file.objectKey, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return file }), /** * List files for a project * Checks that the user is authorized to view the project's files */ listByProject: protectedProcedure .input(z.object({ projectId: z.string(), stageId: z.string().optional(), })) .query(async ({ ctx, input }) => { const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) if (!isAdmin) { const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([ ctx.prisma.assignment.findFirst({ where: { userId: ctx.user.id, projectId: input.projectId }, select: { id: true }, }), ctx.prisma.mentorAssignment.findFirst({ where: { mentorId: ctx.user.id, projectId: input.projectId }, select: { id: true }, }), ctx.prisma.project.findFirst({ where: { id: input.projectId, OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }, ], }, select: { id: true }, }), ]) if (!juryAssignment && !mentorAssignment && !teamMembership) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to this project\'s files', }) } } const where: Record = { projectId: input.projectId } if (input.stageId) { where.requirement = { stageId: input.stageId } } return ctx.prisma.projectFile.findMany({ where, include: { requirement: { select: { id: true, name: true, description: true, isRequired: true, stageId: true, stage: { select: { id: true, name: true, sortOrder: true } }, }, }, }, orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }], }) }), /** * List files for a project grouped by stage * Returns files for the specified stage + all prior stages in the same track */ listByProjectForStage: protectedProcedure .input(z.object({ projectId: z.string(), stageId: z.string(), })) .query(async ({ ctx, input }) => { const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) if (!isAdmin) { const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([ ctx.prisma.assignment.findFirst({ where: { userId: ctx.user.id, projectId: input.projectId }, select: { id: true, stageId: true }, }), ctx.prisma.mentorAssignment.findFirst({ where: { mentorId: ctx.user.id, projectId: input.projectId }, select: { id: true }, }), ctx.prisma.project.findFirst({ where: { id: input.projectId, OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }, ], }, select: { id: true }, }), ]) if (!juryAssignment && !mentorAssignment && !teamMembership) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to this project\'s files', }) } } const targetStage = await ctx.prisma.stage.findUniqueOrThrow({ where: { id: input.stageId }, select: { trackId: true, sortOrder: true }, }) const eligibleStages = await ctx.prisma.stage.findMany({ where: { trackId: targetStage.trackId, sortOrder: { lte: targetStage.sortOrder }, }, select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' }, }) const eligibleStageIds = eligibleStages.map((s) => s.id) const files = await ctx.prisma.projectFile.findMany({ where: { projectId: input.projectId, OR: [ { requirement: { stageId: { in: eligibleStageIds } } }, { requirementId: null }, ], }, include: { requirement: { select: { id: true, name: true, description: true, isRequired: true, stageId: true, stage: { select: { id: true, name: true, sortOrder: true } }, }, }, }, orderBy: [{ createdAt: 'asc' }], }) const grouped: Array<{ stageId: string | null stageName: string sortOrder: number files: typeof files }> = [] const generalFiles = files.filter((f) => !f.requirementId) if (generalFiles.length > 0) { grouped.push({ stageId: null, stageName: 'General', sortOrder: -1, files: generalFiles, }) } for (const stage of eligibleStages) { const stageFiles = files.filter((f) => f.requirement?.stageId === stage.id) if (stageFiles.length > 0) { grouped.push({ stageId: stage.id, stageName: stage.name, sortOrder: stage.sortOrder, files: stageFiles, }) } } return grouped }), /** * Replace a file with a new version */ replaceFile: protectedProcedure .input( z.object({ projectId: z.string(), oldFileId: z.string(), fileName: z.string(), fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']), mimeType: z.string(), size: z.number().int().positive(), bucket: z.string(), objectKey: z.string(), }) ) .mutation(async ({ ctx, input }) => { const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) if (!isAdmin) { // Check user has access to the project (assigned or team member) const [assignment, mentorAssignment, teamMembership] = await Promise.all([ ctx.prisma.assignment.findFirst({ where: { userId: ctx.user.id, projectId: input.projectId }, select: { id: true }, }), ctx.prisma.mentorAssignment.findFirst({ where: { mentorId: ctx.user.id, projectId: input.projectId }, select: { id: true }, }), ctx.prisma.project.findFirst({ where: { id: input.projectId, OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }, ], }, select: { id: true }, }), ]) if (!assignment && !mentorAssignment && !teamMembership) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to replace files for this project', }) } } // Get the old file to read its version const oldFile = await ctx.prisma.projectFile.findUniqueOrThrow({ where: { id: input.oldFileId }, select: { id: true, version: true, projectId: true }, }) if (oldFile.projectId !== input.projectId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'File does not belong to the specified project', }) } // Create new file and update old file in a transaction const result = await ctx.prisma.$transaction(async (tx) => { const newFile = await tx.projectFile.create({ data: { projectId: input.projectId, fileName: input.fileName, fileType: input.fileType, mimeType: input.mimeType, size: input.size, bucket: input.bucket, objectKey: input.objectKey, version: oldFile.version + 1, }, }) // Link old file to new file await tx.projectFile.update({ where: { id: input.oldFileId }, data: { replacedById: newFile.id }, }) await logAudit({ prisma: tx, userId: ctx.user.id, action: 'REPLACE_FILE', entityType: 'ProjectFile', entityId: newFile.id, detailsJson: { projectId: input.projectId, oldFileId: input.oldFileId, oldVersion: oldFile.version, newVersion: newFile.version, fileName: input.fileName, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return newFile }) return result }), /** * Get version history for a file */ getVersionHistory: protectedProcedure .input(z.object({ fileId: z.string() })) .query(async ({ ctx, input }) => { // Find the requested file const file = await ctx.prisma.projectFile.findUniqueOrThrow({ where: { id: input.fileId }, select: { id: true, projectId: true, fileName: true, fileType: true, mimeType: true, size: true, bucket: true, objectKey: true, version: true, replacedById: true, createdAt: true, }, }) // Walk backwards: find all prior versions by following replacedById chains // First, collect ALL files for this project with the same fileType to find the chain const allRelatedFiles = await ctx.prisma.projectFile.findMany({ where: { projectId: file.projectId }, select: { id: true, fileName: true, fileType: true, mimeType: true, size: true, bucket: true, objectKey: true, version: true, replacedById: true, createdAt: true, }, orderBy: { version: 'asc' }, }) // Build a chain map: fileId -> file that replaced it const replacedByMap = new Map( allRelatedFiles.filter((f) => f.replacedById).map((f) => [f.replacedById!, f.id]) ) // Walk from the current file backwards through replacedById to find all versions in chain const versions: typeof allRelatedFiles = [] // Find the root of this version chain (walk backwards) let currentId: string | undefined = input.fileId const visited = new Set() while (currentId && !visited.has(currentId)) { visited.add(currentId) const prevId = replacedByMap.get(currentId) if (prevId) { currentId = prevId } else { break // reached root } } // Now walk forward from root let walkId: string | undefined = currentId const fileMap = new Map(allRelatedFiles.map((f) => [f.id, f])) const forwardVisited = new Set() while (walkId && !forwardVisited.has(walkId)) { forwardVisited.add(walkId) const f = fileMap.get(walkId) if (f) { versions.push(f) walkId = f.replacedById ?? undefined } else { break } } return versions }), /** * Get bulk download URLs for project files */ getBulkDownloadUrls: protectedProcedure .input( z.object({ projectId: z.string(), fileIds: z.array(z.string()).optional(), }) ) .query(async ({ ctx, input }) => { const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) if (!isAdmin) { const [assignment, mentorAssignment, teamMembership] = await Promise.all([ ctx.prisma.assignment.findFirst({ where: { userId: ctx.user.id, projectId: input.projectId }, select: { id: true }, }), ctx.prisma.mentorAssignment.findFirst({ where: { mentorId: ctx.user.id, projectId: input.projectId }, select: { id: true }, }), ctx.prisma.project.findFirst({ where: { id: input.projectId, OR: [ { submittedByUserId: ctx.user.id }, { teamMembers: { some: { userId: ctx.user.id } } }, ], }, select: { id: true }, }), ]) if (!assignment && !mentorAssignment && !teamMembership) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to this project\'s files', }) } } // Get files const where: Record = { projectId: input.projectId } if (input.fileIds && input.fileIds.length > 0) { where.id = { in: input.fileIds } } const files = await ctx.prisma.projectFile.findMany({ where, select: { id: true, fileName: true, bucket: true, objectKey: true, }, }) // Generate signed URLs for each file const results = await Promise.all( files.map(async (file) => { const downloadUrl = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900) return { fileId: file.id, fileName: file.fileName, downloadUrl, } }) ) return results }), /** * Get file requirements for a project from its pipeline's intake stage. * Returns both configJson-based requirements and actual FileRequirement records, * along with which ones are already fulfilled by uploaded files. */ getProjectRequirements: adminProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { // 1. Get the project and its program const project = await ctx.prisma.project.findUniqueOrThrow({ where: { id: input.projectId }, select: { programId: true }, }) // 2. Find the pipeline for this program const pipeline = await ctx.prisma.pipeline.findFirst({ where: { programId: project.programId }, include: { tracks: { where: { kind: 'MAIN' }, include: { stages: { where: { stageType: 'INTAKE' }, take: 1, }, }, }, }, }) if (!pipeline) return null const mainTrack = pipeline.tracks[0] if (!mainTrack) return null const intakeStage = mainTrack.stages[0] if (!intakeStage) return null // 3. Check for actual FileRequirement records first const dbRequirements = await ctx.prisma.fileRequirement.findMany({ where: { stageId: intakeStage.id }, orderBy: { sortOrder: 'asc' }, include: { files: { where: { projectId: input.projectId }, select: { id: true, fileName: true, fileType: true, mimeType: true, size: true, createdAt: true, }, }, }, }) // 4. If we have DB requirements, return those (they're the canonical source) if (dbRequirements.length > 0) { return { stageId: intakeStage.id, requirements: dbRequirements.map((req) => ({ id: req.id, name: req.name, description: req.description, acceptedMimeTypes: req.acceptedMimeTypes, maxSizeMB: req.maxSizeMB, isRequired: req.isRequired, fulfilled: req.files.length > 0, fulfilledFile: req.files[0] ?? null, })), } } // 5. Fall back to configJson requirements const configJson = intakeStage.configJson as Record | null const fileRequirements = (configJson?.fileRequirements as Array<{ name: string description?: string acceptedMimeTypes?: string[] maxSizeMB?: number isRequired?: boolean type?: string required?: boolean }>) ?? [] if (fileRequirements.length === 0) return null // 6. Get project files to check fulfillment const projectFiles = await ctx.prisma.projectFile.findMany({ where: { projectId: input.projectId }, select: { id: true, fileName: true, fileType: true, mimeType: true, size: true, createdAt: true, }, }) return { stageId: intakeStage.id, requirements: fileRequirements.map((req) => { const reqName = req.name.toLowerCase() // Match by checking if any uploaded file's fileName contains the requirement name const matchingFile = projectFiles.find((f) => f.fileName.toLowerCase().includes(reqName) || reqName.includes(f.fileName.toLowerCase().replace(/\.[^.]+$/, '')) ) return { id: null as string | null, name: req.name, description: req.description ?? null, acceptedMimeTypes: req.acceptedMimeTypes ?? [], maxSizeMB: req.maxSizeMB ?? null, // Handle both formats: isRequired (wizard type) and required (seed data) isRequired: req.isRequired ?? req.required ?? false, fulfilled: !!matchingFile, fulfilledFile: matchingFile ?? null, } }), } }), // ========================================================================= // FILE REQUIREMENTS // ========================================================================= /** * Materialize legacy configJson file requirements into FileRequirement rows. * No-op if the stage already has DB-backed requirements. */ materializeRequirementsFromConfig: adminProcedure .input(z.object({ stageId: z.string() })) .mutation(async ({ ctx, input }) => { const stage = await ctx.prisma.stage.findUniqueOrThrow({ where: { id: input.stageId }, select: { id: true, stageType: true, configJson: true, }, }) if (stage.stageType !== 'INTAKE') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Requirements can only be materialized for INTAKE stages', }) } const existingCount = await ctx.prisma.fileRequirement.count({ where: { stageId: input.stageId }, }) if (existingCount > 0) { return { created: 0, skipped: true, reason: 'already_materialized' as const } } const config = (stage.configJson as Record | null) ?? {} const configRequirements = Array.isArray(config.fileRequirements) ? (config.fileRequirements as Array>) : [] if (configRequirements.length === 0) { return { created: 0, skipped: true, reason: 'no_config_requirements' as const } } const mapLegacyMimeType = (type: unknown): string[] => { switch (String(type ?? '').toUpperCase()) { case 'PDF': return ['application/pdf'] case 'VIDEO': return ['video/*'] case 'IMAGE': return ['image/*'] case 'DOC': case 'DOCX': return ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'] case 'PPT': case 'PPTX': return ['application/vnd.openxmlformats-officedocument.presentationml.presentation'] default: return [] } } let created = 0 await ctx.prisma.$transaction(async (tx) => { for (let i = 0; i < configRequirements.length; i++) { const raw = configRequirements[i] const name = typeof raw.name === 'string' ? raw.name.trim() : '' if (!name) continue const acceptedMimeTypes = Array.isArray(raw.acceptedMimeTypes) ? raw.acceptedMimeTypes.filter((v): v is string => typeof v === 'string') : mapLegacyMimeType(raw.type) await tx.fileRequirement.create({ data: { stageId: input.stageId, name, description: typeof raw.description === 'string' && raw.description.trim().length > 0 ? raw.description.trim() : undefined, acceptedMimeTypes, maxSizeMB: typeof raw.maxSizeMB === 'number' && Number.isFinite(raw.maxSizeMB) ? Math.trunc(raw.maxSizeMB) : undefined, isRequired: (raw.isRequired as boolean | undefined) ?? ((raw.required as boolean | undefined) ?? false), sortOrder: i, }, }) created++ } }) return { created, skipped: false as const } }), /** * List file requirements for a stage (available to any authenticated user) */ listRequirements: protectedProcedure .input(z.object({ stageId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.fileRequirement.findMany({ where: { stageId: input.stageId }, orderBy: { sortOrder: 'asc' }, }) }), /** * Create a file requirement for a stage (admin only) */ createRequirement: adminProcedure .input( z.object({ stageId: z.string(), name: z.string().min(1).max(200), description: z.string().max(1000).optional(), acceptedMimeTypes: z.array(z.string()).default([]), maxSizeMB: z.number().int().min(1).max(5000).optional(), isRequired: z.boolean().default(true), sortOrder: z.number().int().default(0), }) ) .mutation(async ({ ctx, input }) => { const requirement = await ctx.prisma.fileRequirement.create({ data: input, }) try { await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', entityType: 'FileRequirement', entityId: requirement.id, detailsJson: { name: input.name, stageId: input.stageId }, }) } catch {} return requirement }), /** * Update a file requirement (admin only) */ updateRequirement: adminProcedure .input( z.object({ id: z.string(), name: z.string().min(1).max(200).optional(), description: z.string().max(1000).optional().nullable(), acceptedMimeTypes: z.array(z.string()).optional(), maxSizeMB: z.number().int().min(1).max(5000).optional().nullable(), isRequired: z.boolean().optional(), sortOrder: z.number().int().optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input const requirement = await ctx.prisma.fileRequirement.update({ where: { id }, data, }) try { await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE', entityType: 'FileRequirement', entityId: id, detailsJson: data, }) } catch {} return requirement }), /** * Delete a file requirement (admin only) */ deleteRequirement: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.prisma.fileRequirement.delete({ where: { id: input.id }, }) try { await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'DELETE', entityType: 'FileRequirement', entityId: input.id, }) } catch {} return { success: true } }), /** * Reorder file requirements (admin only) */ reorderRequirements: adminProcedure .input( z.object({ stageId: z.string(), orderedIds: z.array(z.string()), }) ) .mutation(async ({ ctx, input }) => { await ctx.prisma.$transaction( input.orderedIds.map((id, index) => ctx.prisma.fileRequirement.update({ where: { id }, data: { sortOrder: index }, }) ) ) return { success: true } }), })