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 { 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 { 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 { 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 { 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, txClient?: Prisma.TransactionClient, ): Promise<{ expired: number }> { const run = async (tx: Prisma.TransactionClient) => { 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 } } // If a transaction client was provided, use it directly; otherwise open a new one if (txClient) { return run(txClient) } return prisma.$transaction(run) } // ============================================================================ // Query Helpers // ============================================================================ export async function getPendingIntentsForRound( roundId: string, ): Promise { return prisma.assignmentIntent.findMany({ where: { roundId, status: 'INTENT_PENDING' }, orderBy: { createdAt: 'asc' }, }) } export async function getPendingIntentsForMember( juryGroupMemberId: string, roundId: string, ): Promise { return prisma.assignmentIntent.findMany({ where: { juryGroupMemberId, roundId, status: 'INTENT_PENDING', }, orderBy: { createdAt: 'asc' }, }) } export async function getIntentsForRound( roundId: string, ): Promise { 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, ): Promise { 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', }, }, }) }