Competition/Round architecture: full platform rewrite (Phases 1-9)
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>
2026-02-15 23:04:15 +01:00
|
|
|
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,
|
2026-02-19 12:59:35 +01:00
|
|
|
txClient?: Prisma.TransactionClient,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
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>
2026-02-15 23:04:15 +01:00
|
|
|
): Promise<{ expired: number }> {
|
2026-02-19 12:59:35 +01:00
|
|
|
const run = async (tx: Prisma.TransactionClient) => {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
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>
2026-02-15 23:04:15 +01:00
|
|
|
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 }
|
2026-02-19 12:59:35 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If a transaction client was provided, use it directly; otherwise open a new one
|
|
|
|
|
if (txClient) {
|
|
|
|
|
return run(txClient)
|
|
|
|
|
}
|
|
|
|
|
return prisma.$transaction(run)
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
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>
2026-02-15 23:04:15 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// 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',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|