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>
This commit is contained in:
@@ -20,10 +20,14 @@ export const fileRouter = router({
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
// Find the file record to get the project
|
||||
// Find the file record to get the project and round info
|
||||
const file = await ctx.prisma.projectFile.findFirst({
|
||||
where: { bucket: input.bucket, objectKey: input.objectKey },
|
||||
select: { projectId: true },
|
||||
select: {
|
||||
projectId: true,
|
||||
roundId: true,
|
||||
round: { select: { programId: true, sortOrder: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
@@ -33,24 +37,55 @@ export const fileRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user is assigned as jury or mentor for this project
|
||||
const [juryAssignment, mentorAssignment] = await Promise.all([
|
||||
// Check if user is assigned as jury, mentor, or team member for this project
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: file.projectId },
|
||||
select: { id: true },
|
||||
select: { id: true, roundId: true },
|
||||
}),
|
||||
ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { mentorId: ctx.user.id, projectId: file.projectId },
|
||||
select: { id: true },
|
||||
}),
|
||||
ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: file.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!juryAssignment && !mentorAssignment) {
|
||||
if (!juryAssignment && !mentorAssignment && !teamMembership) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this file',
|
||||
})
|
||||
}
|
||||
|
||||
// For jury members, verify round-scoped access:
|
||||
// File must belong to the jury's assigned round or a prior round in the same program
|
||||
if (juryAssignment && !mentorAssignment && !teamMembership && file.roundId && file.round) {
|
||||
const assignedRound = await ctx.prisma.round.findUnique({
|
||||
where: { id: juryAssignment.roundId },
|
||||
select: { programId: true, sortOrder: true },
|
||||
})
|
||||
|
||||
if (assignedRound) {
|
||||
const sameProgram = assignedRound.programId === file.round.programId
|
||||
const priorOrSameRound = file.round.sortOrder <= assignedRound.sortOrder
|
||||
|
||||
if (!sameProgram || !priorOrSameRound) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this file',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min
|
||||
@@ -189,12 +224,15 @@ export const fileRouter = router({
|
||||
* Checks that the user is authorized to view the project's files
|
||||
*/
|
||||
listByProject: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.input(z.object({
|
||||
projectId: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
const [juryAssignment, mentorAssignment] = await Promise.all([
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true },
|
||||
@@ -203,9 +241,19 @@ export const fileRouter = router({
|
||||
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) {
|
||||
if (!juryAssignment && !mentorAssignment && !teamMembership) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this project\'s files',
|
||||
@@ -213,9 +261,127 @@ export const fileRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { projectId: input.projectId }
|
||||
if (input.roundId) {
|
||||
where.roundId = input.roundId
|
||||
}
|
||||
|
||||
return ctx.prisma.projectFile.findMany({
|
||||
where: { projectId: input.projectId },
|
||||
where,
|
||||
include: {
|
||||
round: { select: { id: true, name: true, sortOrder: true } },
|
||||
},
|
||||
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* List files for a project grouped by round
|
||||
* Returns files for the specified round + all prior rounds in the same program
|
||||
*/
|
||||
listByProjectForRound: protectedProcedure
|
||||
.input(z.object({
|
||||
projectId: z.string(),
|
||||
roundId: z.string(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true, roundId: true },
|
||||
}),
|
||||
ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { mentorId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true },
|
||||
}),
|
||||
ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!juryAssignment && !mentorAssignment && !teamMembership) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have access to this project\'s files',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get the target round with its program and sortOrder
|
||||
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { programId: true, sortOrder: true },
|
||||
})
|
||||
|
||||
// Get all rounds in the same program with sortOrder <= target
|
||||
const eligibleRounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
programId: targetRound.programId,
|
||||
sortOrder: { lte: targetRound.sortOrder },
|
||||
},
|
||||
select: { id: true, name: true, sortOrder: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
|
||||
|
||||
// Get files for these rounds (or files with no roundId)
|
||||
const files = await ctx.prisma.projectFile.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
OR: [
|
||||
{ roundId: { in: eligibleRoundIds } },
|
||||
{ roundId: null },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
round: { select: { id: true, name: true, sortOrder: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: 'asc' }],
|
||||
})
|
||||
|
||||
// Group by round
|
||||
const grouped: Array<{
|
||||
roundId: string | null
|
||||
roundName: string
|
||||
sortOrder: number
|
||||
files: typeof files
|
||||
}> = []
|
||||
|
||||
// Add "General" group for files with no round
|
||||
const generalFiles = files.filter((f) => !f.roundId)
|
||||
if (generalFiles.length > 0) {
|
||||
grouped.push({
|
||||
roundId: null,
|
||||
roundName: 'General',
|
||||
sortOrder: -1,
|
||||
files: generalFiles,
|
||||
})
|
||||
}
|
||||
|
||||
// Add groups for each round
|
||||
for (const round of eligibleRounds) {
|
||||
const roundFiles = files.filter((f) => f.roundId === round.id)
|
||||
if (roundFiles.length > 0) {
|
||||
grouped.push({
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
sortOrder: round.sortOrder,
|
||||
files: roundFiles,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return grouped
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user