Files
MOPC-Portal/src/server/services/assignment-intent.ts
Matt baca483fcb
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX
## Critical Logic Fixes (Tier 1)
- Fix requiredReviews config key mismatch (always defaulted to 3)
- Fix double-email + stageName/roundName metadata mismatch in notifications
- Fix snake_case config reads in peer review (peerReviewEnabled was always blocked)
- Add server-side COI check to evaluation submit (was client-only)
- Fix hard-coded feedbackText.min(10) — now uses config values
- Fix binaryDecision corruption in non-binary scoring modes
- Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx
- Fix removeFromRound: now cleans up orphaned Assignment records
- Fix 3-day reminder sending wrong email template (was using 24h template)

## High-Priority Logic Fixes (Tier 2)
- Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED)
- Scope AI assignment job to jury group members (was querying all JURY_MEMBERs)
- Add COI awareness to AI assignment generation
- Enforce requireAllCriteriaScored server-side
- Fix expireIntentsForRound nested transaction (now uses caller's tx)
- Implement notifyOnEntry for advancement path
- Implement notifyOnAdvance (was dead config)
- Fix checkRequirementsAndTransition for SubmissionFileRequirement model

## New Features (Tier 3)
- Add Project to Round: dialog with "Create New" and "From Pool" tabs
- Assignment "By Project" mode: select project → assign multiple jurors
- Backend: project.createAndAssignToRound procedure

## UI/UX Improvements (Tier 4+5)
- Add AlertDialog confirmation to header status dropdown
- Replace native confirm() with AlertDialog in assignments table
- Jury stats card now display-only with "Change" link
- Assignments tab restructured into logical card groups
- Inline-editable round name in header
- Back button shows destination label
- Readiness checklist: green check instead of strikethrough
- Gate assignments tab when no jury group assigned
- Relative time on window stats card
- Toast feedback on date saves
- Disable advance button when no target round
- COI section shows placeholder when empty
- Round position shown as "Round X of Y"
- InlineMemberCap edit icon always visible
- Status badge tooltip with description
- Add REMINDER_3_DAYS email template
- Fix maybeSendEmail to respect notification preferences
- Optimize bulk notification email loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00

298 lines
8.2 KiB
TypeScript

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,
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<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',
},
},
})
}