import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure } from '../trpc' import { getPresignedUrl, generateObjectKey, BUCKET_NAME } from '@/lib/minio' 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 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', }) } // Check if user is assigned as jury or mentor for this project const [juryAssignment, mentorAssignment] = await Promise.all([ ctx.prisma.assignment.findFirst({ where: { userId: ctx.user.id, projectId: file.projectId }, select: { id: true }, }), ctx.prisma.mentorAssignment.findFirst({ where: { mentorId: ctx.user.id, projectId: file.projectId }, select: { id: true }, }), ]) if (!juryAssignment && !mentorAssignment) { 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 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 }) => { 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 ctx.prisma.auditLog.create({ data: { 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 }, }) // Note: Actual MinIO deletion could be done here or via background job // For now, we just delete the database record // Audit log await ctx.prisma.auditLog.create({ data: { 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() })) .query(async ({ ctx, input }) => { const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) if (!isAdmin) { const [juryAssignment, mentorAssignment] = 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 }, }), ]) if (!juryAssignment && !mentorAssignment) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to this project\'s files', }) } } return ctx.prisma.projectFile.findMany({ where: { projectId: input.projectId }, orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }], }) }), })