import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma, FilteringOutcome } from '@prisma/client' import { router, protectedProcedure, adminProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' export const decisionRouter = router({ /** * Override a project's stage state or filtering result */ override: adminProcedure .input( z.object({ entityType: z.enum([ 'ProjectStageState', 'FilteringResult', 'AwardEligibility', ]), entityId: z.string(), newValue: z.record(z.unknown()), reasonCode: z.enum([ 'DATA_CORRECTION', 'POLICY_EXCEPTION', 'JURY_CONFLICT', 'SPONSOR_DECISION', 'ADMIN_DISCRETION', ]), reasonText: z.string().max(2000).optional(), }) ) .mutation(async ({ ctx, input }) => { let previousValue: Record = {} // Fetch current value based on entity type switch (input.entityType) { case 'ProjectStageState': { const pss = await ctx.prisma.projectStageState.findUniqueOrThrow({ where: { id: input.entityId }, }) previousValue = { state: pss.state, metadataJson: pss.metadataJson, } // Validate the new state const newState = input.newValue.state as string | undefined if ( newState && !['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'ROUTED', 'COMPLETED', 'WITHDRAWN'].includes(newState) ) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Invalid state: ${newState}`, }) } await ctx.prisma.$transaction(async (tx) => { await tx.projectStageState.update({ where: { id: input.entityId }, data: { state: (newState as Prisma.EnumProjectStageStateValueFieldUpdateOperationsInput['set']) ?? pss.state, metadataJson: { ...(pss.metadataJson as Record ?? {}), lastOverride: { by: ctx.user.id, at: new Date().toISOString(), reason: input.reasonCode, }, } as Prisma.InputJsonValue, }, }) await tx.overrideAction.create({ data: { entityType: input.entityType, entityId: input.entityId, previousValue: previousValue as Prisma.InputJsonValue, newValueJson: input.newValue as Prisma.InputJsonValue, reasonCode: input.reasonCode, reasonText: input.reasonText ?? null, actorId: ctx.user.id, }, }) await tx.decisionAuditLog.create({ data: { eventType: 'override.applied', entityType: input.entityType, entityId: input.entityId, actorId: ctx.user.id, detailsJson: { previousValue, newValue: input.newValue, reasonCode: input.reasonCode, reasonText: input.reasonText, } as Prisma.InputJsonValue, snapshotJson: previousValue as Prisma.InputJsonValue, }, }) await logAudit({ prisma: tx, userId: ctx.user.id, action: 'DECISION_OVERRIDE', entityType: input.entityType, entityId: input.entityId, detailsJson: { reasonCode: input.reasonCode, reasonText: input.reasonText, previousState: previousValue.state, newState: input.newValue.state, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) }) break } case 'FilteringResult': { const fr = await ctx.prisma.filteringResult.findUniqueOrThrow({ where: { id: input.entityId }, }) previousValue = { outcome: fr.outcome, aiScreeningJson: fr.aiScreeningJson, } const newOutcome = input.newValue.outcome as string | undefined await ctx.prisma.$transaction(async (tx) => { if (newOutcome) { await tx.filteringResult.update({ where: { id: input.entityId }, data: { finalOutcome: newOutcome as FilteringOutcome }, }) } await tx.overrideAction.create({ data: { entityType: input.entityType, entityId: input.entityId, previousValue: previousValue as Prisma.InputJsonValue, newValueJson: input.newValue as Prisma.InputJsonValue, reasonCode: input.reasonCode, reasonText: input.reasonText ?? null, actorId: ctx.user.id, }, }) await tx.decisionAuditLog.create({ data: { eventType: 'override.applied', entityType: input.entityType, entityId: input.entityId, actorId: ctx.user.id, detailsJson: { previousValue, newValue: input.newValue, reasonCode: input.reasonCode, } as Prisma.InputJsonValue, }, }) await logAudit({ prisma: tx, userId: ctx.user.id, action: 'DECISION_OVERRIDE', entityType: input.entityType, entityId: input.entityId, detailsJson: { reasonCode: input.reasonCode, previousOutcome: (previousValue as Record).outcome, newOutcome, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) }) break } case 'AwardEligibility': { const ae = await ctx.prisma.awardEligibility.findUniqueOrThrow({ where: { id: input.entityId }, }) previousValue = { eligible: ae.eligible, method: ae.method, } const newEligible = input.newValue.eligible as boolean | undefined await ctx.prisma.$transaction(async (tx) => { if (newEligible !== undefined) { await tx.awardEligibility.update({ where: { id: input.entityId }, data: { eligible: newEligible, method: 'MANUAL', overriddenBy: ctx.user.id, overriddenAt: new Date(), }, }) } await tx.overrideAction.create({ data: { entityType: input.entityType, entityId: input.entityId, previousValue: previousValue as Prisma.InputJsonValue, newValueJson: input.newValue as Prisma.InputJsonValue, reasonCode: input.reasonCode, reasonText: input.reasonText ?? null, actorId: ctx.user.id, }, }) await tx.decisionAuditLog.create({ data: { eventType: 'override.applied', entityType: input.entityType, entityId: input.entityId, actorId: ctx.user.id, detailsJson: { previousValue, newValue: input.newValue, reasonCode: input.reasonCode, } as Prisma.InputJsonValue, }, }) await logAudit({ prisma: tx, userId: ctx.user.id, action: 'DECISION_OVERRIDE', entityType: input.entityType, entityId: input.entityId, detailsJson: { reasonCode: input.reasonCode, previousEligible: previousValue.eligible, newEligible, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) }) break } } return { success: true, entityType: input.entityType, entityId: input.entityId } }), /** * Get the full decision audit timeline for an entity */ auditTimeline: protectedProcedure .input( z.object({ entityType: z.string(), entityId: z.string(), }) ) .query(async ({ ctx, input }) => { const [decisionLogs, overrideActions] = await Promise.all([ ctx.prisma.decisionAuditLog.findMany({ where: { entityType: input.entityType, entityId: input.entityId, }, orderBy: { createdAt: 'desc' }, }), ctx.prisma.overrideAction.findMany({ where: { entityType: input.entityType, entityId: input.entityId, }, orderBy: { createdAt: 'desc' }, }), ]) // Merge and sort by timestamp const timeline = [ ...decisionLogs.map((dl) => ({ type: 'decision' as const, id: dl.id, eventType: dl.eventType, actorId: dl.actorId, details: dl.detailsJson, snapshot: dl.snapshotJson, createdAt: dl.createdAt, })), ...overrideActions.map((oa) => ({ type: 'override' as const, id: oa.id, eventType: `override.${oa.reasonCode}`, actorId: oa.actorId, details: { previousValue: oa.previousValue, newValue: oa.newValueJson, reasonCode: oa.reasonCode, reasonText: oa.reasonText, }, snapshot: null, createdAt: oa.createdAt, })), ].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) return { entityType: input.entityType, entityId: input.entityId, timeline } }), /** * Get override actions (paginated, admin only) */ getOverrides: adminProcedure .input( z.object({ entityType: z.string().optional(), reasonCode: z .enum([ 'DATA_CORRECTION', 'POLICY_EXCEPTION', 'JURY_CONFLICT', 'SPONSOR_DECISION', 'ADMIN_DISCRETION', ]) .optional(), cursor: z.string().optional(), limit: z.number().int().min(1).max(100).default(50), }) ) .query(async ({ ctx, input }) => { const where: Prisma.OverrideActionWhereInput = {} if (input.entityType) where.entityType = input.entityType if (input.reasonCode) where.reasonCode = input.reasonCode const items = await ctx.prisma.overrideAction.findMany({ where, take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: 'desc' }, }) let nextCursor: string | undefined if (items.length > input.limit) { const next = items.pop() nextCursor = next?.id } return { items, nextCursor } }), })