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 }), })