import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma, type PrismaClient } from '@prisma/client' import { router, adminProcedure, protectedProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs' import { generateShortlist } from '../services/ai-shortlist' import { createBulkNotifications } from '../services/in-app-notification' import { getAdvancementNotificationTemplate, getRejectionNotificationTemplate, sendStyledNotificationEmail, sendInvitationEmail, getBaseUrl, } from '@/lib/email' import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite' import { openWindow, closeWindow, lockWindow, checkDeadlinePolicy, validateSubmission, getVisibleWindows, } from '../services/submission-manager' const roundTypeEnum = z.enum([ 'INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION', ]) export const roundRouter = router({ /** * Create a new round within a competition */ create: adminProcedure .input( z.object({ competitionId: z.string(), name: z.string().min(1).max(255), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), roundType: roundTypeEnum, sortOrder: z.number().int().nonnegative(), configJson: z.record(z.unknown()).optional(), windowOpenAt: z.date().nullable().optional(), windowCloseAt: z.date().nullable().optional(), juryGroupId: z.string().nullable().optional(), submissionWindowId: z.string().nullable().optional(), purposeKey: z.string().nullable().optional(), }) ) .mutation(async ({ ctx, input }) => { // Verify competition exists await ctx.prisma.competition.findUniqueOrThrow({ where: { id: input.competitionId }, }) // Validate configJson against the Zod schema for this roundType const config = input.configJson ? validateRoundConfig(input.roundType, input.configJson) : defaultRoundConfig(input.roundType) const round = await ctx.prisma.round.create({ data: { competitionId: input.competitionId, name: input.name, slug: input.slug, roundType: input.roundType, sortOrder: input.sortOrder, configJson: config as unknown as Prisma.InputJsonValue, windowOpenAt: input.windowOpenAt ?? undefined, windowCloseAt: input.windowCloseAt ?? undefined, juryGroupId: input.juryGroupId ?? undefined, submissionWindowId: input.submissionWindowId ?? undefined, purposeKey: input.purposeKey ?? undefined, }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', entityType: 'Round', entityId: round.id, detailsJson: { name: input.name, roundType: input.roundType, competitionId: input.competitionId, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return round }), /** * Get round by ID with all relations */ getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUnique({ where: { id: input.id }, include: { juryGroup: { include: { members: { include: { user: { select: { id: true, name: true, email: true } }, }, }, }, }, submissionWindow: { include: { fileRequirements: true }, }, advancementRules: { orderBy: { sortOrder: 'asc' } }, visibleSubmissionWindows: { include: { submissionWindow: true }, }, _count: { select: { projectRoundStates: true }, }, }, }) if (!round) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' }) } return round }), /** * Update round settings/config */ update: adminProcedure .input( z.object({ id: z.string(), name: z.string().min(1).max(255).optional(), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(), status: z.enum(['ROUND_DRAFT', 'ROUND_ACTIVE', 'ROUND_CLOSED', 'ROUND_ARCHIVED']).optional(), configJson: z.record(z.unknown()).optional(), windowOpenAt: z.date().nullable().optional(), windowCloseAt: z.date().nullable().optional(), juryGroupId: z.string().nullable().optional(), submissionWindowId: z.string().nullable().optional(), purposeKey: z.string().nullable().optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, configJson, ...data } = input const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id } }) // If configJson provided, validate it against the round type let validatedConfig: Prisma.InputJsonValue | undefined if (configJson) { const parsed = validateRoundConfig(existing.roundType, configJson) validatedConfig = parsed as unknown as Prisma.InputJsonValue } const round = await ctx.prisma.round.update({ where: { id }, data: { ...data, ...(validatedConfig !== undefined ? { configJson: validatedConfig } : {}), }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE', entityType: 'Round', entityId: id, detailsJson: { changes: input, previous: { name: existing.name, status: existing.status, }, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return round }), /** * Reorder rounds within a competition */ updateOrder: adminProcedure .input( z.object({ competitionId: z.string(), roundIds: z.array(z.string()), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.$transaction( input.roundIds.map((roundId, index) => ctx.prisma.round.update({ where: { id: roundId }, data: { sortOrder: index }, }) ) ) }), /** * Delete a round */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.id } }) await ctx.prisma.round.delete({ where: { id: input.id } }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'DELETE', entityType: 'Round', entityId: input.id, detailsJson: { name: existing.name, roundType: existing.roundType, competitionId: existing.competitionId, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return existing }), // ========================================================================= // Project Advancement (Manual Only) // ========================================================================= /** * Advance PASSED projects from one round to the next. * This is ALWAYS manual — no auto-advancement after AI filtering. * Admin must explicitly trigger this after reviewing results. */ advanceProjects: adminProcedure .input( z.object({ roundId: z.string(), targetRoundId: z.string().optional(), projectIds: z.array(z.string()).optional(), autoPassPending: z.boolean().optional(), }) ) .mutation(async ({ ctx, input }) => { const { roundId, targetRoundId, projectIds, autoPassPending } = input // Get current round with competition context + status const currentRound = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { id: true, name: true, competitionId: true, sortOrder: true, status: true, configJson: true }, }) // Validate: current round must be ROUND_ACTIVE or ROUND_CLOSED if (currentRound.status !== 'ROUND_ACTIVE' && currentRound.status !== 'ROUND_CLOSED') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Cannot advance from round with status ${currentRound.status}. Round must be ROUND_ACTIVE or ROUND_CLOSED.`, }) } // Determine target round let targetRound: { id: string; name: string; competitionId: string; sortOrder: number; configJson: unknown } if (targetRoundId) { targetRound = await ctx.prisma.round.findUniqueOrThrow({ where: { id: targetRoundId }, select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true }, }) // Validate: target must be in same competition if (targetRound.competitionId !== currentRound.competitionId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Target round must belong to the same competition as the source round.', }) } // Validate: target must be after current round if (targetRound.sortOrder <= currentRound.sortOrder) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Target round must come after the current round (higher sortOrder).', }) } } else { // Find next round in same competition by sortOrder const nextRound = await ctx.prisma.round.findFirst({ where: { competitionId: currentRound.competitionId, sortOrder: { gt: currentRound.sortOrder }, }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true }, }) if (!nextRound) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No subsequent round exists in this competition. Create the next round first.', }) } targetRound = nextRound } // Validate projectIds exist in current round if provided if (projectIds && projectIds.length > 0) { const existingStates = await ctx.prisma.projectRoundState.findMany({ where: { roundId, projectId: { in: projectIds } }, select: { projectId: true }, }) const existingIds = new Set(existingStates.map((s) => s.projectId)) const missing = projectIds.filter((id) => !existingIds.has(id)) if (missing.length > 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Projects not found in current round: ${missing.join(', ')}`, }) } } // Transaction: auto-pass + create entries in target round + mark current as COMPLETED let autoPassedCount = 0 let idsToAdvance: string[] await ctx.prisma.$transaction(async (tx) => { // Auto-pass all PENDING projects first (for intake/bulk workflows) — inside tx if (autoPassPending) { const result = await tx.projectRoundState.updateMany({ where: { roundId, state: 'PENDING' }, data: { state: 'PASSED' }, }) autoPassedCount = result.count } // Determine which projects to advance if (projectIds && projectIds.length > 0) { idsToAdvance = projectIds } else { // Default: all PASSED projects in current round const passedStates = await tx.projectRoundState.findMany({ where: { roundId, state: 'PASSED' }, select: { projectId: true }, }) idsToAdvance = passedStates.map((s) => s.projectId) } if (idsToAdvance.length === 0) return // Create ProjectRoundState in target round await tx.projectRoundState.createMany({ data: idsToAdvance.map((projectId) => ({ projectId, roundId: targetRound.id, })), skipDuplicates: true, }) // Mark current round states as COMPLETED await tx.projectRoundState.updateMany({ where: { roundId, projectId: { in: idsToAdvance }, state: 'PASSED', }, data: { state: 'COMPLETED' }, }) // Update project status to ASSIGNED await tx.project.updateMany({ where: { id: { in: idsToAdvance } }, data: { status: 'ASSIGNED' }, }) // Status history await tx.projectStatusHistory.createMany({ data: idsToAdvance.map((projectId) => ({ projectId, status: 'ASSIGNED', changedBy: ctx.user?.id, })), }) }) // If nothing to advance (set inside tx), return early // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!idsToAdvance! || idsToAdvance!.length === 0) { return { advancedCount: 0, autoPassedCount, targetRoundId: targetRound.id, targetRoundName: targetRound.name } } // Audit await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'ADVANCE_PROJECTS', entityType: 'Round', entityId: roundId, detailsJson: { fromRound: currentRound.name, toRound: targetRound.name, targetRoundId: targetRound.id, projectCount: idsToAdvance!.length, autoPassedCount, projectIds: idsToAdvance!, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { advancedCount: idsToAdvance!.length, autoPassedCount, targetRoundId: targetRound.id, targetRoundName: targetRound.name, } }), // ========================================================================= // AI Shortlist Recommendations // ========================================================================= /** * Generate AI-powered shortlist recommendations for a round. * Runs independently for STARTUP and BUSINESS_CONCEPT categories. * Uses per-round config for advancement targets and file parsing. */ generateAIRecommendations: adminProcedure .input( z.object({ roundId: z.string(), rubric: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { id: true, name: true, competitionId: true, configJson: true, }, }) const config = (round.configJson as Record) ?? {} const startupTopN = (config.startupAdvanceCount as number) || 10 const conceptTopN = (config.conceptAdvanceCount as number) || 10 const aiParseFiles = !!config.aiParseFiles const result = await generateShortlist( { roundId: input.roundId, competitionId: round.competitionId, startupTopN, conceptTopN, rubric: input.rubric, aiParseFiles, }, ctx.prisma, ) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'AI_SHORTLIST', entityType: 'Round', entityId: input.roundId, detailsJson: { roundName: round.name, startupTopN, conceptTopN, aiParseFiles, success: result.success, startupCount: result.recommendations.STARTUP.length, conceptCount: result.recommendations.BUSINESS_CONCEPT.length, tokensUsed: result.tokensUsed, errors: result.errors, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return result }), // ========================================================================= // Submission Window Management // ========================================================================= /** * Create a submission window for a round */ createSubmissionWindow: adminProcedure .input( z.object({ competitionId: z.string(), name: z.string().min(1).max(255), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), roundNumber: z.number().int().min(1), windowOpenAt: z.date().optional(), windowCloseAt: z.date().optional(), deadlinePolicy: z.enum(['HARD_DEADLINE', 'FLAG', 'GRACE']).default('HARD_DEADLINE'), graceHours: z.number().int().min(0).optional(), lockOnClose: z.boolean().default(true), }) ) .mutation(async ({ ctx, input }) => { const window = await ctx.prisma.submissionWindow.create({ data: { competitionId: input.competitionId, name: input.name, slug: input.slug, roundNumber: input.roundNumber, windowOpenAt: input.windowOpenAt, windowCloseAt: input.windowCloseAt, deadlinePolicy: input.deadlinePolicy, graceHours: input.graceHours, lockOnClose: input.lockOnClose, }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', entityType: 'SubmissionWindow', entityId: window.id, detailsJson: { name: input.name, competitionId: input.competitionId }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return window }), /** * Update an existing submission window */ updateSubmissionWindow: adminProcedure .input( z.object({ id: z.string(), name: z.string().min(1).max(255).optional(), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(), roundNumber: z.number().int().min(1).optional(), windowOpenAt: z.date().nullable().optional(), windowCloseAt: z.date().nullable().optional(), deadlinePolicy: z.enum(['HARD_DEADLINE', 'FLAG', 'GRACE']).optional(), graceHours: z.number().int().min(0).nullable().optional(), lockOnClose: z.boolean().optional(), sortOrder: z.number().int().optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input const window = await ctx.prisma.submissionWindow.update({ where: { id }, data, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE', entityType: 'SubmissionWindow', entityId: id, detailsJson: data, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return window }), /** * Delete a submission window (only if no files uploaded) */ deleteSubmissionWindow: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // Check if window has uploaded files const window = await ctx.prisma.submissionWindow.findUniqueOrThrow({ where: { id: input.id }, select: { id: true, name: true, _count: { select: { projectFiles: true } } }, }) if (window._count.projectFiles > 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Cannot delete window "${window.name}" — it has ${window._count.projectFiles} uploaded files. Remove files first.`, }) } await ctx.prisma.submissionWindow.delete({ where: { id: input.id } }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'DELETE', entityType: 'SubmissionWindow', entityId: input.id, detailsJson: { name: window.name }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { success: true } }), /** * Open a submission window */ openSubmissionWindow: adminProcedure .input(z.object({ windowId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await openWindow(input.windowId, ctx.user.id, ctx.prisma) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to open window', }) } return result }), /** * Close a submission window */ closeSubmissionWindow: adminProcedure .input(z.object({ windowId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await closeWindow(input.windowId, ctx.user.id, ctx.prisma) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to close window', }) } return result }), /** * Lock a submission window */ lockSubmissionWindow: adminProcedure .input(z.object({ windowId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await lockWindow(input.windowId, ctx.user.id, ctx.prisma) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to lock window', }) } return result }), /** * Check deadline status of a window */ checkDeadline: protectedProcedure .input(z.object({ windowId: z.string() })) .query(async ({ ctx, input }) => { return checkDeadlinePolicy(input.windowId, ctx.prisma) }), /** * Validate files against window requirements */ validateSubmission: protectedProcedure .input( z.object({ projectId: z.string(), windowId: z.string(), files: z.array( z.object({ mimeType: z.string(), size: z.number(), requirementId: z.string().optional(), }) ), }) ) .mutation(async ({ ctx, input }) => { return validateSubmission(input.projectId, input.windowId, input.files, ctx.prisma) }), /** * Get visible submission windows for a round */ getVisibleWindows: protectedProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { return getVisibleWindows(input.roundId, ctx.prisma) }), // ========================================================================= // File Requirements Management // ========================================================================= /** * Create a file requirement for a submission window */ createFileRequirement: adminProcedure .input( z.object({ submissionWindowId: z.string(), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), label: z.string().min(1).max(255), description: z.string().max(2000).optional(), mimeTypes: z.array(z.string()).default([]), maxSizeMb: z.number().int().min(0).optional(), required: z.boolean().default(false), sortOrder: z.number().int().default(0), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.submissionFileRequirement.create({ data: input, }) }), /** * Update a file requirement */ updateFileRequirement: adminProcedure .input( z.object({ id: z.string(), label: z.string().min(1).max(255).optional(), description: z.string().max(2000).optional().nullable(), mimeTypes: z.array(z.string()).optional(), maxSizeMb: z.number().min(0).optional().nullable(), required: z.boolean().optional(), sortOrder: z.number().int().optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input return ctx.prisma.submissionFileRequirement.update({ where: { id }, data, }) }), /** * Delete a file requirement */ deleteFileRequirement: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.prisma.submissionFileRequirement.delete({ where: { id: input.id }, }) }), /** * Get submission windows for applicants in a competition */ getApplicantWindows: protectedProcedure .input(z.object({ competitionId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.submissionWindow.findMany({ where: { competitionId: input.competitionId }, include: { fileRequirements: { orderBy: { sortOrder: 'asc' } }, }, orderBy: { sortOrder: 'asc' }, }) }), /** * Get the most recent SUBMISSION round config for a program. * Used on the project edit page to show required document slots. */ getSubmissionRoundForProgram: adminProcedure .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => { const round = await ctx.prisma.round.findFirst({ where: { roundType: 'SUBMISSION', competition: { programId: input.programId }, }, select: { id: true, name: true, configJson: true, }, orderBy: { sortOrder: 'desc' }, }) return round ?? null }), // ========================================================================= // Notification Procedures // ========================================================================= previewAdvancementEmail: adminProcedure .input( z.object({ roundId: z.string(), targetRoundId: z.string().optional(), customMessage: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const { roundId, targetRoundId, customMessage } = input const currentRound = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { name: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } }, }) // Determine target round name const rounds = currentRound.competition.rounds const currentIdx = rounds.findIndex((r) => r.id === roundId) const targetRound = targetRoundId ? rounds.find((r) => r.id === targetRoundId) : rounds[currentIdx + 1] const toRoundName = targetRound?.name ?? 'Next Round' // Count recipients: team members of PASSED or COMPLETED projects in this round const projectStates = await ctx.prisma.projectRoundState.findMany({ where: { roundId, state: { in: ['PASSED', 'COMPLETED'] } }, select: { projectId: true }, }) const projectIds = projectStates.map((ps) => ps.projectId) let recipientCount = 0 if (projectIds.length > 0) { const teamMembers = await ctx.prisma.teamMember.findMany({ where: { projectId: { in: projectIds } }, select: { user: { select: { email: true } } }, }) const emails = new Set(teamMembers.map((tm) => tm.user.email).filter(Boolean)) // Also count submittedByEmail for projects without team member emails const projects = await ctx.prisma.project.findMany({ where: { id: { in: projectIds } }, select: { submittedByEmail: true, teamMembers: { select: { user: { select: { email: true } } } } }, }) for (const p of projects) { const hasTeamEmail = p.teamMembers.some((tm) => tm.user.email) if (!hasTeamEmail && p.submittedByEmail) { emails.add(p.submittedByEmail) } } recipientCount = emails.size } // Build preview HTML const template = getAdvancementNotificationTemplate( 'Team Member', 'Your Project', currentRound.name, toRoundName, customMessage || undefined ) return { html: template.html, subject: template.subject, recipientCount } }), sendAdvancementNotifications: adminProcedure .input( z.object({ roundId: z.string(), targetRoundId: z.string().optional(), customMessage: z.string().optional(), projectIds: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { const { roundId, targetRoundId, customMessage } = input const currentRound = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { name: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } }, }) const rounds = currentRound.competition.rounds const currentIdx = rounds.findIndex((r) => r.id === roundId) const targetRound = targetRoundId ? rounds.find((r) => r.id === targetRoundId) : rounds[currentIdx + 1] const toRoundName = targetRound?.name ?? 'Next Round' // Get target projects let projectIds = input.projectIds if (!projectIds) { const projectStates = await ctx.prisma.projectRoundState.findMany({ where: { roundId, state: { in: ['PASSED', 'COMPLETED'] } }, select: { projectId: true }, }) projectIds = projectStates.map((ps) => ps.projectId) } if (projectIds.length === 0) { return { sent: 0, failed: 0 } } // 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 } } }, }, }, }) let sent = 0 let failed = 0 const allUserIds = new Set() for (const project of projects) { const recipients = new Map() for (const tm of project.teamMembers) { if (tm.user.email) { recipients.set(tm.user.email, tm.user.name) allUserIds.add(tm.user.id) } } if (recipients.size === 0 && project.submittedByEmail) { recipients.set(project.submittedByEmail, null) } for (const [email, name] of recipients) { try { await sendStyledNotificationEmail( email, name || '', 'ADVANCEMENT_NOTIFICATION', { title: 'Your project has advanced!', message: '', linkUrl: '/applicant', metadata: { projectName: project.title, fromRoundName: currentRound.name, toRoundName, customMessage: customMessage || undefined, }, } ) sent++ } catch (err) { console.error(`[sendAdvancementNotifications] Failed for ${email}:`, err) failed++ } } } // Create in-app notifications if (allUserIds.size > 0) { void createBulkNotifications({ userIds: [...allUserIds], type: 'project_advanced', title: 'Your project has advanced!', message: `Your project has advanced from "${currentRound.name}" to "${toRoundName}".`, linkUrl: '/applicant', linkLabel: 'View Dashboard', icon: 'Trophy', priority: 'high', }) } // Audit await logAudit({ prisma: ctx.prisma, userId: ctx.user?.id, action: 'SEND_ADVANCEMENT_NOTIFICATIONS', entityType: 'Round', entityId: roundId, detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { sent, failed } }), previewRejectionEmail: adminProcedure .input( z.object({ roundId: z.string(), customMessage: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const { roundId, customMessage } = input const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { name: true }, }) // Count recipients: team members of REJECTED projects const projectStates = await ctx.prisma.projectRoundState.findMany({ where: { roundId, state: 'REJECTED' }, select: { projectId: true }, }) const projectIds = projectStates.map((ps) => ps.projectId) let recipientCount = 0 if (projectIds.length > 0) { const projects = await ctx.prisma.project.findMany({ where: { id: { in: projectIds } }, select: { submittedByEmail: true, teamMembers: { select: { user: { select: { email: true } } } } }, }) const emails = new Set() for (const p of projects) { const hasTeamEmail = p.teamMembers.some((tm) => tm.user.email) if (hasTeamEmail) { for (const tm of p.teamMembers) { if (tm.user.email) emails.add(tm.user.email) } } else if (p.submittedByEmail) { emails.add(p.submittedByEmail) } } recipientCount = emails.size } const template = getRejectionNotificationTemplate( 'Team Member', 'Your Project', round.name, customMessage || undefined ) return { html: template.html, subject: template.subject, recipientCount } }), sendRejectionNotifications: adminProcedure .input( z.object({ roundId: z.string(), customMessage: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { const { roundId, customMessage } = input const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { name: true }, }) const projectStates = await ctx.prisma.projectRoundState.findMany({ where: { roundId, state: 'REJECTED' }, select: { projectId: true }, }) const projectIds = projectStates.map((ps) => ps.projectId) if (projectIds.length === 0) { return { sent: 0, failed: 0 } } 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 } } }, }, }, }) let sent = 0 let failed = 0 const allUserIds = new Set() for (const project of projects) { const recipients = new Map() for (const tm of project.teamMembers) { if (tm.user.email) { recipients.set(tm.user.email, tm.user.name) allUserIds.add(tm.user.id) } } if (recipients.size === 0 && project.submittedByEmail) { recipients.set(project.submittedByEmail, null) } for (const [email, name] of recipients) { try { await sendStyledNotificationEmail( email, name || '', 'REJECTION_NOTIFICATION', { title: 'Update on your application', message: '', linkUrl: '/applicant', metadata: { projectName: project.title, roundName: round.name, customMessage: customMessage || undefined, }, } ) sent++ } catch (err) { console.error(`[sendRejectionNotifications] Failed for ${email}:`, err) failed++ } } } // In-app notifications if (allUserIds.size > 0) { void createBulkNotifications({ userIds: [...allUserIds], type: 'NOT_SELECTED', title: 'Update on your application', message: `Your project was not selected to advance from "${round.name}".`, linkUrl: '/applicant', linkLabel: 'View Dashboard', icon: 'Info', }) } await logAudit({ prisma: ctx.prisma, userId: ctx.user?.id, action: 'SEND_REJECTION_NOTIFICATIONS', entityType: 'Round', entityId: roundId, detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { sent, failed } }), getBulkInvitePreview: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const { roundId } = input // Get all projects in this round const projectStates = await ctx.prisma.projectRoundState.findMany({ where: { roundId }, select: { projectId: true }, }) const projectIds = projectStates.map((ps) => ps.projectId) if (projectIds.length === 0) { return { uninvitedCount: 0, totalTeamMembers: 0, alreadyInvitedCount: 0 } } // Get all team members for these projects const teamMembers = await ctx.prisma.teamMember.findMany({ where: { projectId: { in: projectIds } }, select: { user: { select: { id: true, status: true } } }, }) // Deduplicate by user ID const userMap = new Map() for (const tm of teamMembers) { userMap.set(tm.user.id, tm.user.status) } let uninvitedCount = 0 let alreadyInvitedCount = 0 for (const [, status] of userMap) { if (status === 'ACTIVE' || status === 'INVITED') { alreadyInvitedCount++ } else { uninvitedCount++ } } return { uninvitedCount, totalTeamMembers: userMap.size, alreadyInvitedCount, } }), bulkInviteTeamMembers: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const { roundId } = input // Get all projects in this round const projectStates = await ctx.prisma.projectRoundState.findMany({ where: { roundId }, select: { projectId: true }, }) const projectIds = projectStates.map((ps) => ps.projectId) if (projectIds.length === 0) { return { invited: 0, skipped: 0, failed: 0 } } // Get all team members with user details const teamMembers = await ctx.prisma.teamMember.findMany({ where: { projectId: { in: projectIds } }, select: { user: { select: { id: true, email: true, name: true, status: true, role: true } }, }, }) // Deduplicate by user ID const users = new Map() for (const tm of teamMembers) { if (tm.user.email && !users.has(tm.user.id)) { users.set(tm.user.id, tm.user) } } const baseUrl = getBaseUrl() const expiryHours = await getInviteExpiryHours(ctx.prisma as unknown as import('@prisma/client').PrismaClient) const expiryMs = expiryHours * 60 * 60 * 1000 let invited = 0 let skipped = 0 let failed = 0 for (const [, user] of users) { if (user.status === 'ACTIVE' || user.status === 'INVITED') { skipped++ continue } try { const token = generateInviteToken() await ctx.prisma.user.update({ where: { id: user.id }, data: { status: 'INVITED', inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), }, }) const inviteUrl = `${baseUrl}/accept-invite?token=${token}` await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours) invited++ } catch (err) { console.error(`[bulkInviteTeamMembers] Failed for ${user.email}:`, err) failed++ } } await logAudit({ prisma: ctx.prisma, userId: ctx.user?.id, action: 'BULK_INVITE_TEAM_MEMBERS', entityType: 'Round', entityId: roundId, detailsJson: { invited, skipped, failed, totalUsers: users.size }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { invited, skipped, failed } }), })