import { z } from 'zod' import type { Prisma } from '@prisma/client' import { router, protectedProcedure, adminProcedure } from '../trpc' import { logAudit } from '../utils/audit' import { wizardConfigSchema } from '@/types/wizard-config' import { parseWizardConfig } from '@/lib/wizard-config' export const programRouter = router({ /** * List all programs with optional filtering. * When includeStages is true, returns stages nested under * pipelines -> tracks -> stages, flattened as `stages` for convenience. */ list: protectedProcedure .input( z.object({ status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(), includeStages: z.boolean().optional(), }).optional() ) .query(async ({ ctx, input }) => { const includeStages = input?.includeStages || false const programs = await ctx.prisma.program.findMany({ where: input?.status ? { status: input.status } : undefined, orderBy: { year: 'desc' }, include: includeStages ? { pipelines: { include: { tracks: { include: { stages: { orderBy: { sortOrder: 'asc' }, include: { _count: { select: { assignments: true, projectStageStates: true }, }, }, }, }, }, }, }, } : undefined, }) // Flatten stages into a rounds-compatible shape for backward compatibility return programs.map((p) => ({ ...p, // Provide a flat `stages` array for convenience stages: (p as any).pipelines?.flatMap((pipeline: any) => pipeline.tracks?.flatMap((track: any) => (track.stages || []).map((stage: any) => ({ ...stage, pipelineName: pipeline.name, trackName: track.name, // Backward-compatible _count shape _count: { projects: stage._count?.projectStageStates || 0, assignments: stage._count?.assignments || 0, }, })) ) || [] ) || [], // Legacy alias rounds: (p as any).pipelines?.flatMap((pipeline: any) => pipeline.tracks?.flatMap((track: any) => (track.stages || []).map((stage: any) => ({ id: stage.id, name: stage.name, status: stage.status === 'STAGE_ACTIVE' ? 'ACTIVE' : stage.status === 'STAGE_CLOSED' ? 'CLOSED' : stage.status, votingEndAt: stage.windowCloseAt, _count: { projects: stage._count?.projectStageStates || 0, assignments: stage._count?.assignments || 0, }, })) ) || [] ) || [], })) }), /** * Get a single program with its stages (via pipelines) */ get: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const program = await ctx.prisma.program.findUniqueOrThrow({ where: { id: input.id }, include: { pipelines: { include: { tracks: { include: { stages: { orderBy: { sortOrder: 'asc' }, include: { _count: { select: { assignments: true, projectStageStates: true }, }, }, }, }, }, }, }, }, }) // Flatten stages for convenience const stages = (program as any).pipelines?.flatMap((pipeline: any) => pipeline.tracks?.flatMap((track: any) => (track.stages || []).map((stage: any) => ({ ...stage, _count: { projects: stage._count?.projectStageStates || 0, assignments: stage._count?.assignments || 0, }, })) ) || [] ) || [] return { ...program, stages, // Legacy alias rounds: stages.map((s: any) => ({ id: s.id, name: s.name, status: s.status === 'STAGE_ACTIVE' ? 'ACTIVE' : s.status === 'STAGE_CLOSED' ? 'CLOSED' : s.status, votingEndAt: s.windowCloseAt, _count: s._count, })), } }), /** * Create a new program (admin only) */ create: adminProcedure .input( z.object({ name: z.string().min(1).max(255), year: z.number().int().min(2020).max(2100), description: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { const program = await ctx.prisma.program.create({ data: input, }) // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', entityType: 'Program', entityId: program.id, detailsJson: input, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return program }), /** * Update a program (admin only) */ update: adminProcedure .input( z.object({ id: z.string(), name: z.string().min(1).max(255).optional(), slug: z.string().min(1).max(100).optional(), status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(), description: z.string().optional(), settingsJson: z.record(z.any()).optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input const program = await ctx.prisma.program.update({ where: { id }, data, }) // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE', entityType: 'Program', entityId: id, detailsJson: data, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return program }), /** * Delete a program (admin only) * Note: This will cascade delete all rounds, projects, etc. */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const program = await ctx.prisma.program.delete({ where: { id: input.id }, }) // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'DELETE', entityType: 'Program', entityId: input.id, detailsJson: { name: program.name, year: program.year }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return program }), /** * Get wizard config for a program (parsed from settingsJson) */ getWizardConfig: protectedProcedure .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => { const program = await ctx.prisma.program.findUniqueOrThrow({ where: { id: input.programId }, select: { settingsJson: true }, }) return parseWizardConfig(program.settingsJson) }), /** * Update wizard config for a program (admin only) */ updateWizardConfig: adminProcedure .input( z.object({ programId: z.string(), wizardConfig: wizardConfigSchema, }) ) .mutation(async ({ ctx, input }) => { const program = await ctx.prisma.program.findUniqueOrThrow({ where: { id: input.programId }, select: { settingsJson: true }, }) const currentSettings = (program.settingsJson || {}) as Record const updatedSettings = { ...currentSettings, wizardConfig: input.wizardConfig, } await ctx.prisma.program.update({ where: { id: input.programId }, data: { settingsJson: updatedSettings as Prisma.InputJsonValue, }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE', entityType: 'Program', entityId: input.programId, detailsJson: { field: 'wizardConfig', stepsEnabled: input.wizardConfig.steps.filter((s) => s.enabled).length, totalSteps: input.wizardConfig.steps.length, customFieldsCount: input.wizardConfig.customFields?.length ?? 0, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { success: true } }), })