2026-02-14 15:26:42 +01:00
|
|
|
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'
|
2026-02-17 01:43:28 +01:00
|
|
|
import { checkRequirementsAndTransition } from '../services/round-engine'
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
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 },
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
select: { id: true, roundId: true },
|
2026-02-14 15:26:42 +01:00
|
|
|
}),
|
|
|
|
|
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) {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const assignedRound = await ctx.prisma.round.findUnique({
|
|
|
|
|
where: { id: juryAssignment.roundId },
|
|
|
|
|
select: { competitionId: true, sortOrder: true },
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
if (assignedRound) {
|
|
|
|
|
const priorOrCurrentRounds = await ctx.prisma.round.findMany({
|
2026-02-14 15:26:42 +01:00
|
|
|
where: {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
competitionId: assignedRound.competitionId,
|
|
|
|
|
sortOrder: { lte: assignedRound.sortOrder },
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const roundIds = priorOrCurrentRounds.map((r) => r.id)
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
const hasFileRequirement = await ctx.prisma.fileRequirement.findFirst({
|
|
|
|
|
where: {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: { in: roundIds },
|
2026-02-14 15:26:42 +01:00
|
|
|
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(),
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: z.string().optional(),
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.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`,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 09:20:02 +01:00
|
|
|
// 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,
|
|
|
|
|
])
|
2026-02-14 15:26:42 +01:00
|
|
|
|
2026-02-16 09:20:02 +01:00
|
|
|
let isLate = false
|
|
|
|
|
if (roundInfo?.windowCloseAt) {
|
|
|
|
|
isLate = new Date() > roundInfo.windowCloseAt
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bucket = BUCKET_NAME
|
2026-02-16 09:20:02 +01:00
|
|
|
const objectKey = generateObjectKey(project.title, input.fileName, roundInfo?.name)
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
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,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: input.roundId,
|
2026-02-14 15:26:42 +01:00
|
|
|
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(),
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: z.string().optional(),
|
2026-02-14 15:26:42 +01:00
|
|
|
}))
|
|
|
|
|
.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 }
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
if (input.roundId) {
|
|
|
|
|
where.requirement = { roundId: input.roundId }
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ctx.prisma.projectFile.findMany({
|
|
|
|
|
where,
|
|
|
|
|
include: {
|
|
|
|
|
requirement: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
description: true,
|
|
|
|
|
isRequired: true,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: true,
|
|
|
|
|
round: { select: { id: true, name: true, sortOrder: true } },
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
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(),
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: z.string(),
|
2026-02-14 15:26:42 +01:00
|
|
|
}))
|
|
|
|
|
.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 },
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
select: { id: true, roundId: true },
|
2026-02-14 15:26:42 +01:00
|
|
|
}),
|
|
|
|
|
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',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: { competitionId: true, sortOrder: true },
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const eligibleRounds = await ctx.prisma.round.findMany({
|
2026-02-14 15:26:42 +01:00
|
|
|
where: {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
competitionId: targetRound.competitionId,
|
|
|
|
|
sortOrder: { lte: targetRound.sortOrder },
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
|
|
|
|
select: { id: true, name: true, sortOrder: true },
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
const files = await ctx.prisma.projectFile.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
OR: [
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
{ requirement: { roundId: { in: eligibleRoundIds } } },
|
2026-02-14 15:26:42 +01:00
|
|
|
{ requirementId: null },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
requirement: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
description: true,
|
|
|
|
|
isRequired: true,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: true,
|
|
|
|
|
round: { select: { id: true, name: true, sortOrder: true } },
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ createdAt: 'asc' }],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const grouped: Array<{
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: string | null
|
|
|
|
|
roundName: string
|
2026-02-14 15:26:42 +01:00
|
|
|
sortOrder: number
|
|
|
|
|
files: typeof files
|
|
|
|
|
}> = []
|
|
|
|
|
|
|
|
|
|
const generalFiles = files.filter((f) => !f.requirementId)
|
|
|
|
|
if (generalFiles.length > 0) {
|
|
|
|
|
grouped.push({
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: null,
|
|
|
|
|
roundName: 'General',
|
2026-02-14 15:26:42 +01:00
|
|
|
sortOrder: -1,
|
|
|
|
|
files: generalFiles,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
for (const round of eligibleRounds) {
|
|
|
|
|
const roundFiles = files.filter((f) => f.requirement?.roundId === round.id)
|
|
|
|
|
if (roundFiles.length > 0) {
|
2026-02-14 15:26:42 +01:00
|
|
|
grouped.push({
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: round.id,
|
|
|
|
|
roundName: round.name,
|
|
|
|
|
sortOrder: round.sortOrder,
|
|
|
|
|
files: roundFiles,
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
|
Round detail overhaul, file requirements, project management, audit log fix
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
(PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
|
|
|
// 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,
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
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
|
|
|
|
|
}),
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
// NOTE: getProjectRequirements procedure removed - depends on deleted Pipeline/Track/Stage models
|
|
|
|
|
// Will need to be reimplemented with new Competition/Round architecture
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// FILE REQUIREMENTS
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
/**
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
* Materialize legacy configJson file requirements into FileRequirement rows.
|
|
|
|
|
* No-op if the stage already has DB-backed requirements.
|
2026-02-14 15:26:42 +01:00
|
|
|
*/
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
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,
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
if (stage.roundType !== 'INTAKE') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Requirements can only be materialized for INTAKE stages',
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const existingCount = await ctx.prisma.fileRequirement.count({
|
|
|
|
|
where: { roundId: input.roundId },
|
|
|
|
|
})
|
|
|
|
|
if (existingCount > 0) {
|
|
|
|
|
return { created: 0, skipped: true, reason: 'already_materialized' as const }
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const config = (stage.configJson as Record<string, unknown> | null) ?? {}
|
|
|
|
|
const configRequirements = Array.isArray(config.fileRequirements)
|
|
|
|
|
? (config.fileRequirements as Array<Record<string, unknown>>)
|
|
|
|
|
: []
|
2026-02-14 15:26:42 +01:00
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
if (configRequirements.length === 0) {
|
|
|
|
|
return { created: 0, skipped: true, reason: 'no_config_requirements' as const }
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
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 []
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
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++
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
return { created, skipped: false as const }
|
2026-02-14 15:26:42 +01:00
|
|
|
}),
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
/**
|
|
|
|
|
* List file requirements for a stage (available to any authenticated user)
|
|
|
|
|
*/
|
2026-02-14 15:26:42 +01:00
|
|
|
listRequirements: protectedProcedure
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
.input(z.object({ roundId: z.string() }))
|
2026-02-14 15:26:42 +01:00
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
return ctx.prisma.fileRequirement.findMany({
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
where: { roundId: input.roundId },
|
2026-02-14 15:26:42 +01:00
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-16 15:30:44 +01:00
|
|
|
/**
|
|
|
|
|
* 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' },
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
/**
|
|
|
|
|
* Create a file requirement for a stage (admin only)
|
|
|
|
|
*/
|
|
|
|
|
createRequirement: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: z.string(),
|
2026-02-14 15:26:42 +01:00
|
|
|
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,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
detailsJson: { name: input.name, roundId: input.roundId },
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
} 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({
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: z.string(),
|
2026-02-14 15:26:42 +01:00
|
|
|
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 }
|
|
|
|
|
}),
|
Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION
- Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards)
- Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review)
- Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone
- Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields
- Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish
- Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors
- Bulk upload page for admin project imports
- File router enhanced with admin upload and submission window procedures
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:16:55 +01:00
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// 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'
|
|
|
|
|
|
2026-02-16 09:20:02 +01:00
|
|
|
// 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 },
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
|
Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION
- Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards)
- Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review)
- Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone
- Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields
- Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish
- Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors
- Bulk upload page for admin project imports
- File router enhanced with admin upload and submission window procedures
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:16:55 +01:00
|
|
|
const bucket = BUCKET_NAME
|
2026-02-16 09:20:02 +01:00
|
|
|
const objectKey = generateObjectKey(project.title, input.fileName, submissionWindow.name)
|
Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION
- Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards)
- Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review)
- Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone
- Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields
- Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish
- Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors
- Bulk upload page for admin project imports
- File router enhanced with admin upload and submission window procedures
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:16:55 +01:00
|
|
|
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' }],
|
|
|
|
|
})
|
|
|
|
|
}),
|
AI category-aware evaluation: per-round config, file parsing, shortlist, advance flow
- Per-juror cap mode (HARD/SOFT/NONE) in add-member dialog and members table
- Jury invite flow: create user + add to group + send invitation from dialog
- Per-round config: notifyOnAdvance, aiParseFiles, startupAdvanceCount, conceptAdvanceCount
- Moved notify-on-advance from competition-level to per-round setting
- AI filtering: round-tagged files with newest-first sorting, optional file content extraction
- File content extractor service (pdf-parse for PDF, utf-8 for text files)
- AI shortlist runs independently per category (STARTUP / BUSINESS_CONCEPT)
- generateAIRecommendations tRPC endpoint with per-round config integration
- AI recommendations UI: trigger button, confirmation dialog, per-category results display
- Category-aware advance dialog: select/deselect projects by category with target caps
- STAGE_ACTIVE bug fix in assignment router
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:09:52 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
2026-02-16 13:32:23 +01:00
|
|
|
bucket: true,
|
|
|
|
|
objectKey: true,
|
AI category-aware evaluation: per-round config, file parsing, shortlist, advance flow
- Per-juror cap mode (HARD/SOFT/NONE) in add-member dialog and members table
- Jury invite flow: create user + add to group + send invitation from dialog
- Per-round config: notifyOnAdvance, aiParseFiles, startupAdvanceCount, conceptAdvanceCount
- Moved notify-on-advance from competition-level to per-round setting
- AI filtering: round-tagged files with newest-first sorting, optional file content extraction
- File content extractor service (pdf-parse for PDF, utf-8 for text files)
- AI shortlist runs independently per category (STARTUP / BUSINESS_CONCEPT)
- generateAIRecommendations tRPC endpoint with per-round config integration
- AI recommendations UI: trigger button, confirmation dialog, per-category results display
- Category-aware advance dialog: select/deselect projects by category with target caps
- STAGE_ACTIVE bug fix in assignment router
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:09:52 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-17 01:43:28 +01:00
|
|
|
// Auto-transition: check if all required documents are now uploaded
|
|
|
|
|
await checkRequirementsAndTransition(
|
|
|
|
|
input.projectId,
|
|
|
|
|
input.roundId,
|
|
|
|
|
ctx.user.id,
|
|
|
|
|
ctx.prisma,
|
|
|
|
|
)
|
|
|
|
|
|
AI category-aware evaluation: per-round config, file parsing, shortlist, advance flow
- Per-juror cap mode (HARD/SOFT/NONE) in add-member dialog and members table
- Jury invite flow: create user + add to group + send invitation from dialog
- Per-round config: notifyOnAdvance, aiParseFiles, startupAdvanceCount, conceptAdvanceCount
- Moved notify-on-advance from competition-level to per-round setting
- AI filtering: round-tagged files with newest-first sorting, optional file content extraction
- File content extractor service (pdf-parse for PDF, utf-8 for text files)
- AI shortlist runs independently per category (STARTUP / BUSINESS_CONCEPT)
- generateAIRecommendations tRPC endpoint with per-round config integration
- AI recommendations UI: trigger button, confirmation dialog, per-category results display
- Category-aware advance dialog: select/deselect projects by category with target caps
- STAGE_ACTIVE bug fix in assignment router
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:09:52 +01:00
|
|
|
return { uploadUrl, file }
|
|
|
|
|
}),
|
2026-02-16 13:32:23 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}),
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|