MOPC-App/src/server/routers/file.ts

388 lines
12 KiB
TypeScript

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) {
// Find the file record to get the project and round info
const file = await ctx.prisma.projectFile.findFirst({
where: { bucket: input.bucket, objectKey: input.objectKey },
select: {
projectId: true,
roundId: true,
round: { select: { programId: true, sortOrder: true } },
},
})
if (!file) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'File not found',
})
}
// Check if user is assigned as jury, mentor, or team member for this project
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',
})
}
// For jury members, verify round-scoped access:
// File must belong to the jury's assigned round or a prior round in the same program
if (juryAssignment && !mentorAssignment && !teamMembership && file.roundId && file.round) {
const assignedRound = await ctx.prisma.round.findUnique({
where: { id: juryAssignment.roundId },
select: { programId: true, sortOrder: true },
})
if (assignedRound) {
const sameProgram = assignedRound.programId === file.round.programId
const priorOrSameRound = file.round.sortOrder <= assignedRound.sortOrder
if (!sameProgram || !priorOrSameRound) {
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(),
})
)
.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`,
})
}
const bucket = BUCKET_NAME
const objectKey = generateObjectKey(input.projectId, input.fileName)
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600) // 1 hour
// Create file record
const file = await ctx.prisma.projectFile.create({
data: {
projectId: input.projectId,
fileType: input.fileType,
fileName: input.fileName,
mimeType: input.mimeType,
size: input.size,
bucket,
objectKey,
},
})
// Audit log
await 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,
},
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.roundId = input.roundId
}
return ctx.prisma.projectFile.findMany({
where,
include: {
round: { select: { id: true, name: true, sortOrder: true } },
},
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
})
}),
/**
* List files for a project grouped by round
* Returns files for the specified round + all prior rounds in the same program
*/
listByProjectForRound: 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',
})
}
}
// Get the target round with its program and sortOrder
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { programId: true, sortOrder: true },
})
// Get all rounds in the same program with sortOrder <= target
const eligibleRounds = await ctx.prisma.round.findMany({
where: {
programId: targetRound.programId,
sortOrder: { lte: targetRound.sortOrder },
},
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
})
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
// Get files for these rounds (or files with no roundId)
const files = await ctx.prisma.projectFile.findMany({
where: {
projectId: input.projectId,
OR: [
{ roundId: { in: eligibleRoundIds } },
{ roundId: null },
],
},
include: {
round: { select: { id: true, name: true, sortOrder: true } },
},
orderBy: [{ createdAt: 'asc' }],
})
// Group by round
const grouped: Array<{
roundId: string | null
roundName: string
sortOrder: number
files: typeof files
}> = []
// Add "General" group for files with no round
const generalFiles = files.filter((f) => !f.roundId)
if (generalFiles.length > 0) {
grouped.push({
roundId: null,
roundName: 'General',
sortOrder: -1,
files: generalFiles,
})
}
// Add groups for each round
for (const round of eligibleRounds) {
const roundFiles = files.filter((f) => f.roundId === round.id)
if (roundFiles.length > 0) {
grouped.push({
roundId: round.id,
roundName: round.name,
sortOrder: round.sortOrder,
files: roundFiles,
})
}
}
return grouped
}),
})