import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, adminProcedure } from '../trpc' import { logAudit } from '../utils/audit' import { sendAnnouncementEmail } from '@/lib/email' import type { PrismaClient } from '@prisma/client' /** * Send round-entry notification emails to project team members. * Fire-and-forget: errors are logged but never block the assignment. */ async function sendRoundEntryEmails( prisma: PrismaClient, projectIds: string[], roundName: string, ) { try { // Fetch projects with team members' user emails + fallback submittedByEmail const projects = await prisma.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, title: true, submittedByEmail: true, teamMembers: { select: { user: { select: { email: true, name: true } }, }, }, }, }) const emailPromises: Promise[] = [] for (const project of projects) { // Collect unique emails for this project const recipients = new Map() for (const tm of project.teamMembers) { if (tm.user.email) { recipients.set(tm.user.email, tm.user.name) } } // Fallback: if no team members have emails, use submittedByEmail if (recipients.size === 0 && project.submittedByEmail) { recipients.set(project.submittedByEmail, null) } for (const [email, name] of recipients) { emailPromises.push( sendAnnouncementEmail( email, name, `Your project has entered: ${roundName}`, `Your project "${project.title}" has been added to the round "${roundName}" in the Monaco Ocean Protection Challenge. You will receive further instructions as the round progresses.`, 'View Your Dashboard', `${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/dashboard`, ).catch((err) => { console.error(`[round-entry-email] Failed to send to ${email}:`, err) }), ) } } await Promise.allSettled(emailPromises) } catch (err) { console.error('[round-entry-email] Failed to send round entry emails:', err) } } /** * Project Pool Router * * Manages the project pool for assigning projects to competition rounds. * Shows all projects by default, with optional filtering for unassigned-only * or projects not yet in a specific round. */ export const projectPoolRouter = router({ /** * List projects in the pool with filtering and pagination. * By default shows ALL projects. Use filters to narrow: * - unassignedOnly: true → only projects not in any round * - excludeRoundId: "..." → only projects not already in that round */ listUnassigned: adminProcedure .input( z.object({ programId: z.string(), competitionCategory: z .enum(['STARTUP', 'BUSINESS_CONCEPT']) .optional(), search: z.string().optional(), unassignedOnly: z.boolean().optional().default(false), excludeRoundId: z.string().optional(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(200).default(50), }) ) .query(async ({ ctx, input }) => { const { programId, competitionCategory, search, unassignedOnly, excludeRoundId, page, perPage } = input const skip = (page - 1) * perPage // Build where clause const where: Record = { programId, } // Optional: only show projects not in any round if (unassignedOnly) { where.projectRoundStates = { none: {} } } // Optional: exclude projects already in a specific round if (excludeRoundId && !unassignedOnly) { where.projectRoundStates = { none: { roundId: excludeRoundId }, } } // Filter by competition category if (competitionCategory) { where.competitionCategory = competitionCategory } // Search in title, teamName, description, institution, country, geographicZone, team member names if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { teamName: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } }, { institution: { contains: search, mode: 'insensitive' } }, { country: { contains: search, mode: 'insensitive' } }, { geographicZone: { contains: search, mode: 'insensitive' } }, { teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } }, ] } // Execute queries in parallel const [projects, total] = await Promise.all([ ctx.prisma.project.findMany({ where, skip, take: perPage, orderBy: { createdAt: 'desc' }, select: { id: true, title: true, teamName: true, description: true, competitionCategory: true, oceanIssue: true, country: true, status: true, submittedAt: true, createdAt: true, tags: true, wantsMentorship: true, programId: true, _count: { select: { files: true, teamMembers: true, }, }, projectRoundStates: { select: { roundId: true, state: true, round: { select: { name: true, roundType: true, sortOrder: true, }, }, }, orderBy: { round: { sortOrder: 'asc' }, }, }, }, }), ctx.prisma.project.count({ where }), ]) return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage), } }), /** * Bulk assign projects to a round */ assignToRound: adminProcedure .input( z.object({ projectIds: z.array(z.string()).min(1).max(200), roundId: z.string(), }) ) .mutation(async ({ ctx, input }) => { const { projectIds, roundId } = input // Step 1: Fetch all projects to validate const projects = await ctx.prisma.project.findMany({ where: { id: { in: projectIds }, }, select: { id: true, title: true, programId: true, }, }) // Validate all projects were found if (projects.length !== projectIds.length) { const foundIds = new Set(projects.map((p) => p.id)) const missingIds = projectIds.filter((id) => !foundIds.has(id)) throw new TRPCError({ code: 'BAD_REQUEST', message: `Some projects were not found: ${missingIds.join(', ')}`, }) } // Verify round exists and get config const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { id: true, name: true, configJson: true }, }) // Step 2: Perform bulk assignment in a transaction const result = await ctx.prisma.$transaction(async (tx) => { // Create ProjectRoundState entries for each project (skip existing) const assignmentData = projectIds.map((projectId) => ({ projectId, roundId, })) await tx.projectRoundState.createMany({ data: assignmentData, skipDuplicates: true, }) // Update project statuses const updatedProjects = await tx.project.updateMany({ where: { id: { in: projectIds }, }, data: { status: 'ASSIGNED', }, }) // Create status history records for each project await tx.projectStatusHistory.createMany({ data: projectIds.map((projectId) => ({ projectId, status: 'ASSIGNED', changedBy: ctx.user?.id, })), }) return updatedProjects }) // Audit outside transaction so failures don't roll back the assignment await logAudit({ prisma: ctx.prisma, userId: ctx.user?.id, action: 'BULK_ASSIGN_TO_ROUND', entityType: 'Project', detailsJson: { roundId, projectCount: projectIds.length, projectIds, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) // Send round-entry notification emails if enabled (fire-and-forget) const config = (round.configJson as Record) || {} if (config.notifyOnEntry) { void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name) } return { success: true, assignedCount: result.count, roundId, } }), /** * Assign ALL matching projects in a program to a round (server-side, no ID limit). * Skips projects already in the target round. */ assignAllToRound: adminProcedure .input( z.object({ programId: z.string(), roundId: z.string(), competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(), unassignedOnly: z.boolean().optional().default(false), }) ) .mutation(async ({ ctx, input }) => { const { programId, roundId, competitionCategory, unassignedOnly } = input // Verify round exists and get config const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { id: true, name: true, configJson: true }, }) // Find projects to assign const where: Record = { programId, } if (unassignedOnly) { // Only projects not in any round where.projectRoundStates = { none: {} } } else { // All projects not already in the target round where.projectRoundStates = { none: { roundId }, } } if (competitionCategory) { where.competitionCategory = competitionCategory } const projects = await ctx.prisma.project.findMany({ where, select: { id: true }, }) if (projects.length === 0) { return { success: true, assignedCount: 0, roundId } } const projectIds = projects.map((p) => p.id) const result = await ctx.prisma.$transaction(async (tx) => { await tx.projectRoundState.createMany({ data: projectIds.map((projectId) => ({ projectId, roundId })), skipDuplicates: true, }) const updated = await tx.project.updateMany({ where: { id: { in: projectIds } }, data: { status: 'ASSIGNED' }, }) await tx.projectStatusHistory.createMany({ data: projectIds.map((projectId) => ({ projectId, status: 'ASSIGNED', changedBy: ctx.user?.id, })), }) return updated }) // Audit outside transaction so failures don't roll back the assignment await logAudit({ prisma: ctx.prisma, userId: ctx.user?.id, action: 'BULK_ASSIGN_ALL_TO_ROUND', entityType: 'Project', detailsJson: { roundId, programId, competitionCategory: competitionCategory || 'ALL', unassignedOnly, projectCount: projectIds.length, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) // Send round-entry notification emails if enabled (fire-and-forget) const config = (round.configJson as Record) || {} if (config.notifyOnEntry) { void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name) } return { success: true, assignedCount: result.count, roundId } }), })