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

1052 lines
32 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio'
import { logAudit } from '../utils/audit'
export const fileRouter = router({
/**
* Get pre-signed download URL
* Checks that the user is authorized to access the file's project
*/
getDownloadUrl: protectedProcedure
.input(
z.object({
bucket: z.string(),
objectKey: z.string(),
})
)
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
const file = await ctx.prisma.projectFile.findFirst({
where: { bucket: input.bucket, objectKey: input.objectKey },
select: {
projectId: true,
},
})
if (!file) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'File not found',
})
}
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: file.projectId },
select: { id: true, stageId: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: file.projectId },
select: { id: true },
}),
ctx.prisma.project.findFirst({
where: {
id: file.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
}),
])
if (!juryAssignment && !mentorAssignment && !teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this file',
})
}
if (juryAssignment && !mentorAssignment && !teamMembership) {
const assignedStage = await ctx.prisma.stage.findUnique({
where: { id: juryAssignment.stageId },
select: { trackId: true, sortOrder: true },
})
if (assignedStage) {
const priorOrCurrentStages = await ctx.prisma.stage.findMany({
where: {
trackId: assignedStage.trackId,
sortOrder: { lte: assignedStage.sortOrder },
},
select: { id: true },
})
const stageIds = priorOrCurrentStages.map((s) => s.id)
const hasFileRequirement = await ctx.prisma.fileRequirement.findFirst({
where: {
stageId: { in: stageIds },
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(),
stageId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Block dangerous file extensions
const dangerousExtensions = ['.exe', '.sh', '.bat', '.cmd', '.ps1', '.php', '.jsp', '.cgi', '.dll', '.msi']
const ext = input.fileName.toLowerCase().slice(input.fileName.lastIndexOf('.'))
if (dangerousExtensions.includes(ext)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `File type "${ext}" is not allowed`,
})
}
let isLate = false
if (input.stageId) {
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: { windowCloseAt: true },
})
if (stage?.windowCloseAt) {
isLate = new Date() > stage.windowCloseAt
}
}
const bucket = BUCKET_NAME
const objectKey = generateObjectKey(input.projectId, input.fileName)
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600) // 1 hour
// Create file record
const file = await ctx.prisma.projectFile.create({
data: {
projectId: input.projectId,
fileType: input.fileType,
fileName: input.fileName,
mimeType: input.mimeType,
size: input.size,
bucket,
objectKey,
isLate,
},
})
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPLOAD_FILE',
entityType: 'ProjectFile',
entityId: file.id,
detailsJson: {
projectId: input.projectId,
fileName: input.fileName,
fileType: input.fileType,
stageId: input.stageId,
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(),
stageId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
}),
])
if (!juryAssignment && !mentorAssignment && !teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this project\'s files',
})
}
}
const where: Record<string, unknown> = { projectId: input.projectId }
if (input.stageId) {
where.requirement = { stageId: input.stageId }
}
return ctx.prisma.projectFile.findMany({
where,
include: {
requirement: {
select: {
id: true,
name: true,
description: true,
isRequired: true,
stageId: true,
stage: { select: { id: true, name: true, sortOrder: true } },
},
},
},
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
})
}),
/**
* List files for a project grouped by stage
* Returns files for the specified stage + all prior stages in the same track
*/
listByProjectForStage: protectedProcedure
.input(z.object({
projectId: z.string(),
stageId: z.string(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
select: { id: true, stageId: 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 targetStage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: { trackId: true, sortOrder: true },
})
const eligibleStages = await ctx.prisma.stage.findMany({
where: {
trackId: targetStage.trackId,
sortOrder: { lte: targetStage.sortOrder },
},
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
})
const eligibleStageIds = eligibleStages.map((s) => s.id)
const files = await ctx.prisma.projectFile.findMany({
where: {
projectId: input.projectId,
OR: [
{ requirement: { stageId: { in: eligibleStageIds } } },
{ requirementId: null },
],
},
include: {
requirement: {
select: {
id: true,
name: true,
description: true,
isRequired: true,
stageId: true,
stage: { select: { id: true, name: true, sortOrder: true } },
},
},
},
orderBy: [{ createdAt: 'asc' }],
})
const grouped: Array<{
stageId: string | null
stageName: string
sortOrder: number
files: typeof files
}> = []
const generalFiles = files.filter((f) => !f.requirementId)
if (generalFiles.length > 0) {
grouped.push({
stageId: null,
stageName: 'General',
sortOrder: -1,
files: generalFiles,
})
}
for (const stage of eligibleStages) {
const stageFiles = files.filter((f) => f.requirement?.stageId === stage.id)
if (stageFiles.length > 0) {
grouped.push({
stageId: stage.id,
stageName: stage.name,
sortOrder: stage.sortOrder,
files: stageFiles,
})
}
}
return grouped
}),
/**
* Replace a file with a new version
*/
replaceFile: protectedProcedure
.input(
z.object({
projectId: z.string(),
oldFileId: z.string(),
fileName: z.string(),
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
mimeType: z.string(),
size: z.number().int().positive(),
bucket: z.string(),
objectKey: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
// Check user has access to the project (assigned or team member)
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
}),
])
if (!assignment && !mentorAssignment && !teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to replace files for this project',
})
}
}
// Get the old file to read its version
const oldFile = await ctx.prisma.projectFile.findUniqueOrThrow({
where: { id: input.oldFileId },
select: { id: true, version: true, projectId: true },
})
if (oldFile.projectId !== input.projectId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'File does not belong to the specified project',
})
}
// Create new file and update old file in a transaction
const result = await ctx.prisma.$transaction(async (tx) => {
const newFile = await tx.projectFile.create({
data: {
projectId: input.projectId,
fileName: input.fileName,
fileType: input.fileType,
mimeType: input.mimeType,
size: input.size,
bucket: input.bucket,
objectKey: input.objectKey,
version: oldFile.version + 1,
},
})
// Link old file to new file
await tx.projectFile.update({
where: { id: input.oldFileId },
data: { replacedById: newFile.id },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'REPLACE_FILE',
entityType: 'ProjectFile',
entityId: newFile.id,
detailsJson: {
projectId: input.projectId,
oldFileId: input.oldFileId,
oldVersion: oldFile.version,
newVersion: newFile.version,
fileName: input.fileName,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return newFile
})
return result
}),
/**
* Get version history for a file
*/
getVersionHistory: protectedProcedure
.input(z.object({ fileId: z.string() }))
.query(async ({ ctx, input }) => {
// Find the requested file
const file = await ctx.prisma.projectFile.findUniqueOrThrow({
where: { id: input.fileId },
select: {
id: true,
projectId: true,
fileName: true,
fileType: true,
mimeType: true,
size: true,
bucket: true,
objectKey: true,
version: true,
replacedById: true,
createdAt: true,
},
})
// Walk backwards: find all prior versions by following replacedById chains
// First, collect ALL files for this project with the same fileType to find the chain
const allRelatedFiles = await ctx.prisma.projectFile.findMany({
where: { projectId: file.projectId },
select: {
id: true,
fileName: true,
fileType: true,
mimeType: true,
size: true,
bucket: true,
objectKey: true,
version: true,
replacedById: true,
createdAt: true,
},
orderBy: { version: 'asc' },
})
// Build a chain map: fileId -> file that replaced it
const replacedByMap = new Map(
allRelatedFiles.filter((f) => f.replacedById).map((f) => [f.replacedById!, f.id])
)
// Walk from the current file backwards through replacedById to find all versions in chain
const versions: typeof allRelatedFiles = []
// Find the root of this version chain (walk backwards)
let currentId: string | undefined = input.fileId
const visited = new Set<string>()
while (currentId && !visited.has(currentId)) {
visited.add(currentId)
const prevId = replacedByMap.get(currentId)
if (prevId) {
currentId = prevId
} else {
break // reached root
}
}
// Now walk forward from root
let walkId: string | undefined = currentId
const fileMap = new Map(allRelatedFiles.map((f) => [f.id, f]))
const forwardVisited = new Set<string>()
while (walkId && !forwardVisited.has(walkId)) {
forwardVisited.add(walkId)
const f = fileMap.get(walkId)
if (f) {
versions.push(f)
walkId = f.replacedById ?? undefined
} else {
break
}
}
return versions
}),
/**
* Get bulk download URLs for project files
*/
getBulkDownloadUrls: protectedProcedure
.input(
z.object({
projectId: z.string(),
fileIds: z.array(z.string()).optional(),
})
)
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
}),
])
if (!assignment && !mentorAssignment && !teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this project\'s files',
})
}
}
// Get files
const where: Record<string, unknown> = { projectId: input.projectId }
if (input.fileIds && input.fileIds.length > 0) {
where.id = { in: input.fileIds }
}
const files = await ctx.prisma.projectFile.findMany({
where,
select: {
id: true,
fileName: true,
bucket: true,
objectKey: true,
},
})
// Generate signed URLs for each file
const results = await Promise.all(
files.map(async (file) => {
const downloadUrl = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900)
return {
fileId: file.id,
fileName: file.fileName,
downloadUrl,
}
})
)
return results
}),
/**
* Get file requirements for a project from its pipeline's intake stage.
* Returns both configJson-based requirements and actual FileRequirement records,
* along with which ones are already fulfilled by uploaded files.
*/
getProjectRequirements: adminProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// 1. Get the project and its program
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
select: { programId: true },
})
// 2. Find the pipeline for this program
const pipeline = await ctx.prisma.pipeline.findFirst({
where: { programId: project.programId },
include: {
tracks: {
where: { kind: 'MAIN' },
include: {
stages: {
where: { stageType: 'INTAKE' },
take: 1,
},
},
},
},
})
if (!pipeline) return null
const mainTrack = pipeline.tracks[0]
if (!mainTrack) return null
const intakeStage = mainTrack.stages[0]
if (!intakeStage) return null
// 3. Check for actual FileRequirement records first
const dbRequirements = await ctx.prisma.fileRequirement.findMany({
where: { stageId: intakeStage.id },
orderBy: { sortOrder: 'asc' },
include: {
files: {
where: { projectId: input.projectId },
select: {
id: true,
fileName: true,
fileType: true,
mimeType: true,
size: true,
createdAt: true,
},
},
},
})
// 4. If we have DB requirements, return those (they're the canonical source)
if (dbRequirements.length > 0) {
return {
stageId: intakeStage.id,
requirements: dbRequirements.map((req) => ({
id: req.id,
name: req.name,
description: req.description,
acceptedMimeTypes: req.acceptedMimeTypes,
maxSizeMB: req.maxSizeMB,
isRequired: req.isRequired,
fulfilled: req.files.length > 0,
fulfilledFile: req.files[0] ?? null,
})),
}
}
// 5. Fall back to configJson requirements
const configJson = intakeStage.configJson as Record<string, unknown> | null
const fileRequirements = (configJson?.fileRequirements as Array<{
name: string
description?: string
acceptedMimeTypes?: string[]
maxSizeMB?: number
isRequired?: boolean
type?: string
required?: boolean
}>) ?? []
if (fileRequirements.length === 0) return null
// 6. Get project files to check fulfillment
const projectFiles = await ctx.prisma.projectFile.findMany({
where: { projectId: input.projectId },
select: {
id: true,
fileName: true,
fileType: true,
mimeType: true,
size: true,
createdAt: true,
},
})
return {
stageId: intakeStage.id,
requirements: fileRequirements.map((req) => {
const reqName = req.name.toLowerCase()
// Match by checking if any uploaded file's fileName contains the requirement name
const matchingFile = projectFiles.find((f) =>
f.fileName.toLowerCase().includes(reqName) ||
reqName.includes(f.fileName.toLowerCase().replace(/\.[^.]+$/, ''))
)
return {
id: null as string | null,
name: req.name,
description: req.description ?? null,
acceptedMimeTypes: req.acceptedMimeTypes ?? [],
maxSizeMB: req.maxSizeMB ?? null,
// Handle both formats: isRequired (wizard type) and required (seed data)
isRequired: req.isRequired ?? req.required ?? false,
fulfilled: !!matchingFile,
fulfilledFile: matchingFile ?? null,
}
}),
}
}),
// =========================================================================
// FILE REQUIREMENTS
// =========================================================================
/**
* Materialize legacy configJson file requirements into FileRequirement rows.
* No-op if the stage already has DB-backed requirements.
*/
materializeRequirementsFromConfig: adminProcedure
.input(z.object({ stageId: z.string() }))
.mutation(async ({ ctx, input }) => {
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: {
id: true,
stageType: true,
configJson: true,
},
})
if (stage.stageType !== 'INTAKE') {
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Requirements can only be materialized for INTAKE stages',
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
})
}
const existingCount = await ctx.prisma.fileRequirement.count({
where: { stageId: input.stageId },
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
})
if (existingCount > 0) {
return { created: 0, skipped: true, reason: 'already_materialized' as const }
}
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
const config = (stage.configJson as Record<string, unknown> | null) ?? {}
const configRequirements = Array.isArray(config.fileRequirements)
? (config.fileRequirements as Array<Record<string, unknown>>)
: []
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
if (configRequirements.length === 0) {
return { created: 0, skipped: true, reason: 'no_config_requirements' as const }
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +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 []
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +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: {
stageId: input.stageId,
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,
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
},
})
created++
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
}
})
return { created, skipped: false as const }
}),
/**
* List file requirements for a stage (available to any authenticated user)
*/
listRequirements: protectedProcedure
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.fileRequirement.findMany({
where: { stageId: input.stageId },
orderBy: { sortOrder: 'asc' },
})
}),
/**
* Create a file requirement for a stage (admin only)
*/
createRequirement: adminProcedure
.input(
z.object({
stageId: z.string(),
name: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
acceptedMimeTypes: z.array(z.string()).default([]),
maxSizeMB: z.number().int().min(1).max(5000).optional(),
isRequired: z.boolean().default(true),
sortOrder: z.number().int().default(0),
})
)
.mutation(async ({ ctx, input }) => {
const requirement = await ctx.prisma.fileRequirement.create({
data: input,
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'FileRequirement',
entityId: requirement.id,
detailsJson: { name: input.name, stageId: input.stageId },
})
} 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({
stageId: z.string(),
orderedIds: z.array(z.string()),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.orderedIds.map((id, index) =>
ctx.prisma.fileRequirement.update({
where: { id },
data: { sortOrder: index },
})
)
)
return { success: true }
}),
})