354 lines
11 KiB
TypeScript
354 lines
11 KiB
TypeScript
|
|
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 }
|
||
|
|
}),
|
||
|
|
})
|