702 lines
18 KiB
TypeScript
702 lines
18 KiB
TypeScript
|
|
import { z } from 'zod'
|
||
|
|
import { TRPCError } from '@trpc/server'
|
||
|
|
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
||
|
|
import { getPresignedUrl } from '@/lib/minio'
|
||
|
|
|
||
|
|
// 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,
|
||
|
|
status: submit ? 'SUBMITTED' : existing.status,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
return project
|
||
|
|
} else {
|
||
|
|
// Create new
|
||
|
|
const project = await ctx.prisma.project.create({
|
||
|
|
data: {
|
||
|
|
roundId,
|
||
|
|
...data,
|
||
|
|
metadataJson: metadataJson as unknown ?? undefined,
|
||
|
|
submittedByUserId: ctx.user.id,
|
||
|
|
submittedByEmail: ctx.user.email,
|
||
|
|
submissionSource: 'MANUAL',
|
||
|
|
status: 'SUBMITTED',
|
||
|
|
submittedAt: submit ? now : null,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
// Audit log
|
||
|
|
await ctx.prisma.auditLog.create({
|
||
|
|
data: {
|
||
|
|
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']),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
.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,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
if (!project) {
|
||
|
|
throw new TRPCError({
|
||
|
|
code: 'NOT_FOUND',
|
||
|
|
message: 'Project not found or you do not have access',
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Can't upload if already submitted
|
||
|
|
if (project.submittedAt) {
|
||
|
|
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,
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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(),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
.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, ...fileData } = input
|
||
|
|
|
||
|
|
// Delete existing file of same type if exists
|
||
|
|
await ctx.prisma.projectFile.deleteMany({
|
||
|
|
where: {
|
||
|
|
projectId,
|
||
|
|
fileType: input.fileType,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
// Create new file record
|
||
|
|
const file = await ctx.prisma.projectFile.create({
|
||
|
|
data: {
|
||
|
|
projectId,
|
||
|
|
...fileData,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
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 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 },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
if (!project) {
|
||
|
|
throw new TRPCError({
|
||
|
|
code: 'NOT_FOUND',
|
||
|
|
message: 'Project not found',
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Build timeline
|
||
|
|
const timeline = [
|
||
|
|
{
|
||
|
|
status: 'CREATED',
|
||
|
|
label: 'Application Started',
|
||
|
|
date: project.createdAt,
|
||
|
|
completed: true,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
status: 'SUBMITTED',
|
||
|
|
label: 'Application Submitted',
|
||
|
|
date: project.submittedAt,
|
||
|
|
completed: !!project.submittedAt,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
status: 'UNDER_REVIEW',
|
||
|
|
label: 'Under Review',
|
||
|
|
date: project.status === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null,
|
||
|
|
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
status: 'SEMIFINALIST',
|
||
|
|
label: 'Semi-finalist',
|
||
|
|
date: null, // Would need status change tracking
|
||
|
|
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
status: 'FINALIST',
|
||
|
|
label: 'Finalist',
|
||
|
|
date: null,
|
||
|
|
completed: ['FINALIST', 'WINNER'].includes(project.status),
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
return {
|
||
|
|
project,
|
||
|
|
timeline,
|
||
|
|
currentStatus: project.status,
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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 }
|
||
|
|
}),
|
||
|
|
})
|