Files
MOPC-Portal/src/server/routers/project.ts
Matt 25e06e11e4 feat: add all missing fields to project update mutation and edit form
Adds competitionCategory, oceanIssue, institution, geographicZone,
wantsMentorship, and foundedAt to the tRPC update mutation input schema
and the admin project edit form UI (with CountrySelect + Switch).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:28:26 +01:00

1376 lines
45 KiB
TypeScript

import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, userHasRole } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
import {
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit'
import { sendInvitationEmail } from '@/lib/email'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const STATUSES_WITH_TEAM_NOTIFICATIONS = ['SEMIFINALIST', 'FINALIST', 'REJECTED'] as const
// Valid project status transitions
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
SUBMITTED: ['ELIGIBLE', 'REJECTED'], // New submissions get screened
ELIGIBLE: ['ASSIGNED', 'REJECTED'], // Eligible projects get assigned to jurors
ASSIGNED: ['SEMIFINALIST', 'FINALIST', 'REJECTED'], // After evaluation
SEMIFINALIST: ['FINALIST', 'REJECTED'], // Semi-finalists advance or get cut
FINALIST: ['REJECTED'], // Finalists can only be rejected (rare)
REJECTED: ['SUBMITTED'], // Rejected can be re-submitted (admin override)
}
export const projectRouter = router({
/**
* List projects with filtering and pagination
* Admin sees all, jury sees only assigned projects
*/
list: protectedProcedure
.input(
z.object({
programId: z.string().optional(),
roundId: z.string().optional(),
status: z
.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
])
.optional(),
statuses: z.array(
z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
])
).optional(),
excludeInRoundId: z.string().optional(), // Exclude projects already in this round
unassignedOnly: z.boolean().optional(), // Projects not in any round
search: z.string().optional(),
tags: z.array(z.string()).optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
oceanIssue: z.enum([
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
'OCEAN_ACIDIFICATION', 'OTHER',
]).optional(),
country: z.string().optional(),
wantsMentorship: z.boolean().optional(),
hasFiles: z.boolean().optional(),
hasAssignments: z.boolean().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(200).default(20),
})
)
.query(async ({ ctx, input }) => {
const {
programId, roundId, excludeInRoundId, status, statuses, unassignedOnly, search, tags,
competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments,
page, perPage,
} = input
const skip = (page - 1) * perPage
// Build where clause
const where: Record<string, unknown> = {}
// Filter by program
if (programId) where.programId = programId
// Filter by round (via ProjectRoundState)
if (roundId) {
where.projectRoundStates = { some: { roundId } }
}
// Exclude projects already in a specific round
if (excludeInRoundId) {
where.projectRoundStates = { none: { roundId: excludeInRoundId } }
}
// Filter by unassigned (not in any round)
if (unassignedOnly) {
where.projectRoundStates = { none: {} }
}
// Status filter
if (statuses?.length || status) {
const statusValues = statuses?.length ? statuses : status ? [status] : []
if (statusValues.length > 0) {
where.status = { in: statusValues }
}
}
if (tags && tags.length > 0) {
where.tags = { hasSome: tags }
}
if (competitionCategory) where.competitionCategory = competitionCategory
if (oceanIssue) where.oceanIssue = oceanIssue
if (country) where.country = country
if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship
if (hasFiles === true) where.files = { some: {} }
if (hasFiles === false) where.files = { none: {} }
if (hasAssignments === true) where.assignments = { some: {} }
if (hasAssignments === false) where.assignments = { none: {} }
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
]
}
// Jury members can only see assigned projects
if (userHasRole(ctx.user, 'JURY_MEMBER')) {
where.assignments = {
...((where.assignments as Record<string, unknown>) || {}),
some: { userId: ctx.user.id },
}
}
const [projects, total, statusGroups] = await Promise.all([
ctx.prisma.project.findMany({
where,
skip,
take: perPage,
orderBy: { createdAt: 'desc' },
include: {
program: { select: { id: true, name: true, year: true } },
_count: { select: { assignments: true, files: true } },
},
}),
ctx.prisma.project.count({ where }),
ctx.prisma.project.groupBy({
by: ['status'],
where,
_count: true,
}),
])
// Build status counts from groupBy (across all pages)
const statusCounts: Record<string, number> = {}
for (const g of statusGroups) {
statusCounts[g.status] = g._count
}
return {
projects,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
statusCounts,
}
}),
/**
* List all project IDs matching filters (no pagination).
* Used for "select all across pages" in bulk operations.
*/
listAllIds: adminProcedure
.input(
z.object({
programId: z.string().optional(),
roundId: z.string().optional(),
excludeInRoundId: z.string().optional(),
unassignedOnly: z.boolean().optional(),
search: z.string().optional(),
statuses: z.array(
z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
])
).optional(),
tags: z.array(z.string()).optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
oceanIssue: z.enum([
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
'OCEAN_ACIDIFICATION', 'OTHER',
]).optional(),
country: z.string().optional(),
wantsMentorship: z.boolean().optional(),
hasFiles: z.boolean().optional(),
hasAssignments: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const {
programId, roundId, excludeInRoundId, unassignedOnly,
search, statuses, tags,
competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments,
} = input
const where: Record<string, unknown> = {}
if (programId) where.programId = programId
if (roundId) {
where.projectRoundStates = { some: { roundId } }
}
if (excludeInRoundId) {
where.projectRoundStates = { none: { roundId: excludeInRoundId } }
}
if (unassignedOnly) {
where.projectRoundStates = { none: {} }
}
if (statuses?.length) where.status = { in: statuses }
if (tags && tags.length > 0) where.tags = { hasSome: tags }
if (competitionCategory) where.competitionCategory = competitionCategory
if (oceanIssue) where.oceanIssue = oceanIssue
if (country) where.country = country
if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship
if (hasFiles === true) where.files = { some: {} }
if (hasFiles === false) where.files = { none: {} }
if (hasAssignments === true) where.assignments = { some: {} }
if (hasAssignments === false) where.assignments = { none: {} }
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
]
}
const projects = await ctx.prisma.project.findMany({
where,
select: { id: true },
orderBy: { createdAt: 'desc' },
})
return { ids: projects.map((p) => p.id) }
}),
/**
* Preview project-team recipients before bulk status update notifications.
* Used by admin UI confirmation dialog to verify notification audience.
*/
previewStatusNotificationRecipients: adminProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(10000),
status: z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
]),
})
)
.query(async ({ ctx, input }) => {
const statusTriggersNotification = STATUSES_WITH_TEAM_NOTIFICATIONS.includes(
input.status as (typeof STATUSES_WITH_TEAM_NOTIFICATIONS)[number]
)
if (!statusTriggersNotification) {
return {
status: input.status,
statusTriggersNotification,
totalProjects: 0,
projectsWithRecipients: 0,
totalRecipients: 0,
projects: [] as Array<{
id: string
title: string
recipientCount: number
recipientsPreview: string[]
hasMoreRecipients: boolean
}>,
}
}
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.ids } },
select: {
id: true,
title: true,
teamMembers: {
select: {
userId: true,
user: {
select: {
email: true,
},
},
},
},
},
orderBy: { title: 'asc' },
})
const MAX_PREVIEW_RECIPIENTS_PER_PROJECT = 8
const mappedProjects = projects.map((project) => {
const uniqueEmails = Array.from(
new Set(
project.teamMembers
.map((member) => member.user?.email?.toLowerCase().trim() ?? '')
.filter((email) => email.length > 0)
)
)
return {
id: project.id,
title: project.title,
recipientCount: uniqueEmails.length,
recipientsPreview: uniqueEmails.slice(0, MAX_PREVIEW_RECIPIENTS_PER_PROJECT),
hasMoreRecipients: uniqueEmails.length > MAX_PREVIEW_RECIPIENTS_PER_PROJECT,
}
})
const projectsWithRecipients = mappedProjects.filter((p) => p.recipientCount > 0).length
const totalRecipients = mappedProjects.reduce((sum, project) => sum + project.recipientCount, 0)
return {
status: input.status,
statusTriggersNotification,
totalProjects: mappedProjects.length,
projectsWithRecipients,
totalRecipients,
projects: mappedProjects,
}
}),
/**
* Get filter options for the project list (distinct values)
*/
getFilterOptions: protectedProcedure
.query(async ({ ctx }) => {
const [countries, categories, issues] = await Promise.all([
ctx.prisma.project.findMany({
where: { country: { not: null } },
select: { country: true },
distinct: ['country'],
orderBy: { country: 'asc' },
}),
ctx.prisma.project.groupBy({
by: ['competitionCategory'],
where: { competitionCategory: { not: null } },
_count: true,
}),
ctx.prisma.project.groupBy({
by: ['oceanIssue'],
where: { oceanIssue: { not: null } },
_count: true,
}),
])
return {
countries: countries.map((c) => c.country).filter(Boolean) as string[],
categories: categories.map((c) => ({
value: c.competitionCategory!,
count: c._count,
})),
issues: issues.map((i) => ({
value: i.oceanIssue!,
count: i._count,
})),
}
}),
/**
* Get a single project with details
*/
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.id },
include: {
files: true,
teamMembers: {
include: {
user: {
select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true },
},
},
orderBy: { joinedAt: 'asc' },
},
mentorAssignment: {
include: {
mentor: {
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
},
},
},
},
})
// Fetch project tags separately (table may not exist if migrations are pending)
let projectTags: { id: string; projectId: string; tagId: string; confidence: number; tag: { id: string; name: string; category: string | null; color: string | null } }[] = []
try {
projectTags = await ctx.prisma.projectTag.findMany({
where: { projectId: input.id },
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
orderBy: { confidence: 'desc' },
})
} catch {
// ProjectTag table may not exist yet
}
// Check access for jury members
if (userHasRole(ctx.user, 'JURY_MEMBER')) {
const assignment = await ctx.prisma.assignment.findFirst({
where: {
projectId: input.id,
userId: ctx.user.id,
},
})
if (!assignment) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this project',
})
}
}
// Attach avatar URLs to team members and mentor
const teamMembersWithAvatars = await Promise.all(
project.teamMembers.map(async (member) => ({
...member,
user: {
...member.user,
avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider),
},
}))
)
const mentorWithAvatar = project.mentorAssignment
? {
...project.mentorAssignment,
mentor: {
...project.mentorAssignment.mentor,
avatarUrl: await getUserAvatarUrl(
project.mentorAssignment.mentor.profileImageKey,
project.mentorAssignment.mentor.profileImageProvider
),
},
}
: null
return {
...project,
projectTags,
teamMembers: teamMembersWithAvatars,
mentorAssignment: mentorWithAvatar,
}
}),
/**
* Create a single project (admin only)
* Projects belong to a program.
*/
create: adminProcedure
.input(
z.object({
programId: z.string(),
title: z.string().min(1).max(500),
teamName: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
country: z.string().optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
oceanIssue: z.enum([
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
'OCEAN_ACIDIFICATION', 'OTHER',
]).optional(),
institution: z.string().optional(),
contactPhone: z.string().optional(),
contactEmail: z.string().email('Invalid email address').optional(),
contactName: z.string().optional(),
city: z.string().optional(),
metadataJson: z.record(z.unknown()).optional(),
teamMembers: z.array(z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
title: z.string().optional(),
phone: z.string().optional(),
sendInvite: z.boolean().default(false),
})).max(10).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const {
metadataJson,
contactPhone, contactEmail, contactName, city,
teamMembers: teamMembersInput,
...rest
} = input
const resolvedProgramId = input.programId
// Build metadata from contact fields + any additional metadata
const fullMetadata: Record<string, unknown> = { ...metadataJson }
if (contactPhone) fullMetadata.contactPhone = contactPhone
if (contactEmail) fullMetadata.contactEmail = contactEmail
if (contactName) fullMetadata.contactName = contactName
if (city) fullMetadata.city = city
// Normalize country to ISO code if provided
const normalizedCountry = input.country
? normalizeCountryToCode(input.country)
: undefined
const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.project.create({
data: {
programId: resolvedProgramId,
title: input.title,
teamName: input.teamName,
description: input.description,
tags: input.tags || [],
country: normalizedCountry,
competitionCategory: input.competitionCategory,
oceanIssue: input.oceanIssue,
institution: input.institution,
metadataJson: Object.keys(fullMetadata).length > 0
? (fullMetadata as Prisma.InputJsonValue)
: undefined,
status: 'SUBMITTED',
},
})
// Create team members if provided
const inviteList: { userId: string; email: string; name: string }[] = []
if (teamMembersInput && teamMembersInput.length > 0) {
for (const member of teamMembersInput) {
// Find or create user
let user = await tx.user.findUnique({
where: { email: member.email.toLowerCase() },
select: { id: true, status: true },
})
if (!user) {
user = await tx.user.create({
data: {
email: member.email.toLowerCase(),
name: member.name,
role: 'APPLICANT',
status: 'NONE',
phoneNumber: member.phone || null,
},
select: { id: true, status: true },
})
}
// Create TeamMember link (skip if already linked)
await tx.teamMember.upsert({
where: {
projectId_userId: {
projectId: created.id,
userId: user.id,
},
},
create: {
projectId: created.id,
userId: user.id,
role: member.role,
title: member.title || null,
},
update: {
role: member.role,
title: member.title || null,
},
})
if (member.sendInvite) {
inviteList.push({ userId: user.id, email: member.email.toLowerCase(), name: member.name })
}
}
}
return { project: created, membersToInvite: inviteList }
})
// Audit outside transaction so failures don't roll back the project creation
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Project',
entityId: project.id,
detailsJson: {
title: input.title,
programId: resolvedProgramId,
teamMembersCount: teamMembersInput?.length || 0,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Send invite emails outside the transaction (never fail project creation)
if (membersToInvite.length > 0) {
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
for (const member of membersToInvite) {
try {
const token = crypto.randomBytes(32).toString('hex')
await ctx.prisma.user.update({
where: { id: member.userId },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(member.email, member.name, inviteUrl, 'APPLICANT')
// Log notification
try {
await ctx.prisma.notificationLog.create({
data: {
userId: member.userId,
channel: 'EMAIL',
type: 'JURY_INVITATION',
status: 'SENT',
},
})
} catch {
// Never fail on notification logging
}
} catch {
// Email sending failure should not break project creation
console.error(`Failed to send invite to ${member.email}`)
}
}
}
return project
}),
/**
* Update a project (admin only)
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
title: z.string().min(1).max(500).optional(),
teamName: z.string().optional().nullable(),
description: z.string().optional().nullable(),
country: z.string().optional().nullable(), // ISO-2 code or country name (will be normalized)
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional().nullable(),
oceanIssue: z.enum([
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
'OCEAN_ACIDIFICATION', 'OTHER',
]).optional().nullable(),
institution: z.string().optional().nullable(),
geographicZone: z.string().optional().nullable(),
wantsMentorship: z.boolean().optional(),
foundedAt: z.string().datetime().optional().nullable(),
status: z
.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
])
.optional(),
tags: z.array(z.string()).optional(),
metadataJson: z.record(z.unknown()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, metadataJson, status, country, foundedAt, ...data } = input
// Normalize country to ISO-2 code if provided
const normalizedCountry = country !== undefined
? (country === null ? null : normalizeCountryToCode(country))
: undefined
// Validate status transition if status is being changed
if (status) {
const currentProject = await ctx.prisma.project.findUniqueOrThrow({
where: { id },
select: { status: true },
})
const allowedTransitions = VALID_PROJECT_TRANSITIONS[currentProject.status] || []
if (!allowedTransitions.includes(status)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Invalid status transition: cannot change from ${currentProject.status} to ${status}. Allowed: ${allowedTransitions.join(', ') || 'none'}`,
})
}
}
const project = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.project.update({
where: { id },
data: {
...data,
...(status && { status }),
...(normalizedCountry !== undefined && { country: normalizedCountry }),
...(foundedAt !== undefined && { foundedAt: foundedAt ? new Date(foundedAt) : null }),
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
},
})
// Record status change in history
if (status) {
await tx.projectStatusHistory.create({
data: {
projectId: id,
status,
changedBy: ctx.user.id,
},
})
}
return updated
})
// Send notifications if status changed
if (status) {
const notificationConfig: Record<
string,
{ type: string; title: string; message: string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
title: "Congratulations! You're a Semi-Finalist",
message: `Your project "${project.title}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
title: "Amazing News! You're a Finalist",
message: `Your project "${project.title}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
title: 'Application Status Update',
message: `We regret to inform you that "${project.title}" was not selected for the next round.`,
},
}
const config = notificationConfig[status]
if (config) {
await notifyProjectTeam(id, {
type: config.type,
title: config.title,
message: config.message,
linkUrl: `/team/projects/${id}`,
linkLabel: 'View Project',
priority: status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
},
})
}
}
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Project',
entityId: id,
detailsJson: { ...data, status, metadataJson } as Record<string, unknown>,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return project
}),
/**
* Delete a project (admin only)
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const target = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, title: true, status: true },
})
const protectedStatuses = ['FINALIST', 'SEMIFINALIST']
if (protectedStatuses.includes(target.status)) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: `Cannot delete a project with status ${target.status}. Change status first.`,
})
}
const project = await ctx.prisma.project.delete({
where: { id: input.id },
})
// Audit outside transaction so failures don't roll back the delete
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Project',
entityId: input.id,
detailsJson: { title: target.title },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return project
}),
/**
* Bulk delete projects (admin only)
*/
bulkDelete: adminProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(200),
})
)
.mutation(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.ids } },
select: { id: true, title: true, status: true },
})
if (projects.length === 0) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No projects found to delete',
})
}
const protectedProjects = projects.filter((p) =>
['FINALIST', 'SEMIFINALIST'].includes(p.status)
)
if (protectedProjects.length > 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: `Cannot delete ${protectedProjects.length} project(s) with FINALIST/SEMIFINALIST status. Remove them from the selection first.`,
})
}
const result = await ctx.prisma.project.deleteMany({
where: { id: { in: projects.map((p) => p.id) } },
})
// Audit outside transaction so failures don't roll back the bulk delete
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_DELETE',
entityType: 'Project',
detailsJson: {
count: projects.length,
titles: projects.map((p) => p.title),
ids: projects.map((p) => p.id),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { deleted: result.count }
}),
/**
* Import projects from CSV data (admin only)
* Projects belong to a program.
*/
importCSV: adminProcedure
.input(
z.object({
programId: z.string(),
projects: z.array(
z.object({
title: z.string().min(1),
teamName: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
metadataJson: z.record(z.unknown()).optional(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
// Verify program exists
await ctx.prisma.program.findUniqueOrThrow({
where: { id: input.programId },
})
// Create projects in a transaction
const result = await ctx.prisma.$transaction(async (tx) => {
const projectData = input.projects.map((p) => {
const { metadataJson, ...rest } = p
return {
...rest,
programId: input.programId,
status: 'SUBMITTED' as const,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
}
})
const created = await tx.project.createManyAndReturn({
data: projectData,
select: { id: true },
})
return { imported: created.length }
})
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'IMPORT',
entityType: 'Project',
detailsJson: { programId: input.programId, count: result.imported },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
}),
/**
* Get all unique tags used in projects
*/
getTags: protectedProcedure
.input(z.object({
programId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.programId) where.programId = input.programId
const projects = await ctx.prisma.project.findMany({
where: Object.keys(where).length > 0 ? where : undefined,
select: { tags: true },
})
const allTags = projects.flatMap((p) => p.tags)
const uniqueTags = [...new Set(allTags)].sort()
return uniqueTags
}),
/**
* Update project status in bulk (admin only)
*/
bulkUpdateStatus: adminProcedure
.input(
z.object({
ids: z.array(z.string()),
status: z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
]),
})
)
.mutation(async ({ ctx, input }) => {
// Fetch matching projects BEFORE update so notifications match actually-updated records
const projects = await ctx.prisma.project.findMany({
where: {
id: { in: input.ids },
},
select: { id: true, title: true },
})
const matchingIds = projects.map((p) => p.id)
// Validate status transitions for all projects
const projectsWithStatus = await ctx.prisma.project.findMany({
where: { id: { in: matchingIds } },
select: { id: true, title: true, status: true },
})
const invalidTransitions: string[] = []
for (const p of projectsWithStatus) {
const allowed = VALID_PROJECT_TRANSITIONS[p.status] || []
if (!allowed.includes(input.status)) {
invalidTransitions.push(`"${p.title}" (${p.status}${input.status})`)
}
}
if (invalidTransitions.length > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Invalid transitions for ${invalidTransitions.length} project(s): ${invalidTransitions.slice(0, 3).join('; ')}${invalidTransitions.length > 3 ? ` and ${invalidTransitions.length - 3} more` : ''}`,
})
}
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.project.updateMany({
where: { id: { in: matchingIds } },
data: { status: input.status },
})
if (matchingIds.length > 0) {
await tx.projectStatusHistory.createMany({
data: matchingIds.map((projectId) => ({
projectId,
status: input.status,
changedBy: ctx.user.id,
})),
})
}
return result
})
// Audit outside transaction so failures don't roll back the bulk update
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'Project',
detailsJson: { ids: matchingIds, status: input.status, count: updated.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Notify project teams based on status
if (projects.length > 0) {
const notificationConfig: Record<
string,
{ type: string; titleFn: (name: string) => string; messageFn: (name: string) => string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
titleFn: () => "Congratulations! You're a Semi-Finalist",
messageFn: (name) => `Your project "${name}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
titleFn: () => "Amazing News! You're a Finalist",
messageFn: (name) => `Your project "${name}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
titleFn: () => 'Application Status Update',
messageFn: (name) =>
`We regret to inform you that "${name}" was not selected for the next round.`,
},
}
const config = notificationConfig[input.status]
if (config) {
for (const project of projects) {
await notifyProjectTeam(project.id, {
type: config.type,
title: config.titleFn(project.title),
message: config.messageFn(project.title),
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: input.status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
},
})
}
}
}
return { updated: updated.count }
}),
/**
* List projects in a program's pool (not assigned to any stage)
*/
listPool: adminProcedure
.input(
z.object({
programId: z.string(),
search: z.string().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
const { programId, search, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = {
programId,
projectRoundStates: { none: {} }, // Projects not assigned to any round
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
]
}
const [projects, total] = await Promise.all([
ctx.prisma.project.findMany({
where,
skip,
take: perPage,
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
teamName: true,
country: true,
competitionCategory: true,
createdAt: true,
},
}),
ctx.prisma.project.count({ where }),
])
return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage) }
}),
/**
* Get full project detail with assignments and evaluation stats in one call.
* Reduces client-side waterfall by combining project.get + assignment.listByProject + evaluation.getProjectStats.
*/
getFullDetail: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [projectRaw, projectTags, assignments, submittedEvaluations] = await Promise.all([
ctx.prisma.project.findUniqueOrThrow({
where: { id: input.id },
include: {
files: true,
teamMembers: {
include: {
user: {
select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true },
},
},
orderBy: { joinedAt: 'asc' },
},
mentorAssignment: {
include: {
mentor: {
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
},
},
},
},
}),
ctx.prisma.projectTag.findMany({
where: { projectId: input.id },
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
orderBy: { confidence: 'desc' },
}).catch(() => [] as { id: string; projectId: string; tagId: string; confidence: number; tag: { id: string; name: string; category: string | null; color: string | null } }[]),
ctx.prisma.assignment.findMany({
where: { projectId: input.id },
include: {
user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
round: { select: { id: true, name: true } },
evaluation: { select: { id: true, status: true, submittedAt: true, globalScore: true, binaryDecision: true, criterionScoresJson: true, feedbackText: true } },
},
orderBy: { createdAt: 'desc' },
}),
ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: { projectId: input.id },
},
}),
])
// Compute evaluation stats
let stats = null
if (submittedEvaluations.length > 0) {
const globalScores = submittedEvaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
// Count recommendations: check binaryDecision first, fall back to boolean criteria
const yesVotes = submittedEvaluations.filter((e) => {
if (e.binaryDecision != null) return e.binaryDecision === true
const scores = e.criterionScoresJson as Record<string, unknown> | null
if (!scores) return false
const boolValues = Object.values(scores).filter((v) => typeof v === 'boolean')
return boolValues.length > 0 && boolValues.every((v) => v === true)
}).length
const hasRecommendationData = submittedEvaluations.some((e) => {
if (e.binaryDecision != null) return true
const scores = e.criterionScoresJson as Record<string, unknown> | null
if (!scores) return false
return Object.values(scores).some((v) => typeof v === 'boolean')
})
stats = {
totalEvaluations: submittedEvaluations.length,
averageGlobalScore: globalScores.length > 0
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: null,
minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
yesVotes,
noVotes: submittedEvaluations.length - yesVotes,
yesPercentage: hasRecommendationData
? (yesVotes / submittedEvaluations.length) * 100
: null,
}
}
// Attach avatar URLs in parallel
const [teamMembersWithAvatars, assignmentsWithAvatars, mentorWithAvatar] = await Promise.all([
Promise.all(
projectRaw.teamMembers.map(async (member) => ({
...member,
user: {
...member.user,
avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider),
},
}))
),
Promise.all(
assignments.map(async (a) => ({
...a,
user: {
...a.user,
avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider),
},
}))
),
projectRaw.mentorAssignment
? (async () => ({
...projectRaw.mentorAssignment!,
mentor: {
...projectRaw.mentorAssignment!.mentor,
avatarUrl: await getUserAvatarUrl(
projectRaw.mentorAssignment!.mentor.profileImageKey,
projectRaw.mentorAssignment!.mentor.profileImageProvider
),
},
}))()
: Promise.resolve(null),
])
return {
project: {
...projectRaw,
projectTags,
teamMembers: teamMembersWithAvatars,
mentorAssignment: mentorWithAvatar,
},
assignments: assignmentsWithAvatars,
stats,
}
}),
/**
* Create a new project and assign it directly to a round.
* Used for late-arriving projects that need to enter a specific round immediately.
*/
createAndAssignToRound: adminProcedure
.input(
z.object({
title: z.string().min(1).max(500),
teamName: z.string().optional(),
description: z.string().optional(),
country: z.string().optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
roundId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, country, ...projectFields } = input
// Get the round to find competitionId, then competition to find programId
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: {
id: true,
name: true,
competition: {
select: {
id: true,
programId: true,
},
},
},
})
// Normalize country to ISO code if provided
const normalizedCountry = country
? normalizeCountryToCode(country)
: undefined
const project = await ctx.prisma.$transaction(async (tx) => {
// 1. Create the project
const created = await tx.project.create({
data: {
programId: round.competition.programId,
title: projectFields.title,
teamName: projectFields.teamName,
description: projectFields.description,
country: normalizedCountry,
competitionCategory: projectFields.competitionCategory,
status: 'ASSIGNED',
},
})
// 2. Create ProjectRoundState entry
await tx.projectRoundState.create({
data: {
projectId: created.id,
roundId,
state: 'PENDING',
},
})
// 3. Create ProjectStatusHistory entry
await tx.projectStatusHistory.create({
data: {
projectId: created.id,
status: 'ASSIGNED',
changedBy: ctx.user.id,
},
})
return created
})
// Audit outside transaction
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_AND_ASSIGN',
entityType: 'Project',
entityId: project.id,
detailsJson: {
title: input.title,
roundId,
roundName: round.name,
programId: round.competition.programId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return project
}),
})