Files
MOPC-Portal/src/server/routers/applicant.ts
Matt ebc6331d1f
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
fix: harden award track filtering edge cases in applicant portal
- getNavFlags/getMyEvaluations: return empty when project has no round
  states instead of dropping the filter (prevented phantom eval rounds)
- getUpcomingDeadlines: detect isInAwardTrack from ProjectRoundState
  join instead of pre-filtered deadline rounds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:27:53 +01:00

2329 lines
74 KiB
TypeScript

import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
import { generateLogoKey, createStorageProvider, type StorageProviderType } from '@/lib/storage'
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
import type { Prisma } from '@prisma/client'
// Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
const TEAM_INVITE_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
function generateInviteToken(): string {
return crypto.randomBytes(32).toString('hex')
}
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 }) => {
const round = await ctx.prisma.round.findFirst({
where: { slug: input.slug },
include: {
competition: {
include: {
program: { select: { id: true, name: true, year: true, description: true } },
},
},
},
})
if (!round) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Round not found',
})
}
const now = new Date()
const isOpen = round.status === 'ROUND_ACTIVE'
return {
stage: {
id: round.id,
name: round.name,
slug: round.slug,
windowCloseAt: null,
isOpen,
},
program: round.competition.program,
}
}),
/**
* Get the current user's submission for a round (as submitter or team member)
*/
getMySubmission: protectedProcedure
.input(z.object({ roundId: z.string().optional(), programId: z.string().optional() }))
.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 },
},
},
],
}
if (input.roundId) {
where.roundAssignments = { some: { roundId: input.roundId } }
}
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']),
roundId: z.string().optional(),
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',
})
}
// 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
// Can't upload if already submitted
if (project.submittedAt && !isLate) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot modify a submitted project',
})
}
// Fetch round name for storage path (if uploading against a round)
let roundName: string | undefined
if (input.roundId) {
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true },
})
roundName = round?.name
}
const objectKey = generateObjectKey(project.title, input.fileName, roundName)
const url = await getPresignedUrl(SUBMISSIONS_BUCKET, objectKey, 'PUT', 3600)
return {
url,
bucket: SUBMISSIONS_BUCKET,
objectKey,
isLate,
roundId: input.roundId || null,
}
}),
/**
* 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(),
roundId: z.string().optional(),
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',
})
}
const { projectId, roundId, isLate, requirementId, ...fileData } = input
// 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,
},
})
}
// Create new file record
const file = await ctx.prisma.projectFile.create({
data: {
projectId,
...fileData,
roundId: roundId || null,
isLate: isLate || false,
requirementId: requirementId || null,
},
})
// 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,
)
}
}
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
import('../services/document-analyzer').then(({ analyzeFileDelayed }) =>
analyzeFileDelayed(file.id).catch((err) =>
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
).catch(() => {})
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',
})
}
// Can't delete if project is submitted
if (file.project.submittedAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot modify a submitted project',
})
}
await ctx.prisma.projectFile.delete({
where: { id: input.fileId },
})
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,
profileImageKey: true,
profileImageProvider: true,
},
},
},
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',
})
}
// 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)
}
}
return {
teamMembers: project.teamMembers,
submittedBy: project.submittedBy,
avatarUrls,
}
}),
/**
* 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(),
nationality: z.string().optional(),
country: z.string().optional(),
institution: z.string().optional(),
sendInvite: z.boolean().default(true),
})
)
.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',
})
}
// 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',
roles: ['APPLICANT'],
status: 'NONE',
nationality: input.nationality,
country: input.country,
institution: input.institution,
},
})
} 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,
})
}
}
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'
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const requiresAccountSetup = user.status !== 'ACTIVE'
// 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 {
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',
},
})
} catch {
// 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',
},
})
} catch {
// 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,
},
})
} catch {
// 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',
})
}
// 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) {
return { project: null, openRounds: [], timeline: [], currentStatus: null }
}
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)
}
}
const isRejected = currentStatus === 'REJECTED'
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
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
})
.map((r) => ({
id: r.id,
name: r.specialAward ? `${r.specialAward.name}: ${r.name}` : r.name,
slug: r.slug,
roundType: r.roundType,
windowCloseAt: r.windowCloseAt,
}))
}
// 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'
// 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 },
})
// 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)
}
return {
project: {
...project,
isTeamLead,
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
},
openRounds,
timeline,
currentStatus,
hasPassedIntake: !!passedIntake,
isIntakeOpen: !!activeIntakeRound,
logoUrl,
}
}),
/**
* 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
// Check if there are EVALUATION rounds (CLOSED/ARCHIVED) with applicantVisibility.enabled
// Only consider rounds the project actually participated in (award track filtering)
let hasEvaluationRounds = false
if (project.programId) {
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)
return parsed.success && parsed.data.applicantVisibility.enabled
})
}
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: [] }
}
// Get all rounds ordered by sortOrder (including award rounds in same competition)
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,
specialAwardId: true,
specialAward: { select: { name: true } },
},
})
// 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
roundType: string
status: string
windowOpenAt: Date | null
windowCloseAt: Date | null
projectState: string | null
isSynthesizedRejection: boolean
}
const entries: TimelineEntry[] = []
// Build lookup for filtering rounds
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')
// 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
// Process visible rounds: hide FILTERING, LIVE_FINAL, DELIBERATION always.
// Also hide MENTORING unless the project is actually participating in it.
// 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.
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
// 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
return true
}
)
for (const round of visibleRounds) {
const actualState = stateMap.get(round.id) ?? null
// Check if a FILTERING round before this round rejected the project
let projectState = actualState
let isSynthesizedRejection = false
const roundSortOrder = rounds.findIndex((r) => r.id === round.id)
const precedingFilterRounds = filteringRounds.filter((fr) => {
const frIdx = rounds.findIndex((r) => r.id === fr.id)
return frIdx < roundSortOrder
})
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({
id: round.id,
label: round.specialAward ? `${round.specialAward.name}: ${round.name}` : round.name,
roundType: round.roundType,
status: round.status,
windowOpenAt: round.windowOpenAt,
windowCloseAt: round.windowCloseAt,
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
}
}
}
}
// 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.
* Respects per-round applicantVisibility config. NEVER leaks juror identity.
*/
getMyEvaluations: 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 []
// Get closed/archived EVALUATION rounds — only ones this project participated in
const projectRoundIds = new Set(
(await ctx.prisma.projectRoundState.findMany({
where: { projectId: project.id },
select: { roundId: true },
})).map((prs) => prs.roundId)
)
if (projectRoundIds.size === 0) return []
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' },
})
const results: Array<{
roundId: string
roundName: string
evaluationCount: number
evaluations: Array<{
id: string
submittedAt: Date | null
globalScore: number | null
criterionScores: Prisma.JsonValue | null
feedbackText: string | null
criteria: Prisma.JsonValue | null
}>
}> = []
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
const vis = parsed.data.applicantVisibility
// Get evaluations via assignments — NEVER select userId or user relation
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' },
})
// Mask round names: "Evaluation Round 1", "Evaluation Round 2", etc.
const maskedName = `Evaluation Round ${i + 1}`
results.push({
roundId: round.id,
roundName: maskedName,
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
}),
/**
* 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 } } },
],
},
select: { id: true, programId: true },
})
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,
specialAwardId: true,
specialAward: { select: { name: true } },
},
orderBy: { windowCloseAt: 'asc' },
})
// Filter by award track membership
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)
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!,
}))
}),
/**
* 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
const allRounds = await ctx.prisma.round.findMany({
where: {
competition: { programId: project.programId },
status: 'ROUND_ACTIVE',
fileRequirements: { some: {} },
},
select: {
id: true,
name: true,
specialAwardId: true,
specialAward: { select: { name: true } },
fileRequirements: {
select: { id: true },
},
},
orderBy: { sortOrder: 'asc' },
})
// 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
})
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,
roundName: round.specialAward ? `${round.specialAward.name}: ${round.name}` : round.name,
required: requirementIds.length,
uploaded,
})
}
return results
}),
/**
* 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' })
}
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 }
}),
/**
* 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,
})
}),
/**
* 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)
}),
/**
* 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 }
}),
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 }
}),
})