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) { // Find the file record to get the project and round info const file = await ctx.prisma.projectFile.findFirst({ where: { bucket: input.bucket, objectKey: input.objectKey }, select: { projectId: true, roundId: true, round: { select: { programId: true, sortOrder: true } }, }, }) if (!file) { throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found', }) } // Check if user is assigned as jury, mentor, or team member for this project const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([ ctx.prisma.assignment.findFirst({ where: { userId: ctx.user.id, projectId: file.projectId }, select: { id: true, roundId: 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', }) } // For jury members, verify round-scoped access: // File must belong to the jury's assigned round or a prior round in the same program if (juryAssignment && !mentorAssignment && !teamMembership && file.roundId && file.round) { const assignedRound = await ctx.prisma.round.findUnique({ where: { id: juryAssignment.roundId }, select: { programId: true, sortOrder: true }, }) if (assignedRound) { const sameProgram = assignedRound.programId === file.round.programId const priorOrSameRound = file.round.sortOrder <= assignedRound.sortOrder if (!sameProgram || !priorOrSameRound) { 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(), }) ) .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`, }) } 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, }, }) // 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, }, 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(), roundId: 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.roundId) { where.roundId = input.roundId } return ctx.prisma.projectFile.findMany({ where, include: { round: { select: { id: true, name: true, sortOrder: true } }, }, orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }], }) }), /** * List files for a project grouped by round * Returns files for the specified round + all prior rounds in the same program */ listByProjectForRound: protectedProcedure .input(z.object({ projectId: z.string(), roundId: 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, roundId: 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', }) } } // Get the target round with its program and sortOrder const targetRound = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { programId: true, sortOrder: true }, }) // Get all rounds in the same program with sortOrder <= target const eligibleRounds = await ctx.prisma.round.findMany({ where: { programId: targetRound.programId, sortOrder: { lte: targetRound.sortOrder }, }, select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' }, }) const eligibleRoundIds = eligibleRounds.map((r) => r.id) // Get files for these rounds (or files with no roundId) const files = await ctx.prisma.projectFile.findMany({ where: { projectId: input.projectId, OR: [ { roundId: { in: eligibleRoundIds } }, { roundId: null }, ], }, include: { round: { select: { id: true, name: true, sortOrder: true } }, }, orderBy: [{ createdAt: 'asc' }], }) // Group by round const grouped: Array<{ roundId: string | null roundName: string sortOrder: number files: typeof files }> = [] // Add "General" group for files with no round const generalFiles = files.filter((f) => !f.roundId) if (generalFiles.length > 0) { grouped.push({ roundId: null, roundName: 'General', sortOrder: -1, files: generalFiles, }) } // Add groups for each round for (const round of eligibleRounds) { const roundFiles = files.filter((f) => f.roundId === round.id) if (roundFiles.length > 0) { grouped.push({ roundId: round.id, roundName: round.name, sortOrder: round.sortOrder, files: roundFiles, }) } } 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 }), })