All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
## 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>
298 lines
8.2 KiB
TypeScript
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',
|
|
},
|
|
},
|
|
})
|
|
}
|