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`, }) } // Fetch project title and optional round name for storage path const [project, roundInfo] = await Promise.all([ ctx.prisma.project.findUniqueOrThrow({ where: { id: input.projectId }, select: { title: true }, }), input.roundId ? ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { name: true, windowCloseAt: true }, }) : null, ]) let isLate = false if (roundInfo?.windowCloseAt) { isLate = new Date() > roundInfo.windowCloseAt } const bucket = BUCKET_NAME const objectKey = generateObjectKey(project.title, input.fileName, roundInfo?.name) 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 }, }) return newFile }) // Audit outside transaction so failures don't roll back the file replacement await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'REPLACE_FILE', entityType: 'ProjectFile', entityId: result.id, detailsJson: { projectId: input.projectId, oldFileId: input.oldFileId, oldVersion: oldFile.version, newVersion: result.version, fileName: input.fileName, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) 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' }, }) }), /** * List file requirements for multiple rounds in a single query. * Avoids dynamic hook violations when fetching requirements per-round. */ listRequirementsByRounds: protectedProcedure .input(z.object({ roundIds: z.array(z.string()).max(50) })) .query(async ({ ctx, input }) => { if (input.roundIds.length === 0) return [] return ctx.prisma.fileRequirement.findMany({ where: { roundId: { in: input.roundIds } }, 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 } }), // ========================================================================= // BULK UPLOAD // ========================================================================= /** * List projects with their upload status for a given submission window. * Powers the bulk upload admin page. */ listProjectsWithUploadStatus: adminProcedure .input( z.object({ submissionWindowId: z.string(), search: z.string().optional(), status: z.enum(['all', 'missing', 'complete']).default('all'), page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(50), }) ) .query(async ({ ctx, input }) => { // Get the submission window with its requirements and competition const window = await ctx.prisma.submissionWindow.findUniqueOrThrow({ where: { id: input.submissionWindowId }, include: { competition: { select: { id: true, programId: true, name: true } }, fileRequirements: { orderBy: { sortOrder: 'asc' } }, }, }) const requirements = window.fileRequirements // Build project filter const projectWhere: Record = { programId: window.competition.programId, } if (input.search) { projectWhere.OR = [ { title: { contains: input.search, mode: 'insensitive' } }, { teamName: { contains: input.search, mode: 'insensitive' } }, ] } // Get total count first (before status filtering, which happens in-memory) const allProjects = await ctx.prisma.project.findMany({ where: projectWhere, select: { id: true, title: true, teamName: true, submittedByUserId: true, submittedBy: { select: { id: true, name: true, email: true } }, files: { where: { submissionWindowId: input.submissionWindowId }, select: { id: true, fileName: true, mimeType: true, size: true, createdAt: true, submissionFileRequirementId: true, }, }, }, orderBy: { title: 'asc' }, }) // Map projects with their requirement status const mapped = allProjects.map((project) => { const reqStatus = requirements.map((req) => { const file = project.files.find( (f) => f.submissionFileRequirementId === req.id ) return { requirementId: req.id, label: req.label, mimeTypes: req.mimeTypes, required: req.required, file: file ?? null, } }) const totalRequired = reqStatus.filter((r) => r.required).length const filledRequired = reqStatus.filter( (r) => r.required && r.file ).length return { project: { id: project.id, title: project.title, teamName: project.teamName, submittedBy: project.submittedBy, }, requirements: reqStatus, isComplete: totalRequired > 0 ? filledRequired >= totalRequired : reqStatus.every((r) => r.file), filledCount: reqStatus.filter((r) => r.file).length, totalCount: reqStatus.length, } }) // Apply status filter const filtered = input.status === 'missing' ? mapped.filter((p) => !p.isComplete) : input.status === 'complete' ? mapped.filter((p) => p.isComplete) : mapped // Paginate const total = filtered.length const totalPages = Math.ceil(total / input.pageSize) const page = Math.min(input.page, Math.max(totalPages, 1)) const projects = filtered.slice( (page - 1) * input.pageSize, page * input.pageSize ) // Summary stats const completeCount = mapped.filter((p) => p.isComplete).length return { projects, requirements, total, page, totalPages, completeCount, totalProjects: mapped.length, competition: window.competition, windowName: window.name, } }), /** * Admin upload for a specific submission file requirement. * Creates pre-signed PUT URL + ProjectFile record. */ adminUploadForRequirement: adminProcedure .input( z.object({ projectId: z.string(), fileName: z.string(), mimeType: z.string(), size: z.number().int().positive(), submissionWindowId: z.string(), submissionFileRequirementId: z.string(), }) ) .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`, }) } // Validate requirement exists and belongs to the window const requirement = await ctx.prisma.submissionFileRequirement.findFirst({ where: { id: input.submissionFileRequirementId, submissionWindowId: input.submissionWindowId, }, }) if (!requirement) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Requirement not found for this submission window', }) } // Validate MIME type if requirement specifies allowed types if (requirement.mimeTypes.length > 0) { const isAllowed = requirement.mimeTypes.some((allowed) => { if (allowed.endsWith('/*')) { return input.mimeType.startsWith(allowed.replace('/*', '/')) } return input.mimeType === allowed }) if (!isAllowed) { throw new TRPCError({ code: 'BAD_REQUEST', message: `File type "${input.mimeType}" is not allowed for this requirement. Accepted: ${requirement.mimeTypes.join(', ')}`, }) } } // Infer fileType from mimeType let fileType: 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' = 'OTHER' if (input.mimeType.startsWith('video/')) fileType = 'VIDEO' else if (input.mimeType === 'application/pdf') fileType = 'EXEC_SUMMARY' else if (input.mimeType.includes('presentation') || input.mimeType.includes('powerpoint')) fileType = 'PRESENTATION' // Fetch project title and window name for storage path const [project, submissionWindow] = await Promise.all([ ctx.prisma.project.findUniqueOrThrow({ where: { id: input.projectId }, select: { title: true }, }), ctx.prisma.submissionWindow.findUniqueOrThrow({ where: { id: input.submissionWindowId }, select: { name: true }, }), ]) const bucket = BUCKET_NAME const objectKey = generateObjectKey(project.title, input.fileName, submissionWindow.name) const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600) // Remove any existing file for this project+requirement combo (replace) await ctx.prisma.projectFile.deleteMany({ where: { projectId: input.projectId, submissionWindowId: input.submissionWindowId, submissionFileRequirementId: input.submissionFileRequirementId, }, }) // Create file record const file = await ctx.prisma.projectFile.create({ data: { projectId: input.projectId, fileType, fileName: input.fileName, mimeType: input.mimeType, size: input.size, bucket, objectKey, submissionWindowId: input.submissionWindowId, submissionFileRequirementId: input.submissionFileRequirementId, }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPLOAD_FILE', entityType: 'ProjectFile', entityId: file.id, detailsJson: { projectId: input.projectId, fileName: input.fileName, submissionWindowId: input.submissionWindowId, submissionFileRequirementId: input.submissionFileRequirementId, bulkUpload: true, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { uploadUrl, file } }), /** * List submission windows (for the bulk upload window selector) */ listSubmissionWindows: adminProcedure .query(async ({ ctx }) => { return ctx.prisma.submissionWindow.findMany({ include: { competition: { select: { id: true, name: true, program: { select: { name: true, year: true } } }, }, fileRequirements: { select: { id: true }, }, }, orderBy: [{ competition: { program: { year: 'desc' } } }, { sortOrder: 'asc' }], }) }), /** * List rounds with their file requirement counts (for bulk upload round selector) */ listRoundsForBulkUpload: adminProcedure .query(async ({ ctx }) => { return ctx.prisma.round.findMany({ where: { fileRequirements: { some: {} }, }, select: { id: true, name: true, roundType: true, sortOrder: true, competition: { select: { id: true, name: true, program: { select: { name: true, year: true } } }, }, fileRequirements: { select: { id: true }, }, }, orderBy: [ { competition: { program: { year: 'desc' } } }, { sortOrder: 'asc' }, ], }) }), /** * List projects with upload status against a round's FileRequirements (for bulk upload) */ listProjectsByRoundRequirements: adminProcedure .input( z.object({ roundId: z.string(), search: z.string().optional(), status: z.enum(['all', 'missing', 'complete']).default('all'), page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(50), }) ) .query(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, include: { competition: { select: { id: true, programId: true, name: true } }, fileRequirements: { orderBy: { sortOrder: 'asc' } }, }, }) // Normalize requirements to a common shape const requirements = round.fileRequirements.map((req) => ({ id: req.id, label: req.name, mimeTypes: req.acceptedMimeTypes, required: req.isRequired, maxSizeMb: req.maxSizeMB, description: req.description, })) // Build project filter const projectWhere: Record = { programId: round.competition.programId, } if (input.search) { projectWhere.OR = [ { title: { contains: input.search, mode: 'insensitive' } }, { teamName: { contains: input.search, mode: 'insensitive' } }, ] } const allProjects = await ctx.prisma.project.findMany({ where: projectWhere, select: { id: true, title: true, teamName: true, submittedByUserId: true, submittedBy: { select: { id: true, name: true, email: true } }, files: { where: { roundId: input.roundId, requirementId: { not: null } }, select: { id: true, fileName: true, mimeType: true, size: true, createdAt: true, requirementId: true, bucket: true, objectKey: true, }, }, }, orderBy: { title: 'asc' }, }) // Map projects with their requirement status const mapped = allProjects.map((project) => { const reqStatus = requirements.map((req) => { const file = project.files.find( (f) => f.requirementId === req.id ) return { requirementId: req.id, label: req.label, mimeTypes: req.mimeTypes, required: req.required, file: file ?? null, } }) const totalRequired = reqStatus.filter((r) => r.required).length const filledRequired = reqStatus.filter( (r) => r.required && r.file ).length return { project: { id: project.id, title: project.title, teamName: project.teamName, submittedBy: project.submittedBy, }, requirements: reqStatus, isComplete: totalRequired > 0 ? filledRequired >= totalRequired : reqStatus.every((r) => r.file), filledCount: reqStatus.filter((r) => r.file).length, totalCount: reqStatus.length, } }) // Apply status filter const filtered = input.status === 'missing' ? mapped.filter((p) => !p.isComplete) : input.status === 'complete' ? mapped.filter((p) => p.isComplete) : mapped // Paginate const total = filtered.length const totalPages = Math.ceil(total / input.pageSize) const page = Math.min(input.page, Math.max(totalPages, 1)) const projects = filtered.slice( (page - 1) * input.pageSize, page * input.pageSize ) const completeCount = mapped.filter((p) => p.isComplete).length return { projects, requirements, total, page, totalPages, completeCount, totalProjects: mapped.length, competition: round.competition, } }), /** * Upload a file for a round's FileRequirement (admin bulk upload) */ adminUploadForRoundRequirement: adminProcedure .input( z.object({ projectId: z.string(), fileName: z.string(), mimeType: z.string(), size: z.number().int().positive(), roundId: z.string(), requirementId: z.string(), }) ) .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`, }) } // Validate requirement exists and belongs to the round const requirement = await ctx.prisma.fileRequirement.findFirst({ where: { id: input.requirementId, roundId: input.roundId, }, }) if (!requirement) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Requirement not found for this round', }) } // Validate MIME type if requirement specifies allowed types if (requirement.acceptedMimeTypes.length > 0) { const isAllowed = requirement.acceptedMimeTypes.some((allowed) => { if (allowed.endsWith('/*')) { return input.mimeType.startsWith(allowed.replace('/*', '/')) } return input.mimeType === allowed }) if (!isAllowed) { throw new TRPCError({ code: 'BAD_REQUEST', message: `File type "${input.mimeType}" is not allowed for this requirement. Accepted: ${requirement.acceptedMimeTypes.join(', ')}`, }) } } // Infer fileType from mimeType let fileType: 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' = 'OTHER' if (input.mimeType.startsWith('video/')) fileType = 'VIDEO' else if (input.mimeType === 'application/pdf') fileType = 'EXEC_SUMMARY' else if (input.mimeType.includes('presentation') || input.mimeType.includes('powerpoint')) fileType = 'PRESENTATION' // Fetch project title and round name for storage path const [project, round] = await Promise.all([ ctx.prisma.project.findUniqueOrThrow({ where: { id: input.projectId }, select: { title: true }, }), ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { name: true }, }), ]) const bucket = BUCKET_NAME const objectKey = generateObjectKey(project.title, input.fileName, round.name) const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600) // Remove any existing file for this project+requirement combo (replace) await ctx.prisma.projectFile.deleteMany({ where: { projectId: input.projectId, roundId: input.roundId, requirementId: input.requirementId, }, }) // Create file record const file = await ctx.prisma.projectFile.create({ data: { projectId: input.projectId, fileType, fileName: input.fileName, mimeType: input.mimeType, size: input.size, bucket, objectKey, roundId: input.roundId, requirementId: input.requirementId, }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPLOAD_FILE', entityType: 'ProjectFile', entityId: file.id, detailsJson: { projectId: input.projectId, fileName: input.fileName, roundId: input.roundId, requirementId: input.requirementId, bulkUpload: true, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { uploadUrl, file } }), /** * Verify that files actually exist in storage (MinIO/S3). * Returns a map of objectKey → exists boolean. */ verifyFilesExist: adminProcedure .input( z.object({ files: z.array( z.object({ bucket: z.string(), objectKey: z.string(), }) ).max(200), }) ) .query(async ({ input }) => { const { getMinioClient } = await import('@/lib/minio') const client = getMinioClient() const results: Record = {} await Promise.all( input.files.map(async ({ bucket, objectKey }) => { try { await client.statObject(bucket, objectKey) results[objectKey] = true } catch { results[objectKey] = false } }) ) return results }), })