2026-01-30 13:41:32 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
|
|
|
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
|
|
|
|
import { getPresignedUrl, generateObjectKey, BUCKET_NAME } from '@/lib/minio'
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
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',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if user is assigned as jury or mentor for this project
|
|
|
|
|
const [juryAssignment, mentorAssignment] = await Promise.all([
|
|
|
|
|
ctx.prisma.assignment.findFirst({
|
|
|
|
|
where: { userId: ctx.user.id, projectId: file.projectId },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.mentorAssignment.findFirst({
|
|
|
|
|
where: { mentorId: ctx.user.id, projectId: file.projectId },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
if (!juryAssignment && !mentorAssignment) {
|
|
|
|
|
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
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
|
|
|
|
|
// Log file access
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'FILE_DOWNLOADED',
|
|
|
|
|
entityType: 'ProjectFile',
|
|
|
|
|
detailsJson: { bucket: input.bucket, objectKey: input.objectKey },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
}).catch(() => {})
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
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 }) => {
|
|
|
|
|
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 ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
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 },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Note: Actual MinIO deletion could be done here or via background job
|
|
|
|
|
// For now, we just delete the database record
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
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() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
|
|
|
|
|
|
|
|
|
if (!isAdmin) {
|
|
|
|
|
const [juryAssignment, mentorAssignment] = 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 },
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
if (!juryAssignment && !mentorAssignment) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'You do not have access to this project\'s files',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ctx.prisma.projectFile.findMany({
|
|
|
|
|
where: { projectId: input.projectId },
|
|
|
|
|
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
})
|