Files
MOPC-Portal/src/server/routers/project.ts
Matt aed5e078b3
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m35s
fix: resolve advance decision and surface country on rankings list
The side panel only read Evaluation.binaryDecision, which is null when
the round's evaluation form stores the 'proceed to next round' answer
as a boolean criterion in criterionScoresJson (the current round's
shape). project.getFullDetail now resolves a unified `decision` field
per assignment using the same fallback pattern as the ranking router:
prefer the column, fall back to a type='advance' (or legacy 'move to
the next stage' boolean) criterion looked up by id in the active form.

Also: project country in the rankings list now renders whenever it's
present, not only when teamName is also set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:20:09 +02:00

2375 lines
80 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.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(), roundId: z.string().optional() }))
.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,
...(input.roundId ? { roundId: input.roundId } : {}),
},
},
}),
])
// Resolve the boolean "advance to next round" criterion id for each round
// so we can fall back to criterionScoresJson when binaryDecision is null.
// Mirrors ranking.ts logic: prefer type='advance', else legacy boolean
// criterion labeled "move to the next stage".
const roundIdsInUse = Array.from(new Set(assignments.map((a) => a.round.id)))
const advanceCriterionByRound = new Map<string, string | null>()
if (roundIdsInUse.length > 0) {
const forms = await ctx.prisma.evaluationForm.findMany({
where: { roundId: { in: roundIdsInUse }, isActive: true },
select: { roundId: true, criteriaJson: true },
})
for (const form of forms) {
if (advanceCriterionByRound.has(form.roundId)) continue
const criteria = (form.criteriaJson as Array<{ id: string; type?: string; label?: string }> | null) ?? []
const found =
criteria.find((c) => c.type === 'advance') ??
criteria.find((c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'))
advanceCriterionByRound.set(form.roundId, found?.id ?? null)
}
}
// 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) => {
// Resolve decision: column first, criterion fallback second.
let decision: boolean | null = a.evaluation?.binaryDecision ?? null
if (decision == null && a.evaluation) {
const advCritId = advanceCriterionByRound.get(a.round.id) ?? null
if (advCritId) {
const scores = a.evaluation.criterionScoresJson as Record<string, unknown> | null
if (scores) {
const val = scores[advCritId]
if (typeof val === 'boolean') decision = val
else if (val === 'true') decision = true
else if (val === 'false') decision = false
}
}
}
return {
...a,
user: {
...a.user,
avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider),
},
evaluation: a.evaluation ? { ...a.evaluation, decision } : null,
}
})
),
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 }
}),
})