Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
290
src/server/services/assignment-intent.ts
Normal file
290
src/server/services/assignment-intent.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user