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 ? { competitions: { include: { rounds: { orderBy: { sortOrder: 'asc' }, include: { _count: { select: { assignments: true, projectRoundStates: true }, }, }, }, }, }, } : undefined, }) // Return programs with rounds flattened, preserving competitionId return programs.map((p) => { const allRounds = (p as any).competitions?.flatMap((c: any) => (c.rounds || []).map((round: any) => ({ ...round, competitionId: c.id })) ) || [] return { ...p, // Provide `stages` as alias for backward compatibility stages: allRounds.map((round: any) => ({ ...round, _count: { projects: round._count?.projectRoundStates || 0, assignments: round._count?.assignments || 0, }, })), // Main rounds array rounds: allRounds.map((round: any) => ({ id: round.id, name: round.name, competitionId: round.competitionId, status: round.status, votingEndAt: round.windowCloseAt, _count: { projects: round._count?.projectRoundStates || 0, assignments: round._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: { competitions: { include: { rounds: { orderBy: { sortOrder: 'asc' }, include: { _count: { select: { assignments: true, projectRoundStates: true }, }, }, }, }, }, }, }) // Flatten rounds from all competitions, preserving competitionId const allRounds = (program as any).competitions?.flatMap((c: any) => (c.rounds || []).map((round: any) => ({ ...round, competitionId: c.id })) ) || [] const rounds = allRounds.map((round: any) => ({ ...round, _count: { projects: round._count?.projectRoundStates || 0, assignments: round._count?.assignments || 0, }, })) || [] return { ...program, // stages as alias for backward compatibility stages: rounds, rounds, } }), /** * 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 } }), })