2026-01-30 13:41:32 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
|
|
|
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
2026-02-05 20:31:08 +01:00
|
|
|
import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio'
|
2026-02-05 21:09:06 +01:00
|
|
|
import { logAudit } from '../utils/audit'
|
2026-01-30 13:41:32 +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) {
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
// Find the file record to get the project and round info
|
2026-01-30 13:41:32 +01:00
|
|
|
const file = await ctx.prisma.projectFile.findFirst({
|
|
|
|
|
where: { bucket: input.bucket, objectKey: input.objectKey },
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
select: {
|
|
|
|
|
projectId: true,
|
|
|
|
|
roundId: true,
|
|
|
|
|
round: { select: { programId: true, sortOrder: true } },
|
|
|
|
|
},
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!file) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'File not found',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
// Check if user is assigned as jury, mentor, or team member for this project
|
|
|
|
|
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
2026-01-30 13:41:32 +01:00
|
|
|
ctx.prisma.assignment.findFirst({
|
|
|
|
|
where: { userId: ctx.user.id, projectId: file.projectId },
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
select: { id: true, roundId: true },
|
2026-01-30 13:41:32 +01:00
|
|
|
}),
|
|
|
|
|
ctx.prisma.mentorAssignment.findFirst({
|
|
|
|
|
where: { mentorId: ctx.user.id, projectId: file.projectId },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
}),
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: file.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
}),
|
2026-01-30 13:41:32 +01:00
|
|
|
])
|
|
|
|
|
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
if (!juryAssignment && !mentorAssignment && !teamMembership) {
|
2026-01-30 13:41:32 +01:00
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'You do not have access to this file',
|
|
|
|
|
})
|
|
|
|
|
}
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
|
|
|
|
|
// For jury members, verify round-scoped access:
|
|
|
|
|
// File must belong to the jury's assigned round or a prior round in the same program
|
|
|
|
|
if (juryAssignment && !mentorAssignment && !teamMembership && file.roundId && file.round) {
|
|
|
|
|
const assignedRound = await ctx.prisma.round.findUnique({
|
|
|
|
|
where: { id: juryAssignment.roundId },
|
|
|
|
|
select: { programId: true, sortOrder: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (assignedRound) {
|
|
|
|
|
const sameProgram = assignedRound.programId === file.round.programId
|
|
|
|
|
const priorOrSameRound = file.round.sortOrder <= assignedRound.sortOrder
|
|
|
|
|
|
|
|
|
|
if (!sameProgram || !priorOrSameRound) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'You do not have access to this file',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
|
|
|
|
|
// Log file access
|
2026-02-05 21:09:06 +01:00
|
|
|
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,
|
|
|
|
|
})
|
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
return { url }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get pre-signed upload URL (admin only)
|
|
|
|
|
*/
|
|
|
|
|
getUploadUrl: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
fileName: z.string(),
|
|
|
|
|
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
|
|
|
|
|
mimeType: z.string(),
|
|
|
|
|
size: z.number().int().positive(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
2026-02-05 20:31:08 +01:00
|
|
|
// 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-01-30 13:41:32 +01:00
|
|
|
const bucket = BUCKET_NAME
|
|
|
|
|
const objectKey = generateObjectKey(input.projectId, input.fileName)
|
|
|
|
|
|
|
|
|
|
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600) // 1 hour
|
|
|
|
|
|
|
|
|
|
// Create file record
|
|
|
|
|
const file = await ctx.prisma.projectFile.create({
|
|
|
|
|
data: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
fileType: input.fileType,
|
|
|
|
|
fileName: input.fileName,
|
|
|
|
|
mimeType: input.mimeType,
|
|
|
|
|
size: input.size,
|
|
|
|
|
bucket,
|
|
|
|
|
objectKey,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
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,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
2026-02-05 21:09:06 +01:00
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-05 20:31:08 +01:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
// Audit log
|
2026-02-05 21:09:06 +01:00
|
|
|
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,
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
2026-02-05 21:09:06 +01:00
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return file
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List files for a project
|
|
|
|
|
* Checks that the user is authorized to view the project's files
|
|
|
|
|
*/
|
|
|
|
|
listByProject: protectedProcedure
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
.input(z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
roundId: z.string().optional(),
|
|
|
|
|
}))
|
2026-01-30 13:41:32 +01:00
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
|
|
|
|
|
|
|
|
|
if (!isAdmin) {
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
2026-01-30 13:41:32 +01:00
|
|
|
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 },
|
|
|
|
|
}),
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
}),
|
2026-01-30 13:41:32 +01:00
|
|
|
])
|
|
|
|
|
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
if (!juryAssignment && !mentorAssignment && !teamMembership) {
|
2026-01-30 13:41:32 +01:00
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'You do not have access to this project\'s files',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
const where: Record<string, unknown> = { projectId: input.projectId }
|
|
|
|
|
if (input.roundId) {
|
|
|
|
|
where.roundId = input.roundId
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
return ctx.prisma.projectFile.findMany({
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
where,
|
|
|
|
|
include: {
|
|
|
|
|
round: { select: { id: true, name: true, sortOrder: true } },
|
|
|
|
|
},
|
2026-01-30 13:41:32 +01:00
|
|
|
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
|
|
|
|
|
})
|
|
|
|
|
}),
|
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List files for a project grouped by round
|
|
|
|
|
* Returns files for the specified round + all prior rounds in the same program
|
|
|
|
|
*/
|
|
|
|
|
listByProjectForRound: protectedProcedure
|
|
|
|
|
.input(z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
}))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
|
|
|
|
|
|
|
|
|
if (!isAdmin) {
|
|
|
|
|
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
|
|
|
|
ctx.prisma.assignment.findFirst({
|
|
|
|
|
where: { userId: ctx.user.id, projectId: input.projectId },
|
|
|
|
|
select: { id: true, roundId: true },
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.mentorAssignment.findFirst({
|
|
|
|
|
where: { mentorId: ctx.user.id, projectId: input.projectId },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
if (!juryAssignment && !mentorAssignment && !teamMembership) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'You do not have access to this project\'s files',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get the target round with its program and sortOrder
|
|
|
|
|
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: { programId: true, sortOrder: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get all rounds in the same program with sortOrder <= target
|
|
|
|
|
const eligibleRounds = await ctx.prisma.round.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
programId: targetRound.programId,
|
|
|
|
|
sortOrder: { lte: targetRound.sortOrder },
|
|
|
|
|
},
|
|
|
|
|
select: { id: true, name: true, sortOrder: true },
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
|
|
|
|
|
|
|
|
|
|
// Get files for these rounds (or files with no roundId)
|
|
|
|
|
const files = await ctx.prisma.projectFile.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ roundId: { in: eligibleRoundIds } },
|
|
|
|
|
{ roundId: null },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
round: { select: { id: true, name: true, sortOrder: true } },
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ createdAt: 'asc' }],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Group by round
|
|
|
|
|
const grouped: Array<{
|
|
|
|
|
roundId: string | null
|
|
|
|
|
roundName: string
|
|
|
|
|
sortOrder: number
|
|
|
|
|
files: typeof files
|
|
|
|
|
}> = []
|
|
|
|
|
|
|
|
|
|
// Add "General" group for files with no round
|
|
|
|
|
const generalFiles = files.filter((f) => !f.roundId)
|
|
|
|
|
if (generalFiles.length > 0) {
|
|
|
|
|
grouped.push({
|
|
|
|
|
roundId: null,
|
|
|
|
|
roundName: 'General',
|
|
|
|
|
sortOrder: -1,
|
|
|
|
|
files: generalFiles,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add groups for each round
|
|
|
|
|
for (const round of eligibleRounds) {
|
|
|
|
|
const roundFiles = files.filter((f) => f.roundId === round.id)
|
|
|
|
|
if (roundFiles.length > 0) {
|
|
|
|
|
grouped.push({
|
|
|
|
|
roundId: round.id,
|
|
|
|
|
roundName: round.name,
|
|
|
|
|
sortOrder: round.sortOrder,
|
|
|
|
|
files: roundFiles,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return grouped
|
|
|
|
|
}),
|
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
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}),
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|