MOPC-App/src/server/routers/applicant.ts

981 lines
26 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
// Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
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 }) => {
// Find the round by slug
const round = await ctx.prisma.round.findFirst({
where: { slug: input.slug },
include: {
program: { select: { id: true, name: true, year: true, description: true } },
},
})
if (!round) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Round not found',
})
}
// Check if submissions are open
const now = new Date()
const isOpen = round.submissionDeadline
? now < round.submissionDeadline
: round.status === 'ACTIVE'
return {
round: {
id: round.id,
name: round.name,
slug: round.slug,
submissionDeadline: round.submissionDeadline,
isOpen,
},
program: round.program,
}
}),
/**
* Get the current user's submission for a round (as submitter or team member)
*/
getMySubmission: protectedProcedure
.input(z.object({ roundId: z.string() }))
.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 project = await ctx.prisma.project.findFirst({
where: {
roundId: input.roundId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
include: {
files: true,
round: {
include: {
program: { select: { 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({
roundId: z.string(),
projectId: z.string().optional(), // If updating existing
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), // Whether to submit or just save draft
})
)
.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',
})
}
// Check if the round is open for submissions
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const now = new Date()
if (round.submissionDeadline && now > round.submissionDeadline) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Submission deadline has passed',
})
}
const { projectId, submit, roundId, 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 {
// Get the round to find the programId
const roundForCreate = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { programId: true },
})
// Create new project
const project = await ctx.prisma.project.create({
data: {
programId: roundForCreate.programId,
roundId,
...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(),
})
)
.mutation(async ({ ctx, input }) => {
// Only applicants can use this
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants can upload files',
})
}
// Verify project ownership
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
submittedByUserId: ctx.user.id,
},
include: {
round: { select: { id: true, votingStartAt: true, settingsJson: true } },
},
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found or you do not have access',
})
}
// Check round upload deadline policy if roundId provided
let isLate = false
const targetRoundId = input.roundId || project.roundId
if (targetRoundId) {
const round = input.roundId
? await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { votingStartAt: true, settingsJson: true },
})
: project.round
if (round) {
const settings = round.settingsJson as Record<string, unknown> | null
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
const now = new Date()
const roundStarted = round.votingStartAt && now > round.votingStartAt
if (roundStarted && uploadPolicy === 'BLOCK') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Uploads are blocked after the round has started',
})
}
if (roundStarted && uploadPolicy === 'ALLOW_LATE') {
isLate = true
}
}
}
// Can't upload if already submitted (unless round allows it)
if (project.submittedAt && !isLate) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot modify a submitted project',
})
}
const timestamp = Date.now()
const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
const objectKey = `${project.id}/${input.fileType}/${timestamp}-${sanitizedName}`
const url = await getPresignedUrl(SUBMISSIONS_BUCKET, objectKey, 'PUT', 3600)
return {
url,
bucket: SUBMISSIONS_BUCKET,
objectKey,
isLate,
roundId: targetRoundId,
}
}),
/**
* 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(),
})
)
.mutation(async ({ ctx, input }) => {
// Only applicants can use this
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants can save files',
})
}
// Verify project ownership
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
submittedByUserId: 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, ...fileData } = input
// Delete existing file of same type, scoped by roundId if provided
await ctx.prisma.projectFile.deleteMany({
where: {
projectId,
fileType: input.fileType,
...(roundId ? { roundId } : {}),
},
})
// Create new file record
const file = await ctx.prisma.projectFile.create({
data: {
projectId,
...fileData,
roundId: roundId || null,
isLate: isLate || false,
},
})
return file
}),
/**
* Delete a file from submission
*/
deleteFile: protectedProcedure
.input(z.object({ fileId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Only applicants can use this
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants can delete files',
})
}
const file = await ctx.prisma.projectFile.findUniqueOrThrow({
where: { id: input.fileId },
include: { project: true },
})
// Verify ownership
if (file.project.submittedByUserId !== ctx.user.id) {
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: {
round: {
include: {
program: { select: { 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: {
round: {
include: {
program: { select: { 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,
},
},
},
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',
})
}
return {
teamMembers: project.teamMembers,
submittedBy: project.submittedBy,
}
}),
/**
* 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(),
})
)
.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 invite new members',
})
}
// Check if already a team member
const existingMember = await ctx.prisma.teamMember.findFirst({
where: {
projectId: input.projectId,
user: { email: input.email },
},
})
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: input.email },
})
if (!user) {
user = await ctx.prisma.user.create({
data: {
email: input.email,
name: input.name,
role: 'APPLICANT',
status: 'INVITED',
},
})
}
// 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 },
},
},
})
// TODO: Send invitation email to the new team member
return teamMember
}),
/**
* 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
}),
})