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 = { 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 = {} // 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 = {} 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() 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) || {}), 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 = {} 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() for (const s of allStates) { if (!latestByProject.has(s.projectId)) { latestByProject.set(s.projectId, s.state) } } // Aggregate counts const countMap = new Map() 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 = {} 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 = {} 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 = { ...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, 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 = {} 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 = { 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 | 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 | 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 }>() 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() 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() const projectIds = new Set() 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() 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() 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() 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() 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() if (includeInviteLink) { const passwordlessUserIds = new Set() 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() 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() const passwordlessUserIds = new Set() 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>() // eligId -> emails const items: NotificationItem[] = [] for (const elig of eligibilities) { const project = elig.project const emailsForElig = new Set() const recipients = new Map() 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 } }), })