2026-02-14 15:26:42 +01:00
|
|
|
import crypto from 'crypto'
|
|
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
|
|
|
|
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
2026-03-05 13:06:17 +01:00
|
|
|
import { getPresignedUrl, generateObjectKey, BUCKET_NAME } from '@/lib/minio'
|
2026-03-03 19:14:41 +01:00
|
|
|
import { generateLogoKey, createStorageProvider, type StorageProviderType } from '@/lib/storage'
|
|
|
|
|
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
|
2026-02-14 15:26:42 +01:00
|
|
|
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
|
|
|
|
import { logAudit } from '@/server/utils/audit'
|
|
|
|
|
import { createNotification } from '../services/in-app-notification'
|
2026-03-03 19:14:41 +01:00
|
|
|
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
|
|
|
|
|
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
2026-03-10 12:47:06 +01:00
|
|
|
import type { PrismaClient, Prisma, RoundType } from '@prisma/client'
|
2026-02-14 15:26:42 +01:00
|
|
|
|
2026-03-05 13:06:17 +01:00
|
|
|
// All uploads use the single configured bucket (MINIO_BUCKET / mopc-files).
|
|
|
|
|
// Files are organized by path prefix: {ProjectName}/{RoundName}/... for submissions,
|
|
|
|
|
// avatars/{userId}/... for profile images, logos/{projectId}/... for project logos.
|
2026-02-14 15:26:42 +01:00
|
|
|
const TEAM_INVITE_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
|
|
|
|
|
|
|
|
|
|
function generateInviteToken(): string {
|
|
|
|
|
return crypto.randomBytes(32).toString('hex')
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
/** Check if a project has been rejected in any round (based on ProjectRoundState, not Project.status) */
|
2026-03-10 12:47:06 +01:00
|
|
|
async function isProjectRejected(prisma: PrismaClient, projectId: string): Promise<boolean> {
|
2026-03-04 13:29:39 +01:00
|
|
|
const rejected = await prisma.projectRoundState.findFirst({
|
|
|
|
|
where: { projectId, state: 'REJECTED' },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
return !!rejected
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
export const applicantRouter = router({
|
|
|
|
|
/**
|
|
|
|
|
* Get submission info for an applicant (by round slug)
|
|
|
|
|
*/
|
|
|
|
|
getSubmissionBySlug: publicProcedure
|
|
|
|
|
.input(z.object({ slug: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const round = await ctx.prisma.round.findFirst({
|
2026-02-14 15:26:42 +01:00
|
|
|
where: { slug: input.slug },
|
|
|
|
|
include: {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
competition: {
|
2026-02-14 15:26:42 +01:00
|
|
|
include: {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
program: { select: { id: true, name: true, year: true, description: true } },
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
if (!round) {
|
2026-02-14 15:26:42 +01:00
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
message: 'Round not found',
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const isOpen = round.status === 'ROUND_ACTIVE'
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
stage: {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
id: round.id,
|
|
|
|
|
name: round.name,
|
|
|
|
|
slug: round.slug,
|
|
|
|
|
windowCloseAt: null,
|
2026-02-14 15:26:42 +01:00
|
|
|
isOpen,
|
|
|
|
|
},
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
program: round.competition.program,
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the current user's submission for a round (as submitter or team member)
|
|
|
|
|
*/
|
|
|
|
|
getMySubmission: protectedProcedure
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
.input(z.object({ roundId: z.string().optional(), programId: z.string().optional() }))
|
2026-02-14 15:26:42 +01:00
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
// Only applicants can use this
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only applicants can access submissions',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const where: Record<string, unknown> = {
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: { userId: ctx.user.id },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
if (input.roundId) {
|
|
|
|
|
where.roundAssignments = { some: { roundId: input.roundId } }
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
if (input.programId) {
|
|
|
|
|
where.programId = input.programId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where,
|
|
|
|
|
include: {
|
|
|
|
|
files: true,
|
|
|
|
|
program: { select: { id: true, name: true, year: true } },
|
|
|
|
|
teamMembers: {
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (project) {
|
|
|
|
|
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
|
|
|
|
return {
|
|
|
|
|
...project,
|
|
|
|
|
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
|
|
|
|
|
isTeamLead: project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create or update a submission (draft or submitted)
|
|
|
|
|
*/
|
|
|
|
|
saveSubmission: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
programId: z.string().optional(),
|
|
|
|
|
projectId: z.string().optional(),
|
|
|
|
|
title: z.string().min(1).max(500),
|
|
|
|
|
teamName: z.string().optional(),
|
|
|
|
|
description: z.string().optional(),
|
|
|
|
|
tags: z.array(z.string()).optional(),
|
|
|
|
|
metadataJson: z.record(z.unknown()).optional(),
|
|
|
|
|
submit: z.boolean().default(false),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Only applicants can use this
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only applicants can submit projects',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
|
|
|
|
const { projectId, submit, programId, metadataJson, ...data } = input
|
|
|
|
|
|
|
|
|
|
if (projectId) {
|
|
|
|
|
// Update existing
|
|
|
|
|
const existing = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: projectId,
|
|
|
|
|
submittedByUserId: ctx.user.id,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Project not found or you do not have access',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Can't update if already submitted
|
|
|
|
|
if (existing.submittedAt && !submit) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Cannot modify a submitted project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const project = await ctx.prisma.project.update({
|
|
|
|
|
where: { id: projectId },
|
|
|
|
|
data: {
|
|
|
|
|
...data,
|
|
|
|
|
metadataJson: metadataJson as unknown ?? undefined,
|
|
|
|
|
submittedAt: submit && !existing.submittedAt ? now : existing.submittedAt,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Update Project status if submitting
|
|
|
|
|
if (submit) {
|
|
|
|
|
await ctx.prisma.project.update({
|
|
|
|
|
where: { id: projectId },
|
|
|
|
|
data: { status: 'SUBMITTED' },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return project
|
|
|
|
|
} else {
|
|
|
|
|
if (!programId) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'programId is required when creating a new submission',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create new project
|
|
|
|
|
const project = await ctx.prisma.project.create({
|
|
|
|
|
data: {
|
|
|
|
|
programId,
|
|
|
|
|
...data,
|
|
|
|
|
metadataJson: metadataJson as unknown ?? undefined,
|
|
|
|
|
submittedByUserId: ctx.user.id,
|
|
|
|
|
submittedByEmail: ctx.user.email,
|
|
|
|
|
submissionSource: 'MANUAL',
|
|
|
|
|
submittedAt: submit ? now : null,
|
|
|
|
|
status: 'SUBMITTED',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CREATE',
|
|
|
|
|
entityType: 'Project',
|
|
|
|
|
entityId: project.id,
|
|
|
|
|
detailsJson: { title: input.title, source: 'applicant_portal' },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return project
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get upload URL for a submission file
|
|
|
|
|
*/
|
|
|
|
|
getUploadUrl: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
fileName: z.string(),
|
|
|
|
|
mimeType: z.string(),
|
|
|
|
|
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: z.string().optional(),
|
2026-02-14 15:26:42 +01:00
|
|
|
requirementId: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Applicants or team members can upload
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
// Check if user is a team member of the project
|
|
|
|
|
const teamMembership = await ctx.prisma.teamMember.findFirst({
|
|
|
|
|
where: { projectId: input.projectId, userId: ctx.user.id },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
if (!teamMembership) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only applicants or team members can upload files',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify project access (owner or team member)
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Project not found or you do not have access',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
// Block rejected projects from uploading
|
|
|
|
|
if (await isProjectRejected(ctx.prisma, input.projectId)) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. Uploads are no longer permitted.' })
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
// If uploading against a requirement, validate mime type and size
|
|
|
|
|
if (input.requirementId) {
|
|
|
|
|
const requirement = await ctx.prisma.fileRequirement.findUnique({
|
|
|
|
|
where: { id: input.requirementId },
|
|
|
|
|
})
|
|
|
|
|
if (!requirement) {
|
|
|
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'File requirement not found' })
|
|
|
|
|
}
|
|
|
|
|
// Validate mime type
|
|
|
|
|
if (requirement.acceptedMimeTypes.length > 0) {
|
|
|
|
|
const accepted = requirement.acceptedMimeTypes.some((pattern) => {
|
|
|
|
|
if (pattern.endsWith('/*')) {
|
|
|
|
|
return input.mimeType.startsWith(pattern.replace('/*', '/'))
|
|
|
|
|
}
|
|
|
|
|
return input.mimeType === pattern
|
|
|
|
|
})
|
|
|
|
|
if (!accepted) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: `File type ${input.mimeType} is not accepted. Accepted types: ${requirement.acceptedMimeTypes.join(', ')}`,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let isLate = false
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
// Can't upload if already submitted — but only for initial application edits.
|
|
|
|
|
// Round-specific uploads (business plan, video for later rounds) are allowed
|
|
|
|
|
// as long as the round is active.
|
|
|
|
|
if (project.submittedAt && !input.roundId) {
|
2026-02-14 15:26:42 +01:00
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Cannot modify a submitted project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
// Fetch round info and verify it's active
|
2026-02-16 09:20:02 +01:00
|
|
|
let roundName: string | undefined
|
|
|
|
|
if (input.roundId) {
|
|
|
|
|
const round = await ctx.prisma.round.findUnique({
|
|
|
|
|
where: { id: input.roundId },
|
2026-03-04 13:29:39 +01:00
|
|
|
select: { name: true, status: true },
|
2026-02-16 09:20:02 +01:00
|
|
|
})
|
2026-03-04 13:29:39 +01:00
|
|
|
if (round && round.status !== 'ROUND_ACTIVE') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'This round is closed. Documents can no longer be uploaded.',
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-16 09:20:02 +01:00
|
|
|
roundName = round?.name
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const objectKey = generateObjectKey(project.title, input.fileName, roundName)
|
2026-02-14 15:26:42 +01:00
|
|
|
|
2026-03-05 13:06:17 +01:00
|
|
|
const url = await getPresignedUrl(BUCKET_NAME, objectKey, 'PUT', 3600)
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
url,
|
2026-03-05 13:06:17 +01:00
|
|
|
bucket: BUCKET_NAME,
|
2026-02-14 15:26:42 +01:00
|
|
|
objectKey,
|
|
|
|
|
isLate,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: input.roundId || null,
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save file metadata after upload
|
|
|
|
|
*/
|
|
|
|
|
saveFileMetadata: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
fileName: z.string(),
|
|
|
|
|
mimeType: z.string(),
|
|
|
|
|
size: z.number().int(),
|
|
|
|
|
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
|
|
|
|
|
bucket: z.string(),
|
|
|
|
|
objectKey: z.string(),
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: z.string().optional(),
|
2026-02-14 15:26:42 +01:00
|
|
|
isLate: z.boolean().optional(),
|
|
|
|
|
requirementId: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Applicants or team members can save files
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
const teamMembership = await ctx.prisma.teamMember.findFirst({
|
|
|
|
|
where: { projectId: input.projectId, userId: ctx.user.id },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
if (!teamMembership) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only applicants or team members can save files',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify project access
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Project not found or you do not have access',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
// Block rejected projects
|
|
|
|
|
if (await isProjectRejected(ctx.prisma, input.projectId)) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. File changes are no longer permitted.' })
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const { projectId, roundId, isLate, requirementId, ...fileData } = input
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
// Delete existing file: by requirementId if provided, otherwise by fileType
|
|
|
|
|
if (requirementId) {
|
|
|
|
|
await ctx.prisma.projectFile.deleteMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectId,
|
|
|
|
|
requirementId,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
await ctx.prisma.projectFile.deleteMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectId,
|
|
|
|
|
fileType: input.fileType,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
// Create new file record
|
2026-02-14 15:26:42 +01:00
|
|
|
const file = await ctx.prisma.projectFile.create({
|
|
|
|
|
data: {
|
|
|
|
|
projectId,
|
|
|
|
|
...fileData,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: roundId || null,
|
2026-02-14 15:26:42 +01:00
|
|
|
isLate: isLate || false,
|
|
|
|
|
requirementId: requirementId || null,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
// Auto-transition: mark as IN_PROGRESS on file activity, then check completion
|
|
|
|
|
if (roundId) {
|
|
|
|
|
await triggerInProgressOnActivity(projectId, roundId, ctx.user.id, ctx.prisma)
|
|
|
|
|
if (requirementId) {
|
|
|
|
|
await checkRequirementsAndTransition(
|
|
|
|
|
projectId,
|
|
|
|
|
roundId,
|
|
|
|
|
ctx.user.id,
|
|
|
|
|
ctx.prisma,
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-17 01:43:28 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 15:53:55 +01:00
|
|
|
// Audit log
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'APPLICANT_UPLOAD_FILE',
|
|
|
|
|
entityType: 'ProjectFile',
|
|
|
|
|
entityId: file.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
projectId,
|
|
|
|
|
fileName: input.fileName,
|
|
|
|
|
fileType: input.fileType,
|
|
|
|
|
roundId: roundId || null,
|
|
|
|
|
isLate: isLate || false,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-17 10:08:04 +01:00
|
|
|
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
|
2026-02-17 11:02:05 +01:00
|
|
|
import('../services/document-analyzer').then(({ analyzeFileDelayed }) =>
|
|
|
|
|
analyzeFileDelayed(file.id).catch((err) =>
|
|
|
|
|
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
|
2026-02-17 10:08:04 +01:00
|
|
|
).catch(() => {})
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
return file
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a file from submission
|
|
|
|
|
*/
|
|
|
|
|
deleteFile: protectedProcedure
|
|
|
|
|
.input(z.object({ fileId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const file = await ctx.prisma.projectFile.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.fileId },
|
|
|
|
|
include: { project: { include: { teamMembers: { select: { userId: true } } } } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Verify ownership or team membership
|
|
|
|
|
const isOwner = file.project.submittedByUserId === ctx.user.id
|
|
|
|
|
const isTeamMember = file.project.teamMembers.some((tm) => tm.userId === ctx.user.id)
|
|
|
|
|
|
|
|
|
|
if (!isOwner && !isTeamMember) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'You do not have access to this file',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
// Block rejected projects
|
|
|
|
|
if (await isProjectRejected(ctx.prisma, file.project.id)) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. File changes are no longer permitted.' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Can't delete initial application files after submission
|
|
|
|
|
if (file.project.submittedAt && !file.roundId) {
|
2026-02-14 15:26:42 +01:00
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Cannot modify a submitted project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
// Round-specific files can only be deleted while the round is active
|
|
|
|
|
if (file.roundId) {
|
|
|
|
|
const round = await ctx.prisma.round.findUnique({
|
|
|
|
|
where: { id: file.roundId },
|
|
|
|
|
select: { status: true },
|
|
|
|
|
})
|
|
|
|
|
if (round && round.status !== 'ROUND_ACTIVE') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'This round is closed. Documents can no longer be modified.',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
await ctx.prisma.projectFile.delete({
|
|
|
|
|
where: { id: input.fileId },
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-04 15:53:55 +01:00
|
|
|
// Audit log
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'APPLICANT_DELETE_FILE',
|
|
|
|
|
entityType: 'ProjectFile',
|
|
|
|
|
entityId: input.fileId,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
projectId: file.project.id,
|
|
|
|
|
fileName: file.fileName,
|
|
|
|
|
fileType: file.fileType,
|
|
|
|
|
roundId: file.roundId,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
return { success: true }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get status timeline from ProjectStatusHistory
|
|
|
|
|
*/
|
|
|
|
|
getStatusTimeline: protectedProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
// Verify user has access to this project
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: { userId: ctx.user.id },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Project not found',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const history = await ctx.prisma.projectStatusHistory.findMany({
|
|
|
|
|
where: { projectId: input.projectId },
|
|
|
|
|
orderBy: { changedAt: 'asc' },
|
|
|
|
|
select: {
|
|
|
|
|
status: true,
|
|
|
|
|
changedAt: true,
|
|
|
|
|
changedBy: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return history
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get submission status timeline
|
|
|
|
|
*/
|
|
|
|
|
getSubmissionStatus: protectedProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: { userId: ctx.user.id },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
program: { select: { id: true, name: true, year: true } },
|
|
|
|
|
files: true,
|
|
|
|
|
teamMembers: {
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
wonAwards: {
|
|
|
|
|
select: { id: true, name: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Project not found',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get the project status
|
|
|
|
|
const currentStatus = project.status ?? 'SUBMITTED'
|
|
|
|
|
|
|
|
|
|
// Fetch actual status history
|
|
|
|
|
const statusHistory = await ctx.prisma.projectStatusHistory.findMany({
|
|
|
|
|
where: { projectId: input.projectId },
|
|
|
|
|
orderBy: { changedAt: 'asc' },
|
|
|
|
|
select: { status: true, changedAt: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Build a map of status -> earliest changedAt
|
|
|
|
|
const statusDateMap = new Map<string, Date>()
|
|
|
|
|
for (const entry of statusHistory) {
|
|
|
|
|
if (!statusDateMap.has(entry.status)) {
|
|
|
|
|
statusDateMap.set(entry.status, entry.changedAt)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isRejected = currentStatus === 'REJECTED'
|
|
|
|
|
const hasWonAward = project.wonAwards.length > 0
|
|
|
|
|
|
|
|
|
|
// Build timeline - handle REJECTED as terminal state
|
|
|
|
|
const timeline = [
|
|
|
|
|
{
|
|
|
|
|
status: 'CREATED',
|
|
|
|
|
label: 'Application Started',
|
|
|
|
|
date: project.createdAt,
|
|
|
|
|
completed: true,
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
status: 'SUBMITTED',
|
|
|
|
|
label: 'Application Submitted',
|
|
|
|
|
date: project.submittedAt || statusDateMap.get('SUBMITTED') || null,
|
|
|
|
|
completed: !!project.submittedAt || statusDateMap.has('SUBMITTED'),
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
status: 'UNDER_REVIEW',
|
|
|
|
|
label: 'Under Review',
|
|
|
|
|
date: statusDateMap.get('ELIGIBLE') || statusDateMap.get('ASSIGNED') ||
|
|
|
|
|
(currentStatus !== 'SUBMITTED' && project.submittedAt ? project.submittedAt : null),
|
|
|
|
|
completed: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(currentStatus),
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
if (isRejected) {
|
|
|
|
|
// For rejected projects, show REJECTED as the terminal red step
|
|
|
|
|
timeline.push({
|
|
|
|
|
status: 'REJECTED',
|
|
|
|
|
label: 'Not Selected',
|
|
|
|
|
date: statusDateMap.get('REJECTED') || null,
|
|
|
|
|
completed: true,
|
|
|
|
|
isTerminal: true,
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
// Normal progression
|
|
|
|
|
timeline.push(
|
|
|
|
|
{
|
|
|
|
|
status: 'SEMIFINALIST',
|
|
|
|
|
label: 'Semi-finalist',
|
|
|
|
|
date: statusDateMap.get('SEMIFINALIST') || null,
|
|
|
|
|
completed: ['SEMIFINALIST', 'FINALIST'].includes(currentStatus) || hasWonAward,
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
status: 'FINALIST',
|
|
|
|
|
label: 'Finalist',
|
|
|
|
|
date: statusDateMap.get('FINALIST') || null,
|
|
|
|
|
completed: currentStatus === 'FINALIST' || hasWonAward,
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (hasWonAward) {
|
|
|
|
|
timeline.push({
|
|
|
|
|
status: 'WINNER',
|
|
|
|
|
label: `Winner${project.wonAwards.length > 0 ? ` - ${project.wonAwards[0].name}` : ''}`,
|
|
|
|
|
date: null,
|
|
|
|
|
completed: true,
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
project,
|
|
|
|
|
timeline,
|
|
|
|
|
currentStatus,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List all submissions for current user (including as team member)
|
|
|
|
|
*/
|
|
|
|
|
listMySubmissions: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only applicants can access submissions',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find projects where user is either the submitter OR a team member
|
|
|
|
|
const projects = await ctx.prisma.project.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: { userId: ctx.user.id },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
program: { select: { id: true, name: true, year: true } },
|
|
|
|
|
files: true,
|
|
|
|
|
teamMembers: {
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Add user's role in each project
|
|
|
|
|
return projects.map((project) => {
|
|
|
|
|
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
|
|
|
|
return {
|
|
|
|
|
...project,
|
|
|
|
|
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
|
|
|
|
|
isTeamLead: project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD',
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get team members for a project
|
|
|
|
|
*/
|
|
|
|
|
getTeamMembers: protectedProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
// Verify user has access to this project
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: { userId: ctx.user.id },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
teamMembers: {
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
email: true,
|
|
|
|
|
status: true,
|
|
|
|
|
lastLoginAt: true,
|
2026-03-03 19:14:41 +01:00
|
|
|
profileImageKey: true,
|
|
|
|
|
profileImageProvider: true,
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { joinedAt: 'asc' },
|
|
|
|
|
},
|
|
|
|
|
submittedBy: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Project not found or you do not have access',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
// Generate presigned avatar URLs for team members with profile images
|
|
|
|
|
const avatarUrls: Record<string, string> = {}
|
|
|
|
|
for (const member of project.teamMembers) {
|
|
|
|
|
if (member.user.profileImageKey) {
|
|
|
|
|
const providerType = (member.user.profileImageProvider as StorageProviderType) || 's3'
|
|
|
|
|
const provider = createStorageProvider(providerType)
|
|
|
|
|
avatarUrls[member.userId] = await provider.getDownloadUrl(member.user.profileImageKey)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
return {
|
|
|
|
|
teamMembers: project.teamMembers,
|
|
|
|
|
submittedBy: project.submittedBy,
|
2026-03-03 19:14:41 +01:00
|
|
|
avatarUrls,
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Invite a new team member
|
|
|
|
|
*/
|
|
|
|
|
inviteTeamMember: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
email: z.string().email(),
|
|
|
|
|
name: z.string().min(1),
|
|
|
|
|
role: z.enum(['MEMBER', 'ADVISOR']),
|
|
|
|
|
title: z.string().optional(),
|
feat: applicant onboarding, bulk invite, team management enhancements
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
|
|
|
nationality: z.string().optional(),
|
|
|
|
|
country: z.string().optional(),
|
|
|
|
|
institution: z.string().optional(),
|
|
|
|
|
sendInvite: z.boolean().default(true),
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const normalizedEmail = input.email.trim().toLowerCase()
|
|
|
|
|
|
|
|
|
|
// Verify user is team lead
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
role: 'LEAD',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only team leads can invite new members',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
// Block rejected projects
|
|
|
|
|
if (await isProjectRejected(ctx.prisma, input.projectId)) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. Team changes are no longer permitted.' })
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
// Check if already a team member
|
|
|
|
|
const existingMember = await ctx.prisma.teamMember.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
user: { email: normalizedEmail },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (existingMember) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'CONFLICT',
|
|
|
|
|
message: 'This person is already a team member',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find or create user
|
|
|
|
|
let user = await ctx.prisma.user.findUnique({
|
|
|
|
|
where: { email: normalizedEmail },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
user = await ctx.prisma.user.create({
|
|
|
|
|
data: {
|
|
|
|
|
email: normalizedEmail,
|
|
|
|
|
name: input.name,
|
|
|
|
|
role: 'APPLICANT',
|
feat: applicant onboarding, bulk invite, team management enhancements
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
|
|
|
roles: ['APPLICANT'],
|
2026-02-14 15:26:42 +01:00
|
|
|
status: 'NONE',
|
feat: applicant onboarding, bulk invite, team management enhancements
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
|
|
|
nationality: input.nationality,
|
|
|
|
|
country: input.country,
|
|
|
|
|
institution: input.institution,
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
|
|
|
|
})
|
feat: applicant onboarding, bulk invite, team management enhancements
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
|
|
|
} else {
|
|
|
|
|
// Update existing user with new profile fields if provided
|
|
|
|
|
const profileUpdates: Record<string, string> = {}
|
|
|
|
|
if (input.nationality && !user.nationality) profileUpdates.nationality = input.nationality
|
|
|
|
|
if (input.country && !user.country) profileUpdates.country = input.country
|
|
|
|
|
if (input.institution && !user.institution) profileUpdates.institution = input.institution
|
|
|
|
|
if (Object.keys(profileUpdates).length > 0) {
|
|
|
|
|
user = await ctx.prisma.user.update({
|
|
|
|
|
where: { id: user.id },
|
|
|
|
|
data: profileUpdates,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (user.status === 'SUSPENDED') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'This user account is suspended and cannot be invited',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const teamLeadName = ctx.user.name?.trim() || 'A team lead'
|
2026-02-23 14:27:58 +01:00
|
|
|
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
2026-02-14 15:26:42 +01:00
|
|
|
const requiresAccountSetup = user.status !== 'ACTIVE'
|
|
|
|
|
|
feat: applicant onboarding, bulk invite, team management enhancements
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
|
|
|
// If sendInvite is false, skip email entirely and leave user as NONE
|
|
|
|
|
if (!input.sendInvite) {
|
|
|
|
|
// No email, no status change — just create team membership below
|
|
|
|
|
} else try {
|
2026-02-14 15:26:42 +01:00
|
|
|
if (requiresAccountSetup) {
|
|
|
|
|
const token = generateInviteToken()
|
|
|
|
|
await ctx.prisma.user.update({
|
|
|
|
|
where: { id: user.id },
|
|
|
|
|
data: {
|
|
|
|
|
status: 'INVITED',
|
|
|
|
|
inviteToken: token,
|
|
|
|
|
inviteTokenExpiresAt: new Date(Date.now() + TEAM_INVITE_TOKEN_EXPIRY_MS),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
|
|
|
|
await sendTeamMemberInviteEmail(
|
|
|
|
|
user.email,
|
|
|
|
|
user.name || input.name,
|
|
|
|
|
project.title,
|
|
|
|
|
teamLeadName,
|
|
|
|
|
inviteUrl
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
await sendStyledNotificationEmail(
|
|
|
|
|
user.email,
|
|
|
|
|
user.name || input.name,
|
|
|
|
|
'TEAM_INVITATION',
|
|
|
|
|
{
|
|
|
|
|
title: 'You were added to a project team',
|
|
|
|
|
message: `${teamLeadName} added you to the project "${project.title}".`,
|
|
|
|
|
linkUrl: `${baseUrl}/applicant/team`,
|
|
|
|
|
linkLabel: 'Open Team',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
projectName: project.title,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
`You've been added to "${project.title}"`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
try {
|
|
|
|
|
await ctx.prisma.notificationLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
channel: 'EMAIL',
|
|
|
|
|
provider: 'SMTP',
|
|
|
|
|
type: 'TEAM_INVITATION',
|
|
|
|
|
status: 'FAILED',
|
|
|
|
|
errorMsg: error instanceof Error ? error.message : 'Unknown error',
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-03-07 16:18:24 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to log failed team invitation notification:', err)
|
2026-02-14 15:26:42 +01:00
|
|
|
// Never fail on notification logging
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'INTERNAL_SERVER_ERROR',
|
|
|
|
|
message: 'Failed to send invitation email. Please try again.',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create team membership
|
|
|
|
|
const teamMember = await ctx.prisma.teamMember.create({
|
|
|
|
|
data: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
userId: user.id,
|
|
|
|
|
role: input.role,
|
|
|
|
|
title: input.title,
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: { id: true, name: true, email: true, status: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await ctx.prisma.notificationLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
channel: 'EMAIL',
|
|
|
|
|
provider: 'SMTP',
|
|
|
|
|
type: 'TEAM_INVITATION',
|
|
|
|
|
status: 'SENT',
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-03-07 16:18:24 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to log sent team invitation notification:', err)
|
2026-02-14 15:26:42 +01:00
|
|
|
// Never fail on notification logging
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await createNotification({
|
|
|
|
|
userId: user.id,
|
|
|
|
|
type: 'TEAM_INVITATION',
|
|
|
|
|
title: 'Team Invitation',
|
|
|
|
|
message: `${teamLeadName} added you to "${project.title}"`,
|
|
|
|
|
linkUrl: '/applicant/team',
|
|
|
|
|
linkLabel: 'View Team',
|
|
|
|
|
priority: 'normal',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
projectName: project.title,
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-03-07 16:18:24 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to create in-app team invitation notification:', err)
|
2026-02-14 15:26:42 +01:00
|
|
|
// Never fail invitation flow on in-app notification issues
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
teamMember,
|
|
|
|
|
inviteEmailSent: true,
|
|
|
|
|
requiresAccountSetup,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove a team member
|
|
|
|
|
*/
|
|
|
|
|
removeTeamMember: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
userId: z.string(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Verify user is team lead
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
role: 'LEAD',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only team leads can remove members',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
// Block rejected projects
|
|
|
|
|
if (await isProjectRejected(ctx.prisma, input.projectId)) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. Team changes are no longer permitted.' })
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
// Can't remove the original submitter
|
|
|
|
|
if (project.submittedByUserId === input.userId) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Cannot remove the original applicant from the team',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.teamMember.deleteMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
userId: input.userId,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Send a message to the assigned mentor
|
|
|
|
|
*/
|
|
|
|
|
sendMentorMessage: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
message: z.string().min(1).max(5000),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Verify user is part of this project team
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: { userId: ctx.user.id },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
mentorAssignment: { select: { mentorId: true } },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Project not found or you do not have access',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!project.mentorAssignment) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'No mentor assigned to this project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mentorMessage = await ctx.prisma.mentorMessage.create({
|
|
|
|
|
data: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
senderId: ctx.user.id,
|
|
|
|
|
message: input.message,
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
sender: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Notify the mentor
|
|
|
|
|
await createNotification({
|
|
|
|
|
userId: project.mentorAssignment.mentorId,
|
|
|
|
|
type: 'MENTOR_MESSAGE',
|
|
|
|
|
title: 'New Message',
|
|
|
|
|
message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`,
|
|
|
|
|
linkUrl: `/mentor/projects/${input.projectId}`,
|
|
|
|
|
linkLabel: 'View Message',
|
|
|
|
|
priority: 'normal',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
projectName: project.title,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return mentorMessage
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get mentor messages for a project (applicant side)
|
|
|
|
|
*/
|
|
|
|
|
getMentorMessages: protectedProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
// Verify user is part of this project team
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{
|
|
|
|
|
teamMembers: {
|
|
|
|
|
some: { userId: ctx.user.id },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Project not found or you do not have access',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const messages = await ctx.prisma.mentorMessage.findMany({
|
|
|
|
|
where: { projectId: input.projectId },
|
|
|
|
|
include: {
|
|
|
|
|
sender: {
|
|
|
|
|
select: { id: true, name: true, email: true, role: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Mark unread messages from mentor as read
|
|
|
|
|
await ctx.prisma.mentorMessage.updateMany({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
senderId: { not: ctx.user.id },
|
|
|
|
|
isRead: false,
|
|
|
|
|
},
|
|
|
|
|
data: { isRead: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return messages
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the applicant's dashboard data: their project (latest edition),
|
|
|
|
|
* team members, open rounds for document submission, and status timeline.
|
|
|
|
|
*/
|
|
|
|
|
getMyDashboard: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only applicants can access this',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find the applicant's project (most recent, from active edition if possible)
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
program: { select: { id: true, name: true, year: true, status: true } },
|
|
|
|
|
files: {
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
},
|
|
|
|
|
teamMembers: {
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: { id: true, name: true, email: true, status: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { joinedAt: 'asc' },
|
|
|
|
|
},
|
|
|
|
|
submittedBy: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
mentorAssignment: {
|
|
|
|
|
include: {
|
|
|
|
|
mentor: {
|
|
|
|
|
select: { id: true, name: true, email: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
wonAwards: {
|
|
|
|
|
select: { id: true, name: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
return { project: null, openRounds: [], timeline: [], currentStatus: null }
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentStatus = project.status ?? 'SUBMITTED'
|
|
|
|
|
|
|
|
|
|
// Fetch status history
|
|
|
|
|
const statusHistory = await ctx.prisma.projectStatusHistory.findMany({
|
|
|
|
|
where: { projectId: project.id },
|
|
|
|
|
orderBy: { changedAt: 'asc' },
|
|
|
|
|
select: { status: true, changedAt: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const statusDateMap = new Map<string, Date>()
|
|
|
|
|
for (const entry of statusHistory) {
|
|
|
|
|
if (!statusDateMap.has(entry.status)) {
|
|
|
|
|
statusDateMap.set(entry.status, entry.changedAt)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
const isRejected = await isProjectRejected(ctx.prisma, project.id)
|
2026-02-14 15:26:42 +01:00
|
|
|
const hasWonAward = project.wonAwards.length > 0
|
|
|
|
|
|
|
|
|
|
// Build timeline
|
|
|
|
|
const timeline = [
|
|
|
|
|
{
|
|
|
|
|
status: 'CREATED',
|
|
|
|
|
label: 'Application Started',
|
|
|
|
|
date: project.createdAt,
|
|
|
|
|
completed: true,
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
status: 'SUBMITTED',
|
|
|
|
|
label: 'Application Submitted',
|
|
|
|
|
date: project.submittedAt || statusDateMap.get('SUBMITTED') || null,
|
|
|
|
|
completed: !!project.submittedAt || statusDateMap.has('SUBMITTED'),
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
status: 'UNDER_REVIEW',
|
|
|
|
|
label: 'Under Review',
|
|
|
|
|
date: statusDateMap.get('ELIGIBLE') || statusDateMap.get('ASSIGNED') ||
|
|
|
|
|
(currentStatus !== 'SUBMITTED' && project.submittedAt ? project.submittedAt : null),
|
|
|
|
|
completed: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(currentStatus),
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
if (isRejected) {
|
|
|
|
|
timeline.push({
|
|
|
|
|
status: 'REJECTED',
|
|
|
|
|
label: 'Not Selected',
|
|
|
|
|
date: statusDateMap.get('REJECTED') || null,
|
|
|
|
|
completed: true,
|
|
|
|
|
isTerminal: true,
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
timeline.push(
|
|
|
|
|
{
|
|
|
|
|
status: 'SEMIFINALIST',
|
|
|
|
|
label: 'Semi-finalist',
|
|
|
|
|
date: statusDateMap.get('SEMIFINALIST') || null,
|
|
|
|
|
completed: ['SEMIFINALIST', 'FINALIST'].includes(currentStatus) || hasWonAward,
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
status: 'FINALIST',
|
|
|
|
|
label: 'Finalist',
|
|
|
|
|
date: statusDateMap.get('FINALIST') || null,
|
|
|
|
|
completed: currentStatus === 'FINALIST' || hasWonAward,
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (hasWonAward) {
|
|
|
|
|
timeline.push({
|
|
|
|
|
status: 'WINNER',
|
|
|
|
|
label: `Winner${project.wonAwards.length > 0 ? ` - ${project.wonAwards[0].name}` : ''}`,
|
|
|
|
|
date: null,
|
|
|
|
|
completed: true,
|
|
|
|
|
isTerminal: false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const programId = project.programId
|
2026-03-04 00:24:33 +01:00
|
|
|
let openRounds: Array<{ id: string; name: string; slug: string | null; roundType: string; windowCloseAt: Date | null }> = []
|
|
|
|
|
if (programId) {
|
|
|
|
|
const allActiveRounds = await ctx.prisma.round.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
competition: { programId },
|
|
|
|
|
status: 'ROUND_ACTIVE',
|
|
|
|
|
},
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
slug: true,
|
|
|
|
|
roundType: true,
|
|
|
|
|
windowCloseAt: true,
|
|
|
|
|
specialAwardId: true,
|
|
|
|
|
specialAward: { select: { name: true } },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Filter rounds based on award track: only show rounds the project is actually in
|
|
|
|
|
const projectRoundIds = new Set(
|
|
|
|
|
(await ctx.prisma.projectRoundState.findMany({
|
|
|
|
|
where: { projectId: project.id },
|
|
|
|
|
select: { roundId: true },
|
|
|
|
|
})).map((prs) => prs.roundId)
|
|
|
|
|
)
|
|
|
|
|
const isInAwardTrack = allActiveRounds.some(
|
|
|
|
|
(r) => r.specialAwardId && projectRoundIds.has(r.id)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
openRounds = allActiveRounds
|
|
|
|
|
.filter((r) => {
|
|
|
|
|
// Award round project isn't in → hide
|
|
|
|
|
if (r.specialAwardId && !projectRoundIds.has(r.id)) return false
|
|
|
|
|
// Main round when project is in award track and has no state in this round → hide
|
|
|
|
|
if (!r.specialAwardId && isInAwardTrack && !projectRoundIds.has(r.id)) return false
|
|
|
|
|
return true
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
2026-03-04 00:24:33 +01:00
|
|
|
.map((r) => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
name: r.specialAward ? `${r.specialAward.name}: ${r.name}` : r.name,
|
|
|
|
|
slug: r.slug,
|
|
|
|
|
roundType: r.roundType,
|
|
|
|
|
windowCloseAt: r.windowCloseAt,
|
|
|
|
|
}))
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
// Determine user's role in the project
|
|
|
|
|
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
|
|
|
|
const isTeamLead = project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD'
|
|
|
|
|
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
// Check if project has passed intake
|
|
|
|
|
const passedIntake = await ctx.prisma.projectRoundState.findFirst({
|
|
|
|
|
where: { projectId: project.id, state: 'PASSED', round: { roundType: 'INTAKE' } },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
// Check if there is an active intake round (applicants can edit project details during intake)
|
|
|
|
|
const activeIntakeRound = await ctx.prisma.round.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
competition: { programId: project.programId },
|
|
|
|
|
roundType: 'INTAKE',
|
|
|
|
|
status: 'ROUND_ACTIVE',
|
|
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Generate presigned logo URL if the project has a logo
|
|
|
|
|
let logoUrl: string | null = null
|
|
|
|
|
if (project.logoKey) {
|
|
|
|
|
const providerType = (project.logoProvider as StorageProviderType) || 's3'
|
|
|
|
|
const provider = createStorageProvider(providerType)
|
|
|
|
|
logoUrl = await provider.getDownloadUrl(project.logoKey)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
return {
|
|
|
|
|
project: {
|
|
|
|
|
...project,
|
|
|
|
|
isTeamLead,
|
|
|
|
|
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
|
|
|
|
|
},
|
2026-03-04 13:29:39 +01:00
|
|
|
openRounds: isRejected ? [] : openRounds,
|
2026-02-14 15:26:42 +01:00
|
|
|
timeline,
|
|
|
|
|
currentStatus,
|
2026-03-04 13:29:39 +01:00
|
|
|
isRejected,
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
hasPassedIntake: !!passedIntake,
|
2026-03-03 19:14:41 +01:00
|
|
|
isIntakeOpen: !!activeIntakeRound,
|
|
|
|
|
logoUrl,
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Lightweight flags for conditional nav rendering.
|
|
|
|
|
*/
|
|
|
|
|
getNavFlags: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
programId: true,
|
|
|
|
|
mentorAssignment: { select: { id: true } },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
return { hasMentor: false, hasEvaluationRounds: false }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if mentor is assigned
|
|
|
|
|
const hasMentor = !!project.mentorAssignment
|
|
|
|
|
|
2026-03-05 17:08:19 +01:00
|
|
|
// Check if feedback is available — first check admin settings, then fall back to per-round config
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
let hasEvaluationRounds = false
|
|
|
|
|
if (project.programId) {
|
2026-03-05 17:08:19 +01:00
|
|
|
// Check admin settings first
|
|
|
|
|
const adminFlags = await ctx.prisma.systemSettings.findMany({
|
|
|
|
|
where: { key: { in: [
|
|
|
|
|
'applicant_show_evaluation_feedback',
|
|
|
|
|
'applicant_show_livefinal_feedback',
|
|
|
|
|
'applicant_show_deliberation_feedback',
|
|
|
|
|
'applicant_hide_feedback_from_rejected',
|
|
|
|
|
] } },
|
|
|
|
|
})
|
|
|
|
|
const adminMap = new Map(adminFlags.map((s) => [s.key, s.value]))
|
|
|
|
|
const adminSettingsExist = adminFlags.length > 0
|
2026-03-04 00:24:33 +01:00
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
const navProjectRejected = await isProjectRejected(ctx.prisma, project.id)
|
2026-03-05 17:08:19 +01:00
|
|
|
|
|
|
|
|
if (adminSettingsExist) {
|
|
|
|
|
// Use admin settings
|
|
|
|
|
if (adminMap.get('applicant_hide_feedback_from_rejected') === 'true' && navProjectRejected) {
|
|
|
|
|
hasEvaluationRounds = false
|
|
|
|
|
} else {
|
|
|
|
|
const anyEnabled = adminMap.get('applicant_show_evaluation_feedback') === 'true'
|
|
|
|
|
|| adminMap.get('applicant_show_livefinal_feedback') === 'true'
|
|
|
|
|
|| adminMap.get('applicant_show_deliberation_feedback') === 'true'
|
|
|
|
|
if (anyEnabled) {
|
|
|
|
|
const enabledTypes: RoundType[] = []
|
|
|
|
|
if (adminMap.get('applicant_show_evaluation_feedback') === 'true') enabledTypes.push('EVALUATION')
|
|
|
|
|
if (adminMap.get('applicant_show_livefinal_feedback') === 'true') enabledTypes.push('LIVE_FINAL')
|
|
|
|
|
if (adminMap.get('applicant_show_deliberation_feedback') === 'true') enabledTypes.push('DELIBERATION')
|
|
|
|
|
|
|
|
|
|
const projectRoundIds = (await ctx.prisma.projectRoundState.findMany({
|
|
|
|
|
where: { projectId: project.id },
|
|
|
|
|
select: { roundId: true },
|
|
|
|
|
})).map((prs) => prs.roundId)
|
|
|
|
|
|
|
|
|
|
hasEvaluationRounds = projectRoundIds.length > 0 && await ctx.prisma.round.count({
|
|
|
|
|
where: {
|
|
|
|
|
competition: { programId: project.programId },
|
|
|
|
|
roundType: { in: enabledTypes },
|
|
|
|
|
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
|
|
|
|
|
id: { in: projectRoundIds },
|
|
|
|
|
},
|
|
|
|
|
}) > 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Fall back to old per-round config
|
|
|
|
|
const projectRoundIds = new Set(
|
|
|
|
|
(await ctx.prisma.projectRoundState.findMany({
|
|
|
|
|
where: { projectId: project.id },
|
|
|
|
|
select: { roundId: true },
|
|
|
|
|
})).map((prs) => prs.roundId)
|
|
|
|
|
)
|
|
|
|
|
const closedEvalRounds = projectRoundIds.size > 0
|
|
|
|
|
? await ctx.prisma.round.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
competition: { programId: project.programId },
|
|
|
|
|
roundType: 'EVALUATION',
|
|
|
|
|
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
|
|
|
|
|
id: { in: [...projectRoundIds] },
|
|
|
|
|
},
|
|
|
|
|
select: { configJson: true },
|
|
|
|
|
})
|
|
|
|
|
: []
|
|
|
|
|
hasEvaluationRounds = closedEvalRounds.some((r) => {
|
|
|
|
|
const parsed = EvaluationConfigSchema.safeParse(r.configJson)
|
|
|
|
|
if (!parsed.success || !parsed.data.applicantVisibility.enabled) return false
|
|
|
|
|
if (parsed.data.applicantVisibility.hideFromRejected && navProjectRejected) return false
|
|
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
}
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { hasMentor, hasEvaluationRounds }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Filtered competition timeline showing only EVALUATION + Grand Finale.
|
|
|
|
|
* Hides FILTERING/INTAKE/SUBMISSION/MENTORING from applicants.
|
|
|
|
|
*/
|
|
|
|
|
getMyCompetitionTimeline: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
select: { id: true, programId: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project?.programId) {
|
|
|
|
|
return { competitionName: null, entries: [] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find competition via programId (fixes the programId/competitionId bug)
|
|
|
|
|
const competition = await ctx.prisma.competition.findFirst({
|
|
|
|
|
where: { programId: project.programId },
|
|
|
|
|
select: { id: true, name: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!competition) {
|
|
|
|
|
return { competitionName: null, entries: [] }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 23:42:21 +01:00
|
|
|
// Get all rounds ordered by sortOrder (including award rounds in same competition)
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
const rounds = await ctx.prisma.round.findMany({
|
|
|
|
|
where: { competitionId: competition.id },
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
roundType: true,
|
|
|
|
|
status: true,
|
|
|
|
|
windowOpenAt: true,
|
|
|
|
|
windowCloseAt: true,
|
2026-03-03 23:42:21 +01:00
|
|
|
specialAwardId: true,
|
|
|
|
|
specialAward: { select: { name: true } },
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get all ProjectRoundState for this project
|
|
|
|
|
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
|
|
|
|
where: { projectId: project.id },
|
|
|
|
|
select: { roundId: true, state: true },
|
|
|
|
|
})
|
|
|
|
|
const stateMap = new Map(projectStates.map((ps) => [ps.roundId, ps.state]))
|
|
|
|
|
|
|
|
|
|
type TimelineEntry = {
|
|
|
|
|
id: string
|
|
|
|
|
label: string
|
2026-03-03 19:14:41 +01:00
|
|
|
roundType: string
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
status: string
|
|
|
|
|
windowOpenAt: Date | null
|
|
|
|
|
windowCloseAt: Date | null
|
|
|
|
|
projectState: string | null
|
|
|
|
|
isSynthesizedRejection: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const entries: TimelineEntry[] = []
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
// Build lookup for filtering rounds
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
const filteringRounds = rounds.filter((r) => r.roundType === 'FILTERING')
|
|
|
|
|
const liveFinalRounds = rounds.filter((r) => r.roundType === 'LIVE_FINAL')
|
|
|
|
|
const deliberationRounds = rounds.filter((r) => r.roundType === 'DELIBERATION')
|
|
|
|
|
|
2026-03-03 23:42:21 +01:00
|
|
|
// Check if this project is in any SEPARATE_POOL award track
|
|
|
|
|
const projectAwardRoundIds = new Set(
|
|
|
|
|
rounds.filter((r) => r.specialAwardId && stateMap.has(r.id)).map((r) => r.id)
|
|
|
|
|
)
|
|
|
|
|
const projectAwardIds = new Set(
|
|
|
|
|
rounds.filter((r) => r.specialAwardId && stateMap.has(r.id)).map((r) => r.specialAwardId!)
|
|
|
|
|
)
|
|
|
|
|
const isInAwardTrack = projectAwardRoundIds.size > 0
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
// Process visible rounds: hide FILTERING, LIVE_FINAL, DELIBERATION always.
|
|
|
|
|
// Also hide MENTORING unless the project is actually participating in it.
|
2026-03-03 23:42:21 +01:00
|
|
|
// For award rounds: only show ones the project is in. For main rounds after
|
|
|
|
|
// the split point: hide if project isn't in them and is in an award track.
|
2026-03-03 19:14:41 +01:00
|
|
|
const visibleRounds = rounds.filter(
|
|
|
|
|
(r) => {
|
|
|
|
|
if (r.roundType === 'FILTERING' || r.roundType === 'LIVE_FINAL' || r.roundType === 'DELIBERATION') return false
|
|
|
|
|
if (r.roundType === 'MENTORING' && !stateMap.has(r.id)) return false
|
2026-03-03 23:42:21 +01:00
|
|
|
// Award round that project is NOT in → hide
|
|
|
|
|
if (r.specialAwardId && !stateMap.has(r.id)) return false
|
|
|
|
|
// Award round for a different award → hide
|
|
|
|
|
if (r.specialAwardId && !projectAwardIds.has(r.specialAwardId)) return false
|
|
|
|
|
// Main competition round where project has no state AND project is in award track → hide
|
|
|
|
|
if (!r.specialAwardId && isInAwardTrack && !stateMap.has(r.id)) return false
|
2026-03-03 19:14:41 +01:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for (const round of visibleRounds) {
|
|
|
|
|
const actualState = stateMap.get(round.id) ?? null
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
// Check if a FILTERING round before this round rejected the project
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
let projectState = actualState
|
|
|
|
|
let isSynthesizedRejection = false
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
const roundSortOrder = rounds.findIndex((r) => r.id === round.id)
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
const precedingFilterRounds = filteringRounds.filter((fr) => {
|
|
|
|
|
const frIdx = rounds.findIndex((r) => r.id === fr.id)
|
2026-03-03 19:14:41 +01:00
|
|
|
return frIdx < roundSortOrder
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for (const fr of precedingFilterRounds) {
|
|
|
|
|
const filterState = stateMap.get(fr.id)
|
|
|
|
|
if (filterState === 'REJECTED') {
|
|
|
|
|
projectState = 'REJECTED'
|
|
|
|
|
isSynthesizedRejection = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if ((filterState === 'IN_PROGRESS' || filterState === 'PENDING') && !actualState) {
|
|
|
|
|
projectState = 'IN_PROGRESS'
|
|
|
|
|
isSynthesizedRejection = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
entries.push({
|
2026-03-03 19:14:41 +01:00
|
|
|
id: round.id,
|
2026-03-03 23:42:21 +01:00
|
|
|
label: round.specialAward ? `${round.specialAward.name}: ${round.name}` : round.name,
|
2026-03-03 19:14:41 +01:00
|
|
|
roundType: round.roundType,
|
|
|
|
|
status: round.status,
|
|
|
|
|
windowOpenAt: round.windowOpenAt,
|
|
|
|
|
windowCloseAt: round.windowCloseAt,
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
projectState,
|
|
|
|
|
isSynthesizedRejection,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Grand Finale: combine LIVE_FINAL + DELIBERATION
|
|
|
|
|
if (liveFinalRounds.length > 0 || deliberationRounds.length > 0) {
|
|
|
|
|
const grandFinaleRounds = [...liveFinalRounds, ...deliberationRounds]
|
|
|
|
|
|
|
|
|
|
// Project state: prefer LIVE_FINAL state, then DELIBERATION
|
|
|
|
|
let gfState: string | null = null
|
|
|
|
|
for (const lfr of liveFinalRounds) {
|
|
|
|
|
const s = stateMap.get(lfr.id)
|
|
|
|
|
if (s) { gfState = s; break }
|
|
|
|
|
}
|
|
|
|
|
if (!gfState) {
|
|
|
|
|
for (const dr of deliberationRounds) {
|
|
|
|
|
const s = stateMap.get(dr.id)
|
|
|
|
|
if (s) { gfState = s; break }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Status: most advanced status among grouped rounds
|
|
|
|
|
const statusPriority: Record<string, number> = {
|
|
|
|
|
ROUND_ARCHIVED: 3,
|
|
|
|
|
ROUND_CLOSED: 2,
|
|
|
|
|
ROUND_ACTIVE: 1,
|
|
|
|
|
ROUND_DRAFT: 0,
|
|
|
|
|
}
|
|
|
|
|
let gfStatus = 'ROUND_DRAFT'
|
|
|
|
|
for (const r of grandFinaleRounds) {
|
|
|
|
|
if ((statusPriority[r.status] ?? 0) > (statusPriority[gfStatus] ?? 0)) {
|
|
|
|
|
gfStatus = r.status
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use earliest window open and latest window close
|
|
|
|
|
const openDates = grandFinaleRounds.map((r) => r.windowOpenAt).filter(Boolean) as Date[]
|
|
|
|
|
const closeDates = grandFinaleRounds.map((r) => r.windowCloseAt).filter(Boolean) as Date[]
|
|
|
|
|
|
|
|
|
|
// Check if a prior filtering rejection should propagate
|
|
|
|
|
let isSynthesizedRejection = false
|
|
|
|
|
const gfSortOrder = Math.min(
|
|
|
|
|
...grandFinaleRounds.map((r) => rounds.findIndex((rr) => rr.id === r.id))
|
|
|
|
|
)
|
|
|
|
|
for (const fr of filteringRounds) {
|
|
|
|
|
const frIdx = rounds.findIndex((r) => r.id === fr.id)
|
|
|
|
|
if (frIdx < gfSortOrder && stateMap.get(fr.id) === 'REJECTED') {
|
|
|
|
|
gfState = 'REJECTED'
|
|
|
|
|
isSynthesizedRejection = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
entries.push({
|
|
|
|
|
id: 'grand-finale',
|
|
|
|
|
label: 'Grand Finale',
|
|
|
|
|
roundType: 'GRAND_FINALE',
|
|
|
|
|
status: gfStatus,
|
|
|
|
|
windowOpenAt: openDates.length > 0 ? new Date(Math.min(...openDates.map((d) => d.getTime()))) : null,
|
|
|
|
|
windowCloseAt: closeDates.length > 0 ? new Date(Math.max(...closeDates.map((d) => d.getTime()))) : null,
|
|
|
|
|
projectState: gfState,
|
|
|
|
|
isSynthesizedRejection,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle projects manually created at a non-intake round:
|
|
|
|
|
// If a project has state in a later round but not earlier, mark prior rounds as PASSED.
|
|
|
|
|
// Find the earliest visible entry (EVALUATION or GRAND_FINALE) that has a real state.
|
|
|
|
|
const firstEntryWithState = entries.findIndex(
|
|
|
|
|
(e) => e.projectState !== null && !e.isSynthesizedRejection
|
|
|
|
|
)
|
|
|
|
|
if (firstEntryWithState > 0) {
|
|
|
|
|
// All entries before the first real state should show as PASSED (if the round is closed/archived)
|
|
|
|
|
for (let i = 0; i < firstEntryWithState; i++) {
|
|
|
|
|
const entry = entries[i]
|
|
|
|
|
if (!entry.projectState) {
|
|
|
|
|
const roundClosed = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
|
|
|
|
if (roundClosed) {
|
|
|
|
|
entry.projectState = 'PASSED'
|
|
|
|
|
entry.isSynthesizedRejection = false // not a rejection, it's a synthesized pass
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
|
|
|
|
|
// If the project was rejected in filtering and there are entries after,
|
|
|
|
|
// null-out states for entries after the rejection point
|
|
|
|
|
let foundRejection = false
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (foundRejection) {
|
|
|
|
|
entry.projectState = null
|
|
|
|
|
}
|
|
|
|
|
if (entry.projectState === 'REJECTED' && entry.isSynthesizedRejection) {
|
|
|
|
|
foundRejection = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { competitionName: competition.name, entries }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get anonymous jury evaluations visible to the applicant.
|
2026-03-05 16:50:20 +01:00
|
|
|
* Reads visibility config from admin SystemSettings (not per-round configJson).
|
|
|
|
|
* Supports EVALUATION, LIVE_FINAL, and DELIBERATION round types.
|
|
|
|
|
* NEVER leaks juror identity.
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
*/
|
|
|
|
|
getMyEvaluations: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 16:50:20 +01:00
|
|
|
// Load admin visibility settings
|
|
|
|
|
const visKeys = [
|
|
|
|
|
'applicant_show_evaluation_feedback', 'applicant_show_evaluation_scores',
|
|
|
|
|
'applicant_show_evaluation_criteria', 'applicant_show_evaluation_text',
|
|
|
|
|
'applicant_show_livefinal_feedback', 'applicant_show_livefinal_scores',
|
|
|
|
|
'applicant_show_deliberation_feedback',
|
|
|
|
|
'applicant_hide_feedback_from_rejected',
|
|
|
|
|
]
|
|
|
|
|
const settingsRows = await ctx.prisma.systemSettings.findMany({
|
|
|
|
|
where: { key: { in: visKeys } },
|
|
|
|
|
})
|
|
|
|
|
const sMap = new Map(settingsRows.map((s) => [s.key, s.value]))
|
|
|
|
|
const adminSettingsExist = settingsRows.length > 0
|
|
|
|
|
const flag = (k: string) => sMap.get(k) === 'true'
|
|
|
|
|
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
select: { id: true, programId: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project?.programId) return []
|
|
|
|
|
|
2026-03-04 00:24:33 +01:00
|
|
|
const projectRoundIds = new Set(
|
|
|
|
|
(await ctx.prisma.projectRoundState.findMany({
|
|
|
|
|
where: { projectId: project.id },
|
|
|
|
|
select: { roundId: true },
|
|
|
|
|
})).map((prs) => prs.roundId)
|
|
|
|
|
)
|
2026-03-04 00:27:53 +01:00
|
|
|
if (projectRoundIds.size === 0) return []
|
|
|
|
|
|
2026-03-05 16:50:20 +01:00
|
|
|
type ResultItem = {
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
roundId: string
|
|
|
|
|
roundName: string
|
2026-03-05 16:50:20 +01:00
|
|
|
roundType: string
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
evaluationCount: number
|
|
|
|
|
evaluations: Array<{
|
|
|
|
|
id: string
|
|
|
|
|
submittedAt: Date | null
|
|
|
|
|
globalScore: number | null
|
|
|
|
|
criterionScores: Prisma.JsonValue | null
|
|
|
|
|
feedbackText: string | null
|
|
|
|
|
criteria: Prisma.JsonValue | null
|
|
|
|
|
}>
|
2026-03-05 16:50:20 +01:00
|
|
|
}
|
|
|
|
|
const results: ResultItem[] = []
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
|
2026-03-05 16:50:20 +01:00
|
|
|
// --- Backwards compatibility: if no admin settings exist yet, fall back to
|
|
|
|
|
// the old per-round applicantVisibility config for EVALUATION rounds ---
|
|
|
|
|
if (!adminSettingsExist) {
|
|
|
|
|
const evalRounds = await ctx.prisma.round.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
competition: { programId: project.programId },
|
|
|
|
|
roundType: 'EVALUATION',
|
|
|
|
|
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
|
|
|
|
|
id: { in: [...projectRoundIds] },
|
|
|
|
|
},
|
|
|
|
|
select: { id: true, name: true, configJson: true },
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
})
|
2026-03-04 13:29:39 +01:00
|
|
|
|
2026-03-05 16:50:20 +01:00
|
|
|
const projectIsRejected = await isProjectRejected(ctx.prisma, project.id)
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
|
2026-03-05 16:50:20 +01:00
|
|
|
for (let i = 0; i < evalRounds.length; i++) {
|
|
|
|
|
const round = evalRounds[i]
|
|
|
|
|
const parsed = EvaluationConfigSchema.safeParse(round.configJson)
|
|
|
|
|
if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue
|
|
|
|
|
if (parsed.data.applicantVisibility.hideFromRejected && projectIsRejected) continue
|
|
|
|
|
const vis = parsed.data.applicantVisibility
|
2026-03-04 13:29:39 +01:00
|
|
|
|
2026-03-05 16:50:20 +01:00
|
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
assignment: { projectId: project.id, roundId: round.id },
|
|
|
|
|
status: { in: ['SUBMITTED', 'LOCKED'] },
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true, submittedAt: true,
|
|
|
|
|
globalScore: vis.showGlobalScore,
|
|
|
|
|
criterionScoresJson: vis.showCriterionScores,
|
|
|
|
|
feedbackText: vis.showFeedbackText,
|
|
|
|
|
form: vis.showCriterionScores ? { select: { criteriaJson: true } } : false,
|
|
|
|
|
},
|
|
|
|
|
orderBy: { submittedAt: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
if (evaluations.length === 0) continue
|
|
|
|
|
|
|
|
|
|
results.push({
|
|
|
|
|
roundId: round.id,
|
|
|
|
|
roundName: `Evaluation Round ${i + 1}`,
|
|
|
|
|
roundType: 'EVALUATION',
|
|
|
|
|
evaluationCount: evaluations.length,
|
|
|
|
|
evaluations: evaluations.map((ev) => ({
|
|
|
|
|
id: ev.id,
|
|
|
|
|
submittedAt: ev.submittedAt,
|
|
|
|
|
globalScore: vis.showGlobalScore ? (ev as { globalScore?: number | null }).globalScore ?? null : null,
|
|
|
|
|
criterionScores: vis.showCriterionScores ? (ev as { criterionScoresJson?: Prisma.JsonValue }).criterionScoresJson ?? null : null,
|
|
|
|
|
feedbackText: vis.showFeedbackText ? (ev as { feedbackText?: string | null }).feedbackText ?? null : null,
|
|
|
|
|
criteria: vis.showCriterionScores ? ((ev as { form?: { criteriaJson: Prisma.JsonValue } | null }).form?.criteriaJson ?? null) : null,
|
|
|
|
|
})),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return results
|
|
|
|
|
}
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
|
2026-03-05 16:50:20 +01:00
|
|
|
// --- New admin settings flow ---
|
|
|
|
|
const evalEnabled = flag('applicant_show_evaluation_feedback')
|
|
|
|
|
const evalShowScores = flag('applicant_show_evaluation_scores')
|
|
|
|
|
const evalShowCriteria = flag('applicant_show_evaluation_criteria')
|
|
|
|
|
const evalShowText = flag('applicant_show_evaluation_text')
|
|
|
|
|
const liveFinalEnabled = flag('applicant_show_livefinal_feedback')
|
|
|
|
|
const liveFinalShowScores = flag('applicant_show_livefinal_scores')
|
|
|
|
|
const deliberationEnabled = flag('applicant_show_deliberation_feedback')
|
|
|
|
|
const hideFromRejected = flag('applicant_hide_feedback_from_rejected')
|
|
|
|
|
|
|
|
|
|
if (!evalEnabled && !liveFinalEnabled && !deliberationEnabled) return []
|
|
|
|
|
|
|
|
|
|
const projectIsRejected = hideFromRejected ? await isProjectRejected(ctx.prisma, project.id) : false
|
|
|
|
|
if (projectIsRejected) return []
|
|
|
|
|
|
|
|
|
|
// Build round type filter
|
|
|
|
|
const enabledTypes: RoundType[] = []
|
|
|
|
|
if (evalEnabled) enabledTypes.push('EVALUATION')
|
|
|
|
|
if (liveFinalEnabled) enabledTypes.push('LIVE_FINAL')
|
|
|
|
|
if (deliberationEnabled) enabledTypes.push('DELIBERATION')
|
|
|
|
|
|
|
|
|
|
const rounds = await ctx.prisma.round.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
competition: { programId: project.programId },
|
|
|
|
|
roundType: { in: enabledTypes },
|
|
|
|
|
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
|
|
|
|
|
id: { in: [...projectRoundIds] },
|
|
|
|
|
},
|
|
|
|
|
select: { id: true, name: true, roundType: true },
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
let evalCounter = 0
|
|
|
|
|
let liveFinalCounter = 0
|
|
|
|
|
let deliberationCounter = 0
|
|
|
|
|
|
|
|
|
|
for (const round of rounds) {
|
|
|
|
|
if (round.roundType === 'EVALUATION') {
|
|
|
|
|
evalCounter++
|
|
|
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
assignment: { projectId: project.id, roundId: round.id },
|
|
|
|
|
status: { in: ['SUBMITTED', 'LOCKED'] },
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
submittedAt: true,
|
|
|
|
|
globalScore: evalShowScores,
|
|
|
|
|
criterionScoresJson: evalShowCriteria,
|
|
|
|
|
feedbackText: evalShowText,
|
|
|
|
|
form: evalShowCriteria ? { select: { criteriaJson: true } } : false,
|
|
|
|
|
},
|
|
|
|
|
orderBy: { submittedAt: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
if (evaluations.length === 0) continue
|
|
|
|
|
|
|
|
|
|
results.push({
|
|
|
|
|
roundId: round.id,
|
|
|
|
|
roundName: `Evaluation Round ${evalCounter}`,
|
|
|
|
|
roundType: 'EVALUATION',
|
|
|
|
|
evaluationCount: evaluations.length,
|
|
|
|
|
evaluations: evaluations.map((ev) => ({
|
|
|
|
|
id: ev.id,
|
|
|
|
|
submittedAt: ev.submittedAt,
|
|
|
|
|
globalScore: evalShowScores ? (ev as { globalScore?: number | null }).globalScore ?? null : null,
|
|
|
|
|
criterionScores: evalShowCriteria ? (ev as { criterionScoresJson?: Prisma.JsonValue }).criterionScoresJson ?? null : null,
|
|
|
|
|
feedbackText: evalShowText ? (ev as { feedbackText?: string | null }).feedbackText ?? null : null,
|
|
|
|
|
criteria: evalShowCriteria ? ((ev as { form?: { criteriaJson: Prisma.JsonValue } | null }).form?.criteriaJson ?? null) : null,
|
|
|
|
|
})),
|
|
|
|
|
})
|
|
|
|
|
} else if (round.roundType === 'LIVE_FINAL') {
|
|
|
|
|
liveFinalCounter++
|
|
|
|
|
// LiveVote scores — anonymized
|
|
|
|
|
// Only show jury votes, not audience votes
|
|
|
|
|
const votes = await ctx.prisma.liveVote.findMany({
|
|
|
|
|
where: {
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
projectId: project.id,
|
2026-03-05 16:50:20 +01:00
|
|
|
session: { roundId: round.id },
|
|
|
|
|
isAudienceVote: false,
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
},
|
2026-03-05 16:50:20 +01:00
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
score: true,
|
|
|
|
|
votedAt: true,
|
|
|
|
|
},
|
|
|
|
|
orderBy: { votedAt: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
if (votes.length === 0) continue
|
|
|
|
|
|
|
|
|
|
results.push({
|
|
|
|
|
roundId: round.id,
|
|
|
|
|
roundName: `Live Final ${liveFinalCounter}`,
|
|
|
|
|
roundType: 'LIVE_FINAL',
|
|
|
|
|
evaluationCount: votes.length,
|
|
|
|
|
evaluations: votes.map((v) => ({
|
|
|
|
|
id: v.id,
|
|
|
|
|
submittedAt: v.votedAt,
|
|
|
|
|
globalScore: liveFinalShowScores ? v.score : null,
|
|
|
|
|
criterionScores: null,
|
|
|
|
|
feedbackText: null,
|
|
|
|
|
criteria: null,
|
|
|
|
|
})),
|
|
|
|
|
})
|
|
|
|
|
} else if (round.roundType === 'DELIBERATION') {
|
|
|
|
|
deliberationCounter++
|
|
|
|
|
// DeliberationVote — per-juror votes for this project
|
|
|
|
|
const votes = await ctx.prisma.deliberationVote.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
session: { roundId: round.id },
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
runoffRound: 0,
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
createdAt: true,
|
|
|
|
|
rank: true,
|
|
|
|
|
isWinnerPick: true,
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
if (votes.length === 0) continue
|
|
|
|
|
|
|
|
|
|
results.push({
|
|
|
|
|
roundId: round.id,
|
|
|
|
|
roundName: `Deliberation ${deliberationCounter}`,
|
|
|
|
|
roundType: 'DELIBERATION',
|
|
|
|
|
evaluationCount: votes.length,
|
|
|
|
|
evaluations: votes.map((v) => ({
|
|
|
|
|
id: v.id,
|
|
|
|
|
submittedAt: v.createdAt,
|
|
|
|
|
globalScore: v.rank,
|
|
|
|
|
criterionScores: null,
|
|
|
|
|
feedbackText: v.isWinnerPick ? 'Selected as winner' : (v.rank ? `Ranked #${v.rank}` : null),
|
|
|
|
|
criteria: null,
|
|
|
|
|
})),
|
|
|
|
|
})
|
|
|
|
|
}
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return results
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Upcoming deadlines for dashboard card.
|
|
|
|
|
*/
|
|
|
|
|
getUpcomingDeadlines: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
2026-03-04 00:24:33 +01:00
|
|
|
select: { id: true, programId: true },
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project?.programId) return []
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const rounds = await ctx.prisma.round.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
competition: { programId: project.programId },
|
|
|
|
|
status: 'ROUND_ACTIVE',
|
|
|
|
|
windowCloseAt: { gt: now },
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
windowCloseAt: true,
|
2026-03-04 00:24:33 +01:00
|
|
|
specialAwardId: true,
|
|
|
|
|
specialAward: { select: { name: true } },
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
},
|
|
|
|
|
orderBy: { windowCloseAt: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-04 00:24:33 +01:00
|
|
|
// Filter by award track membership
|
2026-03-04 00:27:53 +01:00
|
|
|
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
|
|
|
|
where: { projectId: project.id },
|
|
|
|
|
select: { roundId: true, round: { select: { specialAwardId: true } } },
|
|
|
|
|
})
|
|
|
|
|
const projectRoundIds = new Set(projectStates.map((prs) => prs.roundId))
|
|
|
|
|
const isInAwardTrack = projectStates.some((prs) => prs.round.specialAwardId)
|
2026-03-04 00:24:33 +01:00
|
|
|
|
|
|
|
|
return rounds
|
|
|
|
|
.filter((r) => {
|
|
|
|
|
if (r.specialAwardId && !projectRoundIds.has(r.id)) return false
|
|
|
|
|
if (!r.specialAwardId && isInAwardTrack && !projectRoundIds.has(r.id)) return false
|
|
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
.map((r) => ({
|
|
|
|
|
roundName: r.specialAward ? `${r.specialAward.name}: ${r.name}` : r.name,
|
|
|
|
|
windowCloseAt: r.windowCloseAt!,
|
|
|
|
|
}))
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Document completeness progress for dashboard card.
|
|
|
|
|
*/
|
|
|
|
|
getDocumentCompleteness: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
select: { id: true, programId: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project?.programId) return []
|
|
|
|
|
|
|
|
|
|
// Find active rounds with file requirements
|
2026-03-04 00:24:33 +01:00
|
|
|
const allRounds = await ctx.prisma.round.findMany({
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
where: {
|
|
|
|
|
competition: { programId: project.programId },
|
|
|
|
|
status: 'ROUND_ACTIVE',
|
|
|
|
|
fileRequirements: { some: {} },
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
2026-03-04 00:24:33 +01:00
|
|
|
specialAwardId: true,
|
|
|
|
|
specialAward: { select: { name: true } },
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
fileRequirements: {
|
|
|
|
|
select: { id: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-04 00:24:33 +01:00
|
|
|
// Filter by award track membership
|
|
|
|
|
const projectRoundIds = new Set(
|
|
|
|
|
(await ctx.prisma.projectRoundState.findMany({
|
|
|
|
|
where: { projectId: project.id },
|
|
|
|
|
select: { roundId: true },
|
|
|
|
|
})).map((prs) => prs.roundId)
|
|
|
|
|
)
|
|
|
|
|
const isInAwardTrack = allRounds.some(
|
|
|
|
|
(r) => r.specialAwardId && projectRoundIds.has(r.id)
|
|
|
|
|
)
|
|
|
|
|
const rounds = allRounds.filter((r) => {
|
|
|
|
|
if (r.specialAwardId && !projectRoundIds.has(r.id)) return false
|
|
|
|
|
if (!r.specialAwardId && isInAwardTrack && !projectRoundIds.has(r.id)) return false
|
|
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
const results: Array<{ roundId: string; roundName: string; required: number; uploaded: number }> = []
|
|
|
|
|
|
|
|
|
|
for (const round of rounds) {
|
|
|
|
|
const requirementIds = round.fileRequirements.map((fr) => fr.id)
|
|
|
|
|
if (requirementIds.length === 0) continue
|
|
|
|
|
|
|
|
|
|
const uploaded = await ctx.prisma.projectFile.count({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
requirementId: { in: requirementIds },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
results.push({
|
|
|
|
|
roundId: round.id,
|
2026-03-04 00:24:33 +01:00
|
|
|
roundName: round.specialAward ? `${round.specialAward.name}: ${round.name}` : round.name,
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
required: requirementIds.length,
|
|
|
|
|
uploaded,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return results
|
2026-02-14 15:26:42 +01:00
|
|
|
}),
|
feat: applicant onboarding, bulk invite, team management enhancements
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get onboarding context for applicant wizard — project info, institution, logo status.
|
|
|
|
|
*/
|
|
|
|
|
getOnboardingContext: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
title: true,
|
|
|
|
|
institution: true,
|
|
|
|
|
logoKey: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) return null
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
projectTitle: project.title,
|
|
|
|
|
institution: project.institution,
|
|
|
|
|
hasLogo: !!project.logoKey,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a pre-signed URL for uploading a project logo (applicant access).
|
|
|
|
|
*/
|
|
|
|
|
getProjectLogoUploadUrl: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
fileName: z.string(),
|
|
|
|
|
contentType: z.string(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Verify team membership
|
|
|
|
|
const isMember = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!isMember) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 13:29:39 +01:00
|
|
|
// Block rejected projects
|
|
|
|
|
if (await isProjectRejected(ctx.prisma, input.projectId)) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. Logo changes are no longer permitted.' })
|
|
|
|
|
}
|
|
|
|
|
|
feat: applicant onboarding, bulk invite, team management enhancements
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
|
|
|
return getImageUploadUrl(
|
|
|
|
|
input.projectId,
|
|
|
|
|
input.fileName,
|
|
|
|
|
input.contentType,
|
|
|
|
|
generateLogoKey
|
|
|
|
|
)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Confirm project logo upload (applicant access).
|
|
|
|
|
*/
|
|
|
|
|
confirmProjectLogo: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
key: z.string(),
|
|
|
|
|
providerType: z.enum(['s3', 'local']),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Verify team membership
|
|
|
|
|
const isMember = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!isMember) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const logoConfig: ImageUploadConfig<{ logoKey: string | null; logoProvider: string | null }> = {
|
|
|
|
|
label: 'logo',
|
|
|
|
|
generateKey: generateLogoKey,
|
|
|
|
|
findCurrent: (prisma, entityId) =>
|
|
|
|
|
prisma.project.findUnique({
|
|
|
|
|
where: { id: entityId },
|
|
|
|
|
select: { logoKey: true, logoProvider: true },
|
|
|
|
|
}),
|
|
|
|
|
getImageKey: (record) => record.logoKey,
|
|
|
|
|
getProviderType: (record) =>
|
|
|
|
|
(record.logoProvider as StorageProviderType) || 's3',
|
|
|
|
|
setImage: (prisma, entityId, key, providerType) =>
|
|
|
|
|
prisma.project.update({
|
|
|
|
|
where: { id: entityId },
|
|
|
|
|
data: { logoKey: key, logoProvider: providerType },
|
|
|
|
|
}),
|
|
|
|
|
clearImage: (prisma, entityId) =>
|
|
|
|
|
prisma.project.update({
|
|
|
|
|
where: { id: entityId },
|
|
|
|
|
data: { logoKey: null, logoProvider: null },
|
|
|
|
|
}),
|
|
|
|
|
auditEntityType: 'Project',
|
|
|
|
|
auditFieldName: 'logoKey',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await confirmImageUpload(ctx.prisma, logoConfig, input.projectId, input.key, input.providerType, {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
ip: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
/**
|
|
|
|
|
* Delete project logo (applicant access).
|
|
|
|
|
*/
|
|
|
|
|
deleteProjectLogo: protectedProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const isMember = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: input.projectId,
|
|
|
|
|
OR: [
|
|
|
|
|
{ submittedByUserId: ctx.user.id },
|
|
|
|
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!isMember) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const logoConfig: ImageUploadConfig<{ logoKey: string | null; logoProvider: string | null }> = {
|
|
|
|
|
label: 'logo',
|
|
|
|
|
generateKey: generateLogoKey,
|
|
|
|
|
findCurrent: (prisma, entityId) =>
|
|
|
|
|
prisma.project.findUnique({
|
|
|
|
|
where: { id: entityId },
|
|
|
|
|
select: { logoKey: true, logoProvider: true },
|
|
|
|
|
}),
|
|
|
|
|
getImageKey: (record) => record.logoKey,
|
|
|
|
|
getProviderType: (record) =>
|
|
|
|
|
(record.logoProvider as StorageProviderType) || 's3',
|
|
|
|
|
setImage: (prisma, entityId, key, providerType) =>
|
|
|
|
|
prisma.project.update({
|
|
|
|
|
where: { id: entityId },
|
|
|
|
|
data: { logoKey: key, logoProvider: providerType },
|
|
|
|
|
}),
|
|
|
|
|
clearImage: (prisma, entityId) =>
|
|
|
|
|
prisma.project.update({
|
|
|
|
|
where: { id: entityId },
|
|
|
|
|
data: { logoKey: null, logoProvider: null },
|
|
|
|
|
}),
|
|
|
|
|
auditEntityType: 'Project',
|
|
|
|
|
auditFieldName: 'logoKey',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return deleteImage(ctx.prisma, logoConfig, input.projectId, {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
ip: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
feat: applicant onboarding, bulk invite, team management enhancements
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
|
|
|
/**
|
|
|
|
|
* Get project logo URL (applicant access).
|
|
|
|
|
*/
|
|
|
|
|
getProjectLogoUrl: protectedProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const logoConfig = {
|
|
|
|
|
findCurrent: (prisma: typeof ctx.prisma, entityId: string) =>
|
|
|
|
|
prisma.project.findUnique({
|
|
|
|
|
where: { id: entityId },
|
|
|
|
|
select: { logoKey: true, logoProvider: true },
|
|
|
|
|
}),
|
|
|
|
|
getImageKey: (record: { logoKey: string | null }) => record.logoKey,
|
|
|
|
|
getProviderType: (record: { logoProvider: string | null }) =>
|
|
|
|
|
(record.logoProvider as StorageProviderType) || 's3' as StorageProviderType,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
|
|
|
|
|
}),
|
2026-03-03 19:14:41 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Withdraw from competition. Only team lead can withdraw.
|
|
|
|
|
* Finds the current active (non-terminal) ProjectRoundState and transitions to WITHDRAWN.
|
|
|
|
|
*/
|
|
|
|
|
/**
|
|
|
|
|
* Get mentoring request status for a project in a MENTORING round
|
|
|
|
|
*/
|
|
|
|
|
getMentoringRequestStatus: protectedProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string(), roundId: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const round = await ctx.prisma.round.findUnique({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: { id: true, roundType: true, status: true, configJson: true, windowOpenAt: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!round || round.roundType !== 'MENTORING') {
|
|
|
|
|
return { available: false, requested: false, requestedAt: null, deadline: null, canStillRequest: false }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const config = MentoringConfigSchema.safeParse(round.configJson)
|
|
|
|
|
const deadlineDays = config.success ? config.data.mentoringRequestDeadlineDays : 14
|
|
|
|
|
|
|
|
|
|
const deadline = round.windowOpenAt
|
|
|
|
|
? new Date(new Date(round.windowOpenAt).getTime() + deadlineDays * 24 * 60 * 60 * 1000)
|
|
|
|
|
: null
|
|
|
|
|
|
|
|
|
|
const canStillRequest = round.status === 'ROUND_ACTIVE' && (!deadline || new Date() < deadline)
|
|
|
|
|
|
|
|
|
|
const prs = await ctx.prisma.projectRoundState.findUnique({
|
|
|
|
|
where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
|
|
|
|
|
select: { metadataJson: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const metadata = (prs?.metadataJson as Record<string, unknown>) ?? {}
|
|
|
|
|
const requested = !!metadata.mentoringRequested
|
|
|
|
|
const requestedAt = metadata.mentoringRequestedAt ? new Date(metadata.mentoringRequestedAt as string) : null
|
|
|
|
|
|
|
|
|
|
return { available: true, requested, requestedAt, deadline, canStillRequest }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Request or cancel mentoring for the current MENTORING round
|
|
|
|
|
*/
|
|
|
|
|
requestMentoring: protectedProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string(), roundId: z.string(), requesting: z.boolean() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can request mentoring' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify caller is team lead
|
|
|
|
|
const project = await ctx.prisma.project.findUnique({
|
|
|
|
|
where: { id: input.projectId },
|
|
|
|
|
select: { id: true, submittedByUserId: true, title: true },
|
|
|
|
|
})
|
|
|
|
|
if (!project) throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
|
|
|
|
if (project.submittedByUserId !== ctx.user.id) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the team lead can request mentoring' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify round is MENTORING and ACTIVE
|
|
|
|
|
const round = await ctx.prisma.round.findUnique({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
select: { id: true, roundType: true, status: true, configJson: true, windowOpenAt: true },
|
|
|
|
|
})
|
|
|
|
|
if (!round || round.roundType !== 'MENTORING') {
|
|
|
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a mentoring round' })
|
|
|
|
|
}
|
|
|
|
|
if (round.status !== 'ROUND_ACTIVE') {
|
|
|
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Mentoring round is not active' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check deadline
|
|
|
|
|
const config = MentoringConfigSchema.safeParse(round.configJson)
|
|
|
|
|
const deadlineDays = config.success ? config.data.mentoringRequestDeadlineDays : 14
|
|
|
|
|
if (round.windowOpenAt) {
|
|
|
|
|
const deadline = new Date(new Date(round.windowOpenAt).getTime() + deadlineDays * 24 * 60 * 60 * 1000)
|
|
|
|
|
if (new Date() > deadline) {
|
|
|
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Mentoring request window has closed' })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find PRS
|
|
|
|
|
const prs = await ctx.prisma.projectRoundState.findUnique({
|
|
|
|
|
where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
|
|
|
|
|
})
|
|
|
|
|
if (!prs) {
|
|
|
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project is not assigned to this round' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const existingMeta = (prs.metadataJson as Record<string, unknown>) ?? {}
|
|
|
|
|
|
|
|
|
|
// Update metadataJson with mentoring request info
|
|
|
|
|
await ctx.prisma.projectRoundState.update({
|
|
|
|
|
where: { id: prs.id },
|
|
|
|
|
data: {
|
|
|
|
|
metadataJson: {
|
|
|
|
|
...existingMeta,
|
|
|
|
|
mentoringRequested: input.requesting,
|
|
|
|
|
mentoringRequestedAt: input.requesting ? new Date().toISOString() : null,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// If requesting mentoring and currently PASSED (pass-through), transition to IN_PROGRESS
|
|
|
|
|
if (input.requesting && prs.state === 'PASSED') {
|
|
|
|
|
await transitionProject(
|
|
|
|
|
input.projectId, input.roundId,
|
|
|
|
|
'IN_PROGRESS' as Parameters<typeof transitionProject>[2],
|
|
|
|
|
ctx.user.id, ctx.prisma,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
action: input.requesting ? 'MENTORING_REQUESTED' : 'MENTORING_CANCELLED',
|
|
|
|
|
entityType: 'Project',
|
|
|
|
|
entityId: input.projectId,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
detailsJson: { roundId: input.roundId, projectTitle: project.title },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true, requesting: input.requesting }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-03-05 17:08:19 +01:00
|
|
|
updateDescription: protectedProcedure
|
|
|
|
|
.input(z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
description: z.string().max(10000),
|
|
|
|
|
}))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can update descriptions' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check admin setting
|
|
|
|
|
const setting = await ctx.prisma.systemSettings.findUnique({
|
|
|
|
|
where: { key: 'applicant_allow_description_edit' },
|
|
|
|
|
})
|
|
|
|
|
if (setting?.value !== 'true') {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Description editing is currently disabled' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify membership
|
|
|
|
|
const member = await ctx.prisma.teamMember.findFirst({
|
|
|
|
|
where: { projectId: input.projectId, userId: ctx.user.id },
|
|
|
|
|
})
|
|
|
|
|
if (!member) {
|
|
|
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.project.update({
|
|
|
|
|
where: { id: input.projectId },
|
|
|
|
|
data: { description: input.description },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
withdrawFromCompetition: protectedProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
if (ctx.user.role !== 'APPLICANT') {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can withdraw' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify caller is team lead (submittedByUserId)
|
|
|
|
|
const project = await ctx.prisma.project.findUnique({
|
|
|
|
|
where: { id: input.projectId },
|
|
|
|
|
select: { id: true, submittedByUserId: true, title: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (project.submittedByUserId !== ctx.user.id) {
|
|
|
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the team lead can withdraw from the competition' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find the active (non-terminal) ProjectRoundState
|
|
|
|
|
const activePrs = await ctx.prisma.projectRoundState.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
round: { status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } },
|
|
|
|
|
},
|
|
|
|
|
include: { round: { select: { id: true, name: true } } },
|
|
|
|
|
orderBy: { round: { sortOrder: 'desc' } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!activePrs || isTerminalState(activePrs.state as Parameters<typeof isTerminalState>[0])) {
|
|
|
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active round participation to withdraw from' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await transitionProject(
|
|
|
|
|
input.projectId,
|
|
|
|
|
activePrs.roundId,
|
|
|
|
|
'WITHDRAWN' as Parameters<typeof transitionProject>[2],
|
|
|
|
|
ctx.user.id,
|
|
|
|
|
ctx.prisma,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: result.errors?.join('; ') ?? 'Failed to withdraw',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
action: 'WITHDRAWAL',
|
|
|
|
|
entityType: 'Project',
|
|
|
|
|
entityId: input.projectId,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
detailsJson: { roundId: activePrs.roundId, roundName: activePrs.round.name, projectTitle: project.title },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true, roundId: activePrs.roundId, roundName: activePrs.round.name }
|
|
|
|
|
}),
|
2026-04-28 16:14:11 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Last N messages + unread count for the applicant's project mentor workspace.
|
|
|
|
|
* Drives the 'Conversation with [Mentor]' card on /applicant.
|
|
|
|
|
*/
|
|
|
|
|
getMentorConversationPreview: protectedProcedure
|
|
|
|
|
.input(z.object({ projectId: z.string(), limit: z.number().min(1).max(10).default(3) }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const teamMembership = await ctx.prisma.teamMember.findFirst({
|
|
|
|
|
where: { projectId: input.projectId, userId: ctx.user.id },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
if (!teamMembership) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Not a team member of this project',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const assignment = await ctx.prisma.mentorAssignment.findUnique({
|
|
|
|
|
where: { projectId: input.projectId },
|
|
|
|
|
include: { mentor: { select: { id: true, name: true, email: true } } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const [messages, unreadCount] = await Promise.all([
|
|
|
|
|
ctx.prisma.mentorMessage.findMany({
|
|
|
|
|
where: { projectId: input.projectId },
|
|
|
|
|
include: { sender: { select: { id: true, name: true, email: true } } },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
take: input.limit,
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.mentorMessage.count({
|
|
|
|
|
where: {
|
|
|
|
|
projectId: input.projectId,
|
|
|
|
|
senderId: { not: ctx.user.id },
|
|
|
|
|
isRead: false,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
mentor: assignment?.mentor ?? null,
|
|
|
|
|
messages: messages.reverse(),
|
|
|
|
|
unreadCount,
|
|
|
|
|
}
|
|
|
|
|
}),
|
2026-04-28 18:54:40 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns the caller's project's finalist confirmation (if any) plus the
|
|
|
|
|
* data needed by the team-lead's "Edit attendees" dialog: the team roster,
|
|
|
|
|
* the current AttendingMember rows, the program cap, and the editable
|
|
|
|
|
* cutoff derived from the LIVE_FINAL round window.
|
|
|
|
|
*
|
|
|
|
|
* Returns null when the caller is not on a team with a confirmation.
|
|
|
|
|
*/
|
|
|
|
|
getMyFinalistConfirmation: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
const project = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: { teamMembers: { some: { userId: ctx.user.id } } },
|
|
|
|
|
include: {
|
|
|
|
|
program: { select: { id: true, defaultAttendeeCap: true } },
|
|
|
|
|
teamMembers: {
|
|
|
|
|
include: {
|
|
|
|
|
user: { select: { id: true, name: true, email: true } },
|
|
|
|
|
},
|
|
|
|
|
orderBy: { joinedAt: 'asc' },
|
|
|
|
|
},
|
|
|
|
|
finalistConfirmation: {
|
|
|
|
|
include: {
|
|
|
|
|
attendingMembers: { select: { userId: true, needsVisa: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project || !project.finalistConfirmation) return null
|
|
|
|
|
|
|
|
|
|
const callerMember = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
|
|
|
|
|
const isLead = callerMember?.role === 'LEAD'
|
|
|
|
|
|
|
|
|
|
let cutoffAt: Date | null = null
|
|
|
|
|
let editableNow = true
|
|
|
|
|
const round = await ctx.prisma.round.findFirst({
|
|
|
|
|
where: { competition: { programId: project.program.id }, roundType: 'LIVE_FINAL' },
|
|
|
|
|
orderBy: { sortOrder: 'desc' },
|
|
|
|
|
select: { windowOpenAt: true, configJson: true },
|
|
|
|
|
})
|
|
|
|
|
if (round?.windowOpenAt) {
|
|
|
|
|
const cfg = (round.configJson ?? {}) as { attendeeEditCutoffHours?: number }
|
|
|
|
|
const cutoffHours = cfg.attendeeEditCutoffHours ?? 48
|
|
|
|
|
cutoffAt = new Date(round.windowOpenAt.getTime() - cutoffHours * 3_600_000)
|
|
|
|
|
editableNow = Date.now() <= cutoffAt.getTime()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
project: {
|
|
|
|
|
id: project.id,
|
|
|
|
|
title: project.title,
|
|
|
|
|
teamMembers: project.teamMembers.map((tm) => ({
|
|
|
|
|
userId: tm.userId,
|
|
|
|
|
role: tm.role,
|
|
|
|
|
user: tm.user,
|
|
|
|
|
})),
|
|
|
|
|
program: { defaultAttendeeCap: project.program.defaultAttendeeCap },
|
|
|
|
|
},
|
|
|
|
|
confirmation: {
|
|
|
|
|
id: project.finalistConfirmation.id,
|
|
|
|
|
status: project.finalistConfirmation.status,
|
|
|
|
|
attendingMembers: project.finalistConfirmation.attendingMembers,
|
|
|
|
|
},
|
|
|
|
|
isLead,
|
|
|
|
|
cutoffAt,
|
|
|
|
|
editableNow,
|
|
|
|
|
}
|
|
|
|
|
}),
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|