Files
MOPC-Portal/src/server/routers/project.ts
Matt b85a9b9a7b fix: security hardening + performance refactoring (code review batch 1)
- IDOR fix: deliberation vote now verifies juryMemberId === ctx.user.id
- Rate limiting: tRPC middleware (100/min), AI endpoints (5/hr), auth IP-based (10/15min)
- 6 compound indexes added to Prisma schema
- N+1 eliminated in processRoundClose (batch updateMany/createMany)
- N+1 eliminated in batchCheckRequirementsAndTransition (3 batch queries)
- Service extraction: juror-reassignment.ts (578 lines)
- Dead code removed: award.ts, cohort.ts, decision.ts (680 lines)
- 35 bare catch blocks replaced across 16 files
- Fire-and-forget async calls fixed
- Notification false positive bug fixed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:18:24 +01:00

2338 lines
78 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 { attachProjectLogoUrls } from '../utils/project-logo-url'
import {
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit'
import { sendInvitationEmail, getBaseUrl } from '@/lib/email'
import { generateInviteToken, getInviteExpiryMs } from '../utils/invite'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
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(),
roundStates: z.array(z.enum([
'PENDING', 'IN_PROGRESS', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN',
])).optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(200).default(20),
sortBy: z.enum(['title', 'category', 'program', 'assignments', 'status', 'createdAt']).optional(),
sortDir: z.enum(['asc', 'desc']).optional(),
})
)
.query(async ({ ctx, input }) => {
const {
programId, roundId, excludeInRoundId, status, statuses, unassignedOnly, search, tags,
competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments, roundStates,
page, perPage, sortBy, sortDir,
} = input
const skip = (page - 1) * perPage
const dir = sortDir ?? 'desc'
const orderBy: Prisma.ProjectOrderByWithRelationInput = (() => {
switch (sortBy) {
case 'title': return { title: dir }
case 'category': return { competitionCategory: dir }
case 'program': return { program: { name: dir } }
case 'assignments': return { assignments: { _count: dir } }
case 'status': return { status: dir }
case 'createdAt':
default: return { createdAt: dir }
}
})()
// 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: {} }
// Filter by latest round state (matches the statusCounts logic)
if (roundStates?.length) {
const stateFilter: Record<string, unknown> = {}
if (programId) stateFilter.project = { programId }
const allStates = await ctx.prisma.projectRoundState.findMany({
where: stateFilter,
select: { projectId: true, state: true, round: { select: { sortOrder: true } } },
orderBy: { round: { sortOrder: 'desc' } },
})
const latestByProject = new Map<string, string>()
for (const s of allStates) {
if (!latestByProject.has(s.projectId)) {
latestByProject.set(s.projectId, s.state)
}
}
const matchingIds = [...latestByProject.entries()]
.filter(([, state]) => roundStates.includes(state as typeof roundStates[number]))
.map(([id]) => id)
where.id = { in: matchingIds }
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
{ teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } },
]
}
// Jury members can only see assigned projects (but not if they also have admin roles)
if (
userHasRole(ctx.user, 'JURY_MEMBER') &&
!userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')
) {
where.assignments = {
...((where.assignments as Record<string, unknown>) || {}),
some: { userId: ctx.user.id },
}
}
const [projects, total, roundStateCounts] = await Promise.all([
ctx.prisma.project.findMany({
where,
skip,
take: perPage,
orderBy,
include: {
program: { select: { id: true, name: true, year: true } },
_count: { select: { assignments: true, files: true } },
projectRoundStates: {
select: {
state: true,
round: { select: { name: true, sortOrder: true } },
},
orderBy: { round: { sortOrder: 'desc' } },
},
},
}),
ctx.prisma.project.count({ where }),
// Count projects by their LATEST round state (highest sortOrder round).
// This avoids inflated counts where a project that passed round 1
// but was rejected in round 2 shows up in both PASSED and REJECTED.
(async () => {
const stateFilter: Record<string, unknown> = {}
if (where.programId) stateFilter.project = { programId: where.programId as string }
const allStates = await ctx.prisma.projectRoundState.findMany({
where: stateFilter,
select: { projectId: true, state: true, round: { select: { sortOrder: true } } },
orderBy: { round: { sortOrder: 'desc' } },
})
// Pick the latest round state per project
const latestByProject = new Map<string, string>()
for (const s of allStates) {
if (!latestByProject.has(s.projectId)) {
latestByProject.set(s.projectId, s.state)
}
}
// Aggregate counts
const countMap = new Map<string, number>()
for (const state of latestByProject.values()) {
countMap.set(state, (countMap.get(state) ?? 0) + 1)
}
return countMap
})(),
])
// Build round-state counts from the latest-state map
const statusCounts: Record<string, number> = {}
for (const [state, count] of roundStateCounts) {
statusCounts[state] = count
}
const projectsWithLogos = await attachProjectLogoUrls(projects)
return {
projects: projectsWithLogos,
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' } },
{ teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
{ teamMembers: { some: { user: { email: { 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 (err) {
console.error('Failed to fetch project tags:', err)
// ProjectTag table may not exist yet
}
// Check access for jury members (but not if they also have admin roles)
if (userHasRole(ctx.user, 'JURY_MEMBER') && !userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')) {
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(),
roundId: z.string().optional(),
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',
},
})
if (input.roundId) {
await tx.project.update({
where: { id: created.id },
data: { roundId: input.roundId },
})
await tx.projectRoundState.create({
data: {
projectId: created.id,
roundId: input.roundId,
state: 'PENDING',
},
})
}
// 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 (err) {
console.error('Failed to log invitation notification for project team member:', err)
// Never fail on notification logging
}
} catch (err) {
// Email sending failure should not break project creation
console.error(`Failed to send invite to ${member.email}:`, err)
}
}
}
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 actually changing
if (status) {
const currentProject = await ctx.prisma.project.findUniqueOrThrow({
where: { id },
select: { status: true },
})
if (status !== currentProject.status) {
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' } },
{ teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
{ teamMembers: { some: { user: { email: { 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, nationality: true, country: true, institution: true },
},
},
orderBy: { joinedAt: 'asc' },
},
mentorAssignment: {
include: {
mentor: {
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
},
},
},
projectRoundStates: {
select: {
state: true,
round: { select: { name: true, sortOrder: true } },
},
orderBy: { round: { sortOrder: 'desc' } },
},
},
}),
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
}),
/**
* Add a team member to a project (admin only).
* Finds or creates user, then creates TeamMember record.
* Optionally sends invite email if user has no password set.
*/
addTeamMember: adminProcedure
.input(
z.object({
projectId: z.string(),
email: z.string().email(),
name: z.string().min(1),
role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
title: z.string().optional(),
sendInvite: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
const { projectId, email, name, role, title, sendInvite } = input
// Verify project exists
await ctx.prisma.project.findUniqueOrThrow({
where: { id: projectId },
select: { id: true },
})
// Find or create user
let user = await ctx.prisma.user.findUnique({
where: { email: email.toLowerCase() },
select: { id: true, name: true, email: true, passwordHash: true, status: true },
})
if (!user) {
user = await ctx.prisma.user.create({
data: {
email: email.toLowerCase(),
name,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'INVITED',
},
select: { id: true, name: true, email: true, passwordHash: true, status: true },
})
}
// Create TeamMember record
let teamMember
try {
teamMember = await ctx.prisma.teamMember.create({
data: {
projectId,
userId: user.id,
role,
title: title || null,
},
include: {
user: {
select: { id: true, name: true, email: true },
},
},
})
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw new TRPCError({
code: 'CONFLICT',
message: 'This user is already a team member of this project',
})
}
throw err
}
// Send invite email if requested and user has no password
if (sendInvite && !user.passwordHash) {
try {
const token = generateInviteToken()
const expiryMs = await getInviteExpiryMs(ctx.prisma)
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
})
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(email.toLowerCase(), name, inviteUrl, 'APPLICANT')
} catch (err) {
// Email sending failure should not block member creation
console.error(`Failed to send invite to ${email}:`, err)
}
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ADD_TEAM_MEMBER',
entityType: 'Project',
entityId: projectId,
detailsJson: { memberId: user.id, email, role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return teamMember
}),
/**
* Remove a team member from a project (admin only).
* Prevents removing the last LEAD.
*/
removeTeamMember: adminProcedure
.input(
z.object({
projectId: z.string(),
userId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const { projectId, userId } = input
// Check if this is the last LEAD
const targetMember = await ctx.prisma.teamMember.findUniqueOrThrow({
where: { projectId_userId: { projectId, userId } },
select: { id: true, role: true },
})
if (targetMember.role === 'LEAD') {
const leadCount = await ctx.prisma.teamMember.count({
where: { projectId, role: 'LEAD' },
})
if (leadCount <= 1) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot remove the last team lead',
})
}
}
await ctx.prisma.teamMember.delete({
where: { projectId_userId: { projectId, userId } },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REMOVE_TEAM_MEMBER',
entityType: 'Project',
entityId: projectId,
detailsJson: { removedUserId: userId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true }
}),
/**
* Update a team member's role (admin only).
* Prevents removing the last LEAD.
*/
updateTeamMemberRole: adminProcedure
.input(
z.object({
projectId: z.string(),
userId: z.string(),
role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
})
)
.mutation(async ({ ctx, input }) => {
const { projectId, userId, role } = input
const member = await ctx.prisma.teamMember.findUniqueOrThrow({
where: { projectId_userId: { projectId, userId } },
select: { role: true },
})
// Prevent removing the last LEAD
if (member.role === 'LEAD' && role !== 'LEAD') {
const leadCount = await ctx.prisma.teamMember.count({
where: { projectId, role: 'LEAD' },
})
if (leadCount <= 1) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot change the role of the last team lead',
})
}
}
await ctx.prisma.teamMember.update({
where: { projectId_userId: { projectId, userId } },
data: { role },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_TEAM_MEMBER_ROLE',
entityType: 'Project',
entityId: projectId,
detailsJson: { targetUserId: userId, oldRole: member.role, newRole: role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true }
}),
// =========================================================================
// BULK NOTIFICATION ENDPOINTS
// =========================================================================
/**
* Get summary of projects eligible for bulk notifications.
* Returns counts for passed (by round), rejected, and award pool projects,
* plus how many have already been notified.
*/
getBulkNotificationSummary: adminProcedure
.query(async ({ ctx }) => {
// 1. Passed projects grouped by round
const passedStates = await ctx.prisma.projectRoundState.findMany({
where: { state: 'PASSED' },
select: {
projectId: true,
roundId: true,
round: { select: { name: true, sortOrder: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } } },
},
})
// Group by round and compute next round name
const passedByRound = new Map<string, { roundId: string; roundName: string; nextRoundName: string; projectIds: Set<string> }>()
for (const ps of passedStates) {
if (!passedByRound.has(ps.roundId)) {
const rounds = ps.round.competition.rounds
const idx = rounds.findIndex((r) => r.id === ps.roundId)
const nextRound = rounds[idx + 1]
passedByRound.set(ps.roundId, {
roundId: ps.roundId,
roundName: ps.round.name,
nextRoundName: nextRound?.name ?? 'Next Round',
projectIds: new Set(),
})
}
passedByRound.get(ps.roundId)!.projectIds.add(ps.projectId)
}
const passed = [...passedByRound.values()].map((g) => ({
roundId: g.roundId,
roundName: g.roundName,
nextRoundName: g.nextRoundName,
projectCount: g.projectIds.size,
}))
// 2. Rejected projects (REJECTED in ProjectRoundState + FILTERED_OUT in FilteringResult)
const [rejectedPRS, filteredOut] = await Promise.all([
ctx.prisma.projectRoundState.findMany({
where: { state: 'REJECTED' },
select: { projectId: true },
}),
ctx.prisma.filteringResult.findMany({
where: {
OR: [
{ finalOutcome: 'FILTERED_OUT' },
{ outcome: 'FILTERED_OUT', finalOutcome: null },
],
},
select: { projectId: true },
}),
])
const rejectedProjectIds = new Set([
...rejectedPRS.map((r) => r.projectId),
...filteredOut.map((r) => r.projectId),
])
// 3. Award pools
const awards = await ctx.prisma.specialAward.findMany({
select: {
id: true,
name: true,
_count: { select: { eligibilities: { where: { eligible: true } } } },
},
})
const awardPools = awards.map((a) => ({
awardId: a.id,
awardName: a.name,
eligibleCount: a._count.eligibilities,
}))
// 4. Already-sent counts from NotificationLog
const [advancementSent, rejectionSent] = await Promise.all([
ctx.prisma.notificationLog.count({
where: { type: 'ADVANCEMENT_NOTIFICATION', status: 'SENT' },
}),
ctx.prisma.notificationLog.count({
where: { type: 'REJECTION_NOTIFICATION', status: 'SENT' },
}),
])
return {
passed,
rejected: { count: rejectedProjectIds.size },
awardPools,
alreadyNotified: { advancement: advancementSent, rejection: rejectionSent },
}
}),
/**
* Send bulk advancement notifications to all PASSED projects.
* Groups by round, determines next round, sends via batch sender.
* Skips projects that have already been notified (unless skipAlreadySent=false).
*/
previewAdvancementEmail: adminProcedure
.input(
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
})
)
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: {
name: true,
competitionId: true,
},
})
if (!round) throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
const rounds = await ctx.prisma.round.findMany({
where: { competitionId: round.competitionId },
orderBy: { sortOrder: 'asc' },
select: { id: true, name: true },
})
const idx = rounds.findIndex((r) => r.id === input.roundId)
const nextRound = rounds[idx + 1]
const { getAdvancementNotificationTemplate } = await import('@/lib/email')
const template = getAdvancementNotificationTemplate(
'Team Lead Name',
'Example Project Title',
round.name,
nextRound?.name ?? 'Next Round',
input.customMessage || undefined,
undefined,
input.fullCustomBody,
)
return { subject: template.subject, html: template.html }
}),
sendBulkPassedNotifications: adminProcedure
.input(
z.object({
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
skipAlreadySent: z.boolean().default(true),
roundIds: z.array(z.string()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { customMessage, fullCustomBody, skipAlreadySent, roundIds } = input
// Find all PASSED project round states (optionally filtered by round)
const passedStates = await ctx.prisma.projectRoundState.findMany({
where: {
state: 'PASSED',
...(roundIds && roundIds.length > 0 ? { roundId: { in: roundIds } } : {}),
},
select: {
projectId: true,
roundId: true,
round: {
select: {
name: true,
sortOrder: true,
competition: {
select: {
rounds: {
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
},
},
},
},
},
},
})
// Get already-sent project IDs if needed
const alreadySentProjectIds = new Set<string>()
if (skipAlreadySent) {
const sentLogs = await ctx.prisma.notificationLog.findMany({
where: { type: 'ADVANCEMENT_NOTIFICATION', status: 'SENT', projectId: { not: null } },
select: { projectId: true },
distinct: ['projectId'],
})
for (const log of sentLogs) {
if (log.projectId) alreadySentProjectIds.add(log.projectId)
}
}
// Group by round for next-round resolution
const roundMap = new Map<string, { roundName: string; nextRoundName: string }>()
const projectIds = new Set<string>()
for (const ps of passedStates) {
if (skipAlreadySent && alreadySentProjectIds.has(ps.projectId)) continue
projectIds.add(ps.projectId)
if (!roundMap.has(ps.roundId)) {
const rounds = ps.round.competition.rounds
const idx = rounds.findIndex((r) => r.id === ps.roundId)
const nextRound = rounds[idx + 1]
roundMap.set(ps.roundId, {
roundName: ps.round.name,
nextRoundName: nextRound?.name ?? 'Next Round',
})
}
}
if (projectIds.size === 0) {
return { sent: 0, failed: 0, skipped: alreadySentProjectIds.size }
}
// Fetch projects with team members
const projects = await ctx.prisma.project.findMany({
where: { id: { in: [...projectIds] } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
},
projectRoundStates: {
where: { state: 'PASSED' },
select: { roundId: true },
take: 1,
},
},
})
// For passwordless users: generate invite tokens
const baseUrl = getBaseUrl()
const passwordlessUserIds: string[] = []
for (const project of projects) {
for (const tm of project.teamMembers) {
if (!tm.user.passwordHash) {
passwordlessUserIds.push(tm.user.id)
}
}
}
const tokenMap = new Map<string, string>()
if (passwordlessUserIds.length > 0) {
const expiryMs = await getInviteExpiryMs(ctx.prisma)
for (const userId of [...new Set(passwordlessUserIds)]) {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: userId },
data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), status: 'INVITED' },
})
tokenMap.set(userId, token)
}
}
// Build notification items
const items: NotificationItem[] = []
for (const project of projects) {
const roundId = project.projectRoundStates[0]?.roundId
const roundInfo = roundId ? roundMap.get(roundId) : undefined
const recipients = new Map<string, { name: string | null; userId: string }>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, { name: null, userId: '' })
}
for (const [email, { name, userId }] of recipients) {
const inviteToken = tokenMap.get(userId)
const accountUrl = inviteToken ? `${baseUrl}/accept-invite?token=${inviteToken}` : undefined
items.push({
email,
name: name || '',
type: 'ADVANCEMENT_NOTIFICATION',
context: {
title: 'Your project has advanced!',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
fromRoundName: roundInfo?.roundName ?? 'this round',
toRoundName: roundInfo?.nextRoundName ?? 'Next Round',
customMessage: customMessage || undefined,
fullCustomBody,
accountUrl,
},
},
projectId: project.id,
userId: userId || undefined,
roundId: roundId || undefined,
})
}
}
const result = await sendBatchNotifications(items)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SEND_BULK_PASSED_NOTIFICATIONS',
entityType: 'Project',
entityId: 'bulk',
detailsJson: {
sent: result.sent,
failed: result.failed,
projectCount: projectIds.size,
skipped: alreadySentProjectIds.size,
batchId: result.batchId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent: result.sent, failed: result.failed, skipped: alreadySentProjectIds.size }
}),
/**
* Send bulk rejection notifications to all REJECTED and FILTERED_OUT projects.
* Deduplicates by project, uses highest-sortOrder rejection round as context.
*/
sendBulkRejectionNotifications: adminProcedure
.input(
z.object({
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
includeInviteLink: z.boolean().default(false),
skipAlreadySent: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
const { customMessage, fullCustomBody, includeInviteLink, skipAlreadySent } = input
// Find REJECTED from ProjectRoundState
const rejectedPRS = await ctx.prisma.projectRoundState.findMany({
where: { state: 'REJECTED' },
select: {
projectId: true,
roundId: true,
round: { select: { name: true, sortOrder: true } },
},
})
// Find FILTERED_OUT from FilteringResult
const filteredOut = await ctx.prisma.filteringResult.findMany({
where: {
OR: [
{ finalOutcome: 'FILTERED_OUT' },
{ outcome: 'FILTERED_OUT', finalOutcome: null },
],
},
select: {
projectId: true,
roundId: true,
round: { select: { name: true, sortOrder: true } },
},
})
// Deduplicate by project, keep highest-sortOrder rejection round
const projectRejectionMap = new Map<string, { roundId: string; roundName: string; sortOrder: number }>()
for (const r of [...rejectedPRS, ...filteredOut]) {
const existing = projectRejectionMap.get(r.projectId)
if (!existing || r.round.sortOrder > existing.sortOrder) {
projectRejectionMap.set(r.projectId, {
roundId: r.roundId,
roundName: r.round.name,
sortOrder: r.round.sortOrder,
})
}
}
// Skip already-sent
const alreadySentProjectIds = new Set<string>()
if (skipAlreadySent) {
const sentLogs = await ctx.prisma.notificationLog.findMany({
where: { type: 'REJECTION_NOTIFICATION', status: 'SENT', projectId: { not: null } },
select: { projectId: true },
distinct: ['projectId'],
})
for (const log of sentLogs) {
if (log.projectId) alreadySentProjectIds.add(log.projectId)
}
}
const targetProjectIds = [...projectRejectionMap.keys()].filter(
(pid) => !skipAlreadySent || !alreadySentProjectIds.has(pid)
)
if (targetProjectIds.length === 0) {
return { sent: 0, failed: 0, skipped: alreadySentProjectIds.size }
}
// Fetch projects with team members
const projects = await ctx.prisma.project.findMany({
where: { id: { in: targetProjectIds } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
},
},
})
// Generate invite tokens for passwordless users if needed
const baseUrl = getBaseUrl()
const tokenMap = new Map<string, string>()
if (includeInviteLink) {
const passwordlessUserIds = new Set<string>()
for (const project of projects) {
for (const tm of project.teamMembers) {
if (!tm.user.passwordHash) passwordlessUserIds.add(tm.user.id)
}
}
if (passwordlessUserIds.size > 0) {
const expiryMs = await getInviteExpiryMs(ctx.prisma)
for (const userId of passwordlessUserIds) {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: userId },
data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), status: 'INVITED' },
})
tokenMap.set(userId, token)
}
}
}
// Build notification items
const items: NotificationItem[] = []
for (const project of projects) {
const rejection = projectRejectionMap.get(project.id)
const recipients = new Map<string, { name: string | null; userId: string }>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, { name: null, userId: '' })
}
for (const [email, { name, userId }] of recipients) {
const inviteToken = tokenMap.get(userId)
const accountUrl = inviteToken ? `${baseUrl}/accept-invite?token=${inviteToken}` : undefined
items.push({
email,
name: name || '',
type: 'REJECTION_NOTIFICATION',
context: {
title: 'Project Status Update',
message: '',
linkUrl: includeInviteLink ? accountUrl : undefined,
metadata: {
projectName: project.title,
roundName: rejection?.roundName ?? 'this round',
customMessage: customMessage || undefined,
fullCustomBody,
},
},
projectId: project.id,
userId: userId || undefined,
roundId: rejection?.roundId,
})
}
}
const result = await sendBatchNotifications(items)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SEND_BULK_REJECTION_NOTIFICATIONS',
entityType: 'Project',
entityId: 'bulk',
detailsJson: {
sent: result.sent,
failed: result.failed,
projectCount: targetProjectIds.length,
skipped: alreadySentProjectIds.size,
batchId: result.batchId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent: result.sent, failed: result.failed, skipped: alreadySentProjectIds.size }
}),
/**
* Send bulk award pool notifications for a specific award.
* Uses the existing award notification pattern via batch sender.
*/
sendBulkAwardNotifications: adminProcedure
.input(
z.object({
awardId: z.string(),
customMessage: z.string().optional(),
skipAlreadySent: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
const { awardId, customMessage, skipAlreadySent } = input
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: awardId },
select: { id: true, name: true },
})
// Get eligible projects for this award
const eligibilities = await ctx.prisma.awardEligibility.findMany({
where: {
awardId,
eligible: true,
...(skipAlreadySent ? { notifiedAt: null } : {}),
},
select: {
id: true,
projectId: true,
project: {
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
},
},
},
},
})
if (eligibilities.length === 0) {
return { sent: 0, failed: 0, skipped: 0 }
}
// Generate invite tokens for passwordless users
const baseUrl = getBaseUrl()
const tokenMap = new Map<string, string>()
const passwordlessUserIds = new Set<string>()
for (const elig of eligibilities) {
for (const tm of elig.project.teamMembers) {
if (!tm.user.passwordHash) passwordlessUserIds.add(tm.user.id)
}
}
if (passwordlessUserIds.size > 0) {
const expiryMs = await getInviteExpiryMs(ctx.prisma)
for (const userId of passwordlessUserIds) {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: userId },
data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), status: 'INVITED' },
})
tokenMap.set(userId, token)
}
}
// Build items with eligibility tracking
const eligibilityEmailMap = new Map<string, Set<string>>() // eligId -> emails
const items: NotificationItem[] = []
for (const elig of eligibilities) {
const project = elig.project
const emailsForElig = new Set<string>()
const recipients = new Map<string, { name: string | null; userId: string }>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, { name: null, userId: '' })
}
for (const [email, { name, userId }] of recipients) {
emailsForElig.add(email)
const inviteToken = tokenMap.get(userId)
const accountUrl = inviteToken ? `${baseUrl}/accept-invite?token=${inviteToken}` : undefined
items.push({
email,
name: name || '',
type: 'AWARD_SELECTION_NOTIFICATION',
context: {
title: `Your project is being considered for ${award.name}`,
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
awardName: award.name,
customMessage: customMessage || undefined,
accountUrl,
},
},
projectId: project.id,
userId: userId || undefined,
})
}
eligibilityEmailMap.set(elig.id, emailsForElig)
}
const result = await sendBatchNotifications(items)
// Stamp notifiedAt only for eligibilities where all emails succeeded
const failedEmails = new Set(result.errors.map((e) => e.email))
for (const [eligId, emails] of eligibilityEmailMap) {
const anyFailed = [...emails].some((e) => failedEmails.has(e))
if (!anyFailed) {
await ctx.prisma.awardEligibility.update({
where: { id: eligId },
data: { notifiedAt: new Date() },
})
}
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SEND_BULK_AWARD_NOTIFICATIONS',
entityType: 'SpecialAward',
entityId: awardId,
detailsJson: {
awardName: award.name,
sent: result.sent,
failed: result.failed,
eligibilityCount: eligibilities.length,
batchId: result.batchId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent: result.sent, failed: result.failed, skipped: 0 }
}),
})