Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
353
src/server/routers/decision.ts
Normal file
353
src/server/routers/decision.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
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 }
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user