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

298 lines
8.2 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,
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
txClient?: Prisma.TransactionClient,
): Promise<{ expired: number }> {
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
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 }
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
}
// 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',
},
},
})
}