Files
MOPC-Portal/src/server/services/assignment-intent.ts

291 lines
8.0 KiB
TypeScript
Raw Normal View History

import { TRPCError } from '@trpc/server'
import type { AssignmentIntent, AssignmentIntentSource, Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
// ============================================================================
// Create Intent
// ============================================================================
/**
* Creates an assignment intent (pre-assignment signal).
* Enforces uniqueness: one intent per (member, round, project).
*/
export async function createIntent(params: {
juryGroupMemberId: string
roundId: string
projectId: string
source: AssignmentIntentSource
actorId?: string
}): Promise<AssignmentIntent> {
const { juryGroupMemberId, roundId, projectId, source, actorId } = params
const intent = await prisma.$transaction(async (tx) => {
// Check for existing pending intent
const existing = await tx.assignmentIntent.findUnique({
where: {
juryGroupMemberId_roundId_projectId: {
juryGroupMemberId,
roundId,
projectId,
},
},
})
if (existing) {
if (existing.status === 'INTENT_PENDING') {
throw new TRPCError({
code: 'CONFLICT',
message: 'A pending intent already exists for this member/round/project',
})
}
// If previous intent was terminal (HONORED, OVERRIDDEN, EXPIRED, CANCELLED),
// allow creating a new one by updating it back to PENDING
const updated = await tx.assignmentIntent.update({
where: { id: existing.id },
data: { status: 'INTENT_PENDING', source },
})
await logIntentEvent(tx, 'intent.recreated', updated, actorId, {
previousStatus: existing.status,
source,
})
return updated
}
const created = await tx.assignmentIntent.create({
data: {
juryGroupMemberId,
roundId,
projectId,
source,
status: 'INTENT_PENDING',
},
})
await logIntentEvent(tx, 'intent.created', created, actorId, { source })
return created
})
return intent
}
// ============================================================================
// Honor Intent (PENDING → HONORED)
// ============================================================================
/**
* Marks an intent as HONORED when the corresponding assignment is created.
* Only INTENT_PENDING intents can be honored.
*/
export async function honorIntent(
intentId: string,
assignmentId: string,
actorId?: string,
): Promise<AssignmentIntent> {
return prisma.$transaction(async (tx) => {
const intent = await tx.assignmentIntent.findUniqueOrThrow({
where: { id: intentId },
})
assertPending(intent)
const updated = await tx.assignmentIntent.update({
where: { id: intentId },
data: { status: 'HONORED' },
})
await logIntentEvent(tx, 'intent.honored', updated, actorId, {
assignmentId,
})
return updated
})
}
// ============================================================================
// Override Intent (PENDING → OVERRIDDEN)
// ============================================================================
/**
* Marks an intent as OVERRIDDEN when an admin overrides the pre-assignment.
* Only INTENT_PENDING intents can be overridden.
*/
export async function overrideIntent(
intentId: string,
reason: string,
actorId?: string,
): Promise<AssignmentIntent> {
return prisma.$transaction(async (tx) => {
const intent = await tx.assignmentIntent.findUniqueOrThrow({
where: { id: intentId },
})
assertPending(intent)
const updated = await tx.assignmentIntent.update({
where: { id: intentId },
data: { status: 'OVERRIDDEN' },
})
await logIntentEvent(tx, 'intent.overridden', updated, actorId, { reason })
return updated
})
}
// ============================================================================
// Cancel Intent (PENDING → CANCELLED)
// ============================================================================
/**
* Marks an intent as CANCELLED (e.g. admin removes it before it is honored).
* Only INTENT_PENDING intents can be cancelled.
*/
export async function cancelIntent(
intentId: string,
reason: string,
actorId?: string,
): Promise<AssignmentIntent> {
return prisma.$transaction(async (tx) => {
const intent = await tx.assignmentIntent.findUniqueOrThrow({
where: { id: intentId },
})
assertPending(intent)
const updated = await tx.assignmentIntent.update({
where: { id: intentId },
data: { status: 'CANCELLED' },
})
await logIntentEvent(tx, 'intent.cancelled', updated, actorId, { reason })
return updated
})
}
// ============================================================================
// Expire Intents for Round (batch PENDING → EXPIRED)
// ============================================================================
/**
* Expires all INTENT_PENDING intents for a given round.
* Typically called when a round transitions past the assignment phase.
*/
export async function expireIntentsForRound(
roundId: string,
actorId?: string,
): Promise<{ expired: number }> {
return prisma.$transaction(async (tx) => {
const pending = await tx.assignmentIntent.findMany({
where: { roundId, status: 'INTENT_PENDING' },
})
if (pending.length === 0) return { expired: 0 }
await tx.assignmentIntent.updateMany({
where: { roundId, status: 'INTENT_PENDING' },
data: { status: 'EXPIRED' },
})
await tx.decisionAuditLog.create({
data: {
eventType: 'intent.batch_expired',
entityType: 'Round',
entityId: roundId,
actorId: actorId ?? null,
detailsJson: {
expiredCount: pending.length,
intentIds: pending.map((i) => i.id),
} as Prisma.InputJsonValue,
snapshotJson: {
timestamp: new Date().toISOString(),
emittedBy: 'assignment-intent',
},
},
})
return { expired: pending.length }
})
}
// ============================================================================
// Query Helpers
// ============================================================================
export async function getPendingIntentsForRound(
roundId: string,
): Promise<AssignmentIntent[]> {
return prisma.assignmentIntent.findMany({
where: { roundId, status: 'INTENT_PENDING' },
orderBy: { createdAt: 'asc' },
})
}
export async function getPendingIntentsForMember(
juryGroupMemberId: string,
roundId: string,
): Promise<AssignmentIntent[]> {
return prisma.assignmentIntent.findMany({
where: {
juryGroupMemberId,
roundId,
status: 'INTENT_PENDING',
},
orderBy: { createdAt: 'asc' },
})
}
export async function getIntentsForRound(
roundId: string,
): Promise<AssignmentIntent[]> {
return prisma.assignmentIntent.findMany({
where: { roundId },
orderBy: { createdAt: 'asc' },
})
}
// ============================================================================
// Internals
// ============================================================================
function assertPending(intent: AssignmentIntent): void {
if (intent.status !== 'INTENT_PENDING') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Intent ${intent.id} is ${intent.status}, only INTENT_PENDING intents can transition`,
})
}
}
async function logIntentEvent(
tx: Prisma.TransactionClient,
eventType: string,
intent: AssignmentIntent,
actorId: string | undefined,
details: Record<string, unknown>,
): Promise<void> {
await tx.decisionAuditLog.create({
data: {
eventType,
entityType: 'AssignmentIntent',
entityId: intent.id,
actorId: actorId ?? null,
detailsJson: {
juryGroupMemberId: intent.juryGroupMemberId,
roundId: intent.roundId,
projectId: intent.projectId,
status: intent.status,
source: intent.source,
...details,
} as Prisma.InputJsonValue,
snapshotJson: {
timestamp: new Date().toISOString(),
emittedBy: 'assignment-intent',
},
},
})
}