MOPC-App/src/server/routers/decision.ts

354 lines
11 KiB
TypeScript
Raw Normal View History

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<string, unknown> = {}
// 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<string, unknown> ?? {}),
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<string, unknown>).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 }
}),
})