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, 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', }) } if (juryAssignment && !mentorAssignment && !teamMembership) { const assignedRound = await ctx.prisma.round.findUnique({ where: { id: juryAssignment.roundId }, select: { competitionId: true, sortOrder: true }, }) if (assignedRound) { const priorOrCurrentRounds = await ctx.prisma.round.findMany({ where: { competitionId: assignedRound.competitionId, sortOrder: { lte: assignedRound.sortOrder }, }, select: { id: true }, }) const roundIds = priorOrCurrentRounds.map((r) => r.id) const hasFileRequirement = await ctx.prisma.fileRequirement.findFirst({ where: { roundId: { in: roundIds }, 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(), roundId: 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.roundId) { const stage = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, 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, roundId: input.roundId, 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(), 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.requirement = { roundId: input.roundId } } return ctx.prisma.projectFile.findMany({ where, include: { requirement: { select: { id: true, name: true, description: true, isRequired: true, roundId: true, round: { 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(), 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', }) } } const targetRound = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { competitionId: true, sortOrder: true }, }) const eligibleRounds = await ctx.prisma.round.findMany({ where: { competitionId: targetRound.competitionId, sortOrder: { lte: targetRound.sortOrder }, }, select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' }, }) const eligibleRoundIds = eligibleRounds.map((r) => r.id) const files = await ctx.prisma.projectFile.findMany({ where: { projectId: input.projectId, OR: [ { requirement: { roundId: { in: eligibleRoundIds } } }, { requirementId: null }, ], }, include: { requirement: { select: { id: true, name: true, description: true, isRequired: true, roundId: true, round: { select: { id: true, name: true, sortOrder: true } }, }, }, }, orderBy: [{ createdAt: 'asc' }], }) const grouped: Array<{ roundId: string | null roundName: string sortOrder: number files: typeof files }> = [] const generalFiles = files.filter((f) => !f.requirementId) if (generalFiles.length > 0) { grouped.push({ roundId: null, roundName: 'General', sortOrder: -1, files: generalFiles, }) } for (const round of eligibleRounds) { const roundFiles = files.filter((f) => f.requirement?.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 }), // NOTE: getProjectRequirements procedure removed - depends on deleted Pipeline/Track/Stage models // Will need to be reimplemented with new Competition/Round architecture // ========================================================================= // 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({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const stage = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { id: true, roundType: true, configJson: true, }, }) if (stage.roundType !== 'INTAKE') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Requirements can only be materialized for INTAKE stages', }) } const existingCount = await ctx.prisma.fileRequirement.count({ where: { roundId: input.roundId }, }) 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: { roundId: input.roundId, 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({ roundId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.fileRequirement.findMany({ where: { roundId: input.roundId }, orderBy: { sortOrder: 'asc' }, }) }), /** * Create a file requirement for a stage (admin only) */ createRequirement: adminProcedure .input( z.object({ roundId: 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, roundId: input.roundId }, }) } 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({ roundId: 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 } }), })