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

929 lines
28 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'
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<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 },
})
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<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)
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' },
})
}),
/**
* 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 }
}),
})