Files
MOPC-Portal/src/server/routers/file.ts

1594 lines
50 KiB
TypeScript
Raw Normal View History

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'
import { checkRequirementsAndTransition } from '../services/round-engine'
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(),
forDownload: z.boolean().optional(),
fileName: z.string().optional(),
})
)
.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,
input.forDownload ? { downloadFileName: input.fileName || input.objectKey.split('/').pop() || 'download' } : undefined
) // 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,
})
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
import('../services/document-analyzer').then(({ analyzeFileDelayed }) =>
analyzeFileDelayed(file.id).catch((err) =>
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
).catch(() => {})
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<string, unknown> = { 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<string>()
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<string>()
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<string, unknown> = { 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,
{ downloadFileName: file.fileName }
)
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<string, unknown> | null) ?? {}
const configRequirements = Array.isArray(config.fileRequirements)
? (config.fileRequirements as Array<Record<string, unknown>>)
: []
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<string, unknown> = {
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,
})
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
import('../services/document-analyzer').then(({ analyzeFileDelayed }) =>
analyzeFileDelayed(file.id).catch((err) =>
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
).catch(() => {})
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<string, unknown> = {
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,
})
// Auto-transition: check if all required documents are now uploaded
await checkRequirementsAndTransition(
input.projectId,
input.roundId,
ctx.user.id,
ctx.prisma,
)
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
import('../services/document-analyzer').then(({ analyzeFileDelayed }) =>
analyzeFileDelayed(file.id).catch((err) =>
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
).catch(() => {})
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<string, boolean> = {}
await Promise.all(
input.files.map(async ({ bucket, objectKey }) => {
try {
await client.statObject(bucket, objectKey)
results[objectKey] = true
} catch {
results[objectKey] = false
}
})
)
return results
}),
/**
* Analyze all files for a specific project (page count, language, text preview).
* Retroactive: re-analyzes even previously analyzed files.
*/
analyzeProjectFiles: adminProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ input }) => {
const { analyzeProjectFiles } = await import('../services/document-analyzer')
return analyzeProjectFiles(input.projectId)
}),
/**
* Batch analyze all unanalyzed files across the platform.
* For retroactive analysis of files uploaded before this feature.
*/
analyzeAllFiles: adminProcedure
.mutation(async () => {
const { analyzeAllUnanalyzed } = await import('../services/document-analyzer')
return analyzeAllUnanalyzed()
}),
})