Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
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>
This commit is contained in:
@@ -16,7 +16,6 @@ import {
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { sendStyledNotificationEmail } from '@/lib/email'
|
||||
|
||||
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
|
||||
try {
|
||||
@@ -31,11 +30,12 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
||||
name: true,
|
||||
configJson: true,
|
||||
competitionId: true,
|
||||
juryGroupId: true,
|
||||
},
|
||||
})
|
||||
|
||||
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||
const requiredReviews = (config.requiredReviews as number) ?? 3
|
||||
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
||||
const minAssignmentsPerJuror =
|
||||
(config.minLoadPerJuror as number) ??
|
||||
(config.minAssignmentsPerJuror as number) ??
|
||||
@@ -45,8 +45,22 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
||||
(config.maxAssignmentsPerJuror as number) ??
|
||||
20
|
||||
|
||||
// Scope jurors to jury group if the round has one assigned
|
||||
let scopedJurorIds: string[] | undefined
|
||||
if (round.juryGroupId) {
|
||||
const groupMembers = await prisma.juryGroupMember.findMany({
|
||||
where: { juryGroupId: round.juryGroupId },
|
||||
select: { userId: true },
|
||||
})
|
||||
scopedJurorIds = groupMembers.map((m) => m.userId)
|
||||
}
|
||||
|
||||
const jurors = await prisma.user.findMany({
|
||||
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -96,6 +110,18 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
|
||||
// Query COI records for this round to exclude conflicted juror-project pairs
|
||||
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||
where: {
|
||||
roundId,
|
||||
hasConflict: true,
|
||||
},
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const coiExclusions = new Set(
|
||||
coiRecords.map((c) => `${c.userId}:${c.projectId}`)
|
||||
)
|
||||
|
||||
// Calculate batch info
|
||||
const BATCH_SIZE = 15
|
||||
const totalBatches = Math.ceil(projects.length / BATCH_SIZE)
|
||||
@@ -144,8 +170,13 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
||||
onProgress
|
||||
)
|
||||
|
||||
// Filter out suggestions that conflict with COI declarations
|
||||
const filteredSuggestions = coiExclusions.size > 0
|
||||
? result.suggestions.filter((s) => !coiExclusions.has(`${s.jurorId}:${s.projectId}`))
|
||||
: result.suggestions
|
||||
|
||||
// Enrich suggestions with names for storage
|
||||
const enrichedSuggestions = result.suggestions.map((s) => {
|
||||
const enrichedSuggestions = filteredSuggestions.map((s) => {
|
||||
const juror = jurors.find((j) => j.id === s.jurorId)
|
||||
const project = projects.find((p) => p.id === s.projectId)
|
||||
return {
|
||||
@@ -162,7 +193,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
processedCount: projects.length,
|
||||
suggestionsCount: result.suggestions.length,
|
||||
suggestionsCount: filteredSuggestions.length,
|
||||
suggestionsJson: enrichedSuggestions,
|
||||
fallbackUsed: result.fallbackUsed ?? false,
|
||||
},
|
||||
@@ -171,7 +202,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.AI_SUGGESTIONS_READY,
|
||||
title: 'AI Assignment Suggestions Ready',
|
||||
message: `AI generated ${result.suggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
|
||||
message: `AI generated ${filteredSuggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
|
||||
linkUrl: `/admin/rounds/${roundId}`,
|
||||
linkLabel: 'View Suggestions',
|
||||
priority: 'high',
|
||||
@@ -179,7 +210,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
||||
roundId,
|
||||
jobId,
|
||||
projectCount: projects.length,
|
||||
suggestionsCount: result.suggestions.length,
|
||||
suggestionsCount: filteredSuggestions.length,
|
||||
fallbackUsed: result.fallbackUsed,
|
||||
},
|
||||
})
|
||||
@@ -425,7 +456,7 @@ export const assignmentRouter = router({
|
||||
linkLabel: 'View Assignment',
|
||||
metadata: {
|
||||
projectName: project.title,
|
||||
stageName: stageInfo.name,
|
||||
roundName: stageInfo.name,
|
||||
deadline,
|
||||
assignmentId: assignment.id,
|
||||
},
|
||||
@@ -567,7 +598,7 @@ export const assignmentRouter = router({
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: {
|
||||
projectCount,
|
||||
stageName: stage?.name,
|
||||
roundName: stage?.name,
|
||||
deadline,
|
||||
},
|
||||
})
|
||||
@@ -621,7 +652,7 @@ export const assignmentRouter = router({
|
||||
select: { configJson: true },
|
||||
})
|
||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||
const requiredReviews = (config.requiredReviews as number) ?? 3
|
||||
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
||||
|
||||
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
@@ -692,7 +723,7 @@ export const assignmentRouter = router({
|
||||
select: { configJson: true },
|
||||
})
|
||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||
const requiredReviews = (config.requiredReviews as number) ?? 3
|
||||
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
||||
const minAssignmentsPerJuror =
|
||||
(config.minLoadPerJuror as number) ??
|
||||
(config.minAssignmentsPerJuror as number) ??
|
||||
@@ -1100,7 +1131,7 @@ export const assignmentRouter = router({
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: {
|
||||
projectCount,
|
||||
stageName: stage?.name,
|
||||
roundName: stage?.name,
|
||||
deadline,
|
||||
},
|
||||
})
|
||||
@@ -1252,7 +1283,7 @@ export const assignmentRouter = router({
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: {
|
||||
projectCount,
|
||||
stageName: stage?.name,
|
||||
roundName: stage?.name,
|
||||
deadline,
|
||||
},
|
||||
})
|
||||
@@ -1361,7 +1392,7 @@ export const assignmentRouter = router({
|
||||
|
||||
/**
|
||||
* Notify all jurors of their current assignments for a round (admin only).
|
||||
* Sends both in-app notifications AND direct emails to each juror.
|
||||
* Sends in-app notifications (emails are handled by maybeSendEmail via createBulkNotifications).
|
||||
*/
|
||||
notifyJurorsOfAssignments: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
@@ -1378,7 +1409,7 @@ export const assignmentRouter = router({
|
||||
})
|
||||
|
||||
if (assignments.length === 0) {
|
||||
return { sent: 0, jurorCount: 0, emailsSent: 0 }
|
||||
return { sent: 0, jurorCount: 0 }
|
||||
}
|
||||
|
||||
// Count assignments per user
|
||||
@@ -1414,44 +1445,11 @@ export const assignmentRouter = router({
|
||||
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`,
|
||||
linkUrl: `/jury/competitions`,
|
||||
linkLabel: 'View Assignments',
|
||||
metadata: { projectCount, stageName: round.name, deadline },
|
||||
metadata: { projectCount, roundName: round.name, deadline },
|
||||
})
|
||||
totalSent += userIds.length
|
||||
}
|
||||
|
||||
// Send direct emails to every juror (regardless of notification email settings)
|
||||
const allUserIds = Object.keys(userCounts)
|
||||
const users = await ctx.prisma.user.findMany({
|
||||
where: { id: { in: allUserIds } },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
||||
let emailsSent = 0
|
||||
|
||||
for (const user of users) {
|
||||
const projectCount = userCounts[user.id] || 0
|
||||
if (projectCount === 0) continue
|
||||
|
||||
try {
|
||||
await sendStyledNotificationEmail(
|
||||
user.email,
|
||||
user.name || '',
|
||||
'BATCH_ASSIGNED',
|
||||
{
|
||||
name: user.name || undefined,
|
||||
title: `Projects Assigned - ${round.name}`,
|
||||
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name}.`,
|
||||
linkUrl: `${baseUrl}/jury/competitions`,
|
||||
metadata: { projectCount, roundName: round.name, deadline },
|
||||
}
|
||||
)
|
||||
emailsSent++
|
||||
} catch (error) {
|
||||
console.error(`Failed to send assignment email to ${user.email}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
@@ -1461,12 +1459,11 @@ export const assignmentRouter = router({
|
||||
detailsJson: {
|
||||
jurorCount: Object.keys(userCounts).length,
|
||||
totalAssignments: assignments.length,
|
||||
emailsSent,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { sent: totalSent, jurorCount: Object.keys(userCounts).length, emailsSent }
|
||||
return { sent: totalSent, jurorCount: Object.keys(userCounts).length }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -132,9 +132,9 @@ export const evaluationRouter = router({
|
||||
z.object({
|
||||
id: z.string(),
|
||||
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])),
|
||||
globalScore: z.number().int().min(1).max(10),
|
||||
binaryDecision: z.boolean(),
|
||||
feedbackText: z.string().min(10),
|
||||
globalScore: z.number().int().min(1).max(10).optional(),
|
||||
binaryDecision: z.boolean().optional(),
|
||||
feedbackText: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -152,6 +152,17 @@ export const evaluationRouter = router({
|
||||
throw new TRPCError({ code: 'FORBIDDEN' })
|
||||
}
|
||||
|
||||
// Server-side COI check
|
||||
const coi = await ctx.prisma.conflictOfInterest.findFirst({
|
||||
where: { assignmentId: evaluation.assignmentId, hasConflict: true },
|
||||
})
|
||||
if (coi) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Cannot submit evaluation — conflict of interest declared',
|
||||
})
|
||||
}
|
||||
|
||||
// Check voting window via round
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: evaluation.assignment.roundId },
|
||||
@@ -194,12 +205,66 @@ export const evaluationRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Load round config for validation
|
||||
const config = (round.configJson as Record<string, unknown>) || {}
|
||||
const scoringMode = (config.scoringMode as string) || 'criteria'
|
||||
|
||||
// Fix 3: Dynamic feedback validation based on config
|
||||
const requireFeedback = config.requireFeedback !== false
|
||||
if (requireFeedback) {
|
||||
const feedbackMinLength = (config.feedbackMinLength as number) || 10
|
||||
if (!data.feedbackText || data.feedbackText.length < feedbackMinLength) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Feedback must be at least ${feedbackMinLength} characters`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fix 4: Normalize binaryDecision and globalScore based on scoringMode
|
||||
if (scoringMode !== 'binary') {
|
||||
data.binaryDecision = undefined
|
||||
}
|
||||
if (scoringMode === 'binary') {
|
||||
data.globalScore = undefined
|
||||
}
|
||||
|
||||
// Fix 5: requireAllCriteriaScored validation
|
||||
if (config.requireAllCriteriaScored && scoringMode === 'criteria') {
|
||||
const evalForm = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { roundId: round.id, isActive: true },
|
||||
select: { criteriaJson: true },
|
||||
})
|
||||
if (evalForm?.criteriaJson) {
|
||||
const criteria = evalForm.criteriaJson as Array<{ id: string; type?: string; required?: boolean }>
|
||||
const scorableCriteria = criteria.filter(
|
||||
(c) => c.type !== 'section_header' && c.type !== 'text' && c.required !== false
|
||||
)
|
||||
const scores = data.criterionScoresJson as Record<string, unknown> | undefined
|
||||
const missingCriteria = scorableCriteria.filter(
|
||||
(c) => !scores || typeof scores[c.id] !== 'number'
|
||||
)
|
||||
if (missingCriteria.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Missing scores for criteria: ${missingCriteria.map((c) => c.id).join(', ')}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit evaluation and mark assignment as completed atomically
|
||||
const saveData = {
|
||||
criterionScoresJson: data.criterionScoresJson,
|
||||
globalScore: data.globalScore ?? null,
|
||||
binaryDecision: data.binaryDecision ?? null,
|
||||
feedbackText: data.feedbackText ?? null,
|
||||
}
|
||||
const [updated] = await ctx.prisma.$transaction([
|
||||
ctx.prisma.evaluation.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...saveData,
|
||||
status: 'SUBMITTED',
|
||||
submittedAt: now,
|
||||
},
|
||||
@@ -784,7 +849,7 @@ export const evaluationRouter = router({
|
||||
})
|
||||
|
||||
const settings = (stage.configJson as Record<string, unknown>) || {}
|
||||
if (!settings.peer_review_enabled) {
|
||||
if (!settings.peerReviewEnabled) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Peer review is not enabled for this stage',
|
||||
@@ -843,7 +908,7 @@ export const evaluationRouter = router({
|
||||
})
|
||||
|
||||
// Anonymize individual scores based on round settings
|
||||
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
|
||||
const anonymizationLevel = (settings.anonymizationLevel as string) || 'fully_anonymous'
|
||||
|
||||
const individualScores = evaluations.map((e) => {
|
||||
let jurorLabel: string
|
||||
@@ -926,7 +991,7 @@ export const evaluationRouter = router({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
const settings = (round.configJson as Record<string, unknown>) || {}
|
||||
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
|
||||
const anonymizationLevel = (settings.anonymizationLevel as string) || 'fully_anonymous'
|
||||
|
||||
const anonymizedComments = discussion.comments.map((c: { id: string; userId: string; user: { name: string | null }; content: string; createdAt: Date }, idx: number) => {
|
||||
let authorLabel: string
|
||||
@@ -978,7 +1043,7 @@ export const evaluationRouter = router({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
const settings = (round.configJson as Record<string, unknown>) || {}
|
||||
const maxLength = (settings.max_comment_length as number) || 2000
|
||||
const maxLength = (settings.maxCommentLength as number) || 2000
|
||||
if (input.content.length > maxLength) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
|
||||
@@ -1249,4 +1249,97 @@ export const projectRouter = router({
|
||||
stats,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new project and assign it directly to a round.
|
||||
* Used for late-arriving projects that need to enter a specific round immediately.
|
||||
*/
|
||||
createAndAssignToRound: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1).max(500),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId, country, ...projectFields } = input
|
||||
|
||||
// Get the round to find competitionId, then competition to find programId
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
competition: {
|
||||
select: {
|
||||
id: true,
|
||||
programId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Normalize country to ISO code if provided
|
||||
const normalizedCountry = country
|
||||
? normalizeCountryToCode(country)
|
||||
: undefined
|
||||
|
||||
const project = await ctx.prisma.$transaction(async (tx) => {
|
||||
// 1. Create the project
|
||||
const created = await tx.project.create({
|
||||
data: {
|
||||
programId: round.competition.programId,
|
||||
title: projectFields.title,
|
||||
teamName: projectFields.teamName,
|
||||
description: projectFields.description,
|
||||
country: normalizedCountry,
|
||||
competitionCategory: projectFields.competitionCategory,
|
||||
status: 'ASSIGNED',
|
||||
},
|
||||
})
|
||||
|
||||
// 2. Create ProjectRoundState entry
|
||||
await tx.projectRoundState.create({
|
||||
data: {
|
||||
projectId: created.id,
|
||||
roundId,
|
||||
state: 'PENDING',
|
||||
},
|
||||
})
|
||||
|
||||
// 3. Create ProjectStatusHistory entry
|
||||
await tx.projectStatusHistory.create({
|
||||
data: {
|
||||
projectId: created.id,
|
||||
status: 'ASSIGNED',
|
||||
changedBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
// Audit outside transaction
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE_AND_ASSIGN',
|
||||
entityType: 'Project',
|
||||
entityId: project.id,
|
||||
detailsJson: {
|
||||
title: input.title,
|
||||
roundId,
|
||||
roundName: round.name,
|
||||
programId: round.competition.programId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return project
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { Prisma, type PrismaClient } from '@prisma/client'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
|
||||
import { generateShortlist } from '../services/ai-shortlist'
|
||||
import { createBulkNotifications } from '../services/in-app-notification'
|
||||
import { sendAnnouncementEmail } from '@/lib/email'
|
||||
import {
|
||||
openWindow,
|
||||
closeWindow,
|
||||
@@ -255,19 +257,43 @@ export const roundRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId, targetRoundId, projectIds, autoPassPending } = input
|
||||
|
||||
// Get current round with competition context
|
||||
// Get current round with competition context + status
|
||||
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { id: true, name: true, competitionId: true, sortOrder: true },
|
||||
select: { id: true, name: true, competitionId: true, sortOrder: true, status: true, configJson: true },
|
||||
})
|
||||
|
||||
// Validate: current round must be ROUND_ACTIVE or ROUND_CLOSED
|
||||
if (currentRound.status !== 'ROUND_ACTIVE' && currentRound.status !== 'ROUND_CLOSED') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Cannot advance from round with status ${currentRound.status}. Round must be ROUND_ACTIVE or ROUND_CLOSED.`,
|
||||
})
|
||||
}
|
||||
|
||||
// Determine target round
|
||||
let targetRound: { id: string; name: string }
|
||||
let targetRound: { id: string; name: string; competitionId: string; sortOrder: number; configJson: unknown }
|
||||
if (targetRoundId) {
|
||||
targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: targetRoundId },
|
||||
select: { id: true, name: true },
|
||||
select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true },
|
||||
})
|
||||
|
||||
// Validate: target must be in same competition
|
||||
if (targetRound.competitionId !== currentRound.competitionId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Target round must belong to the same competition as the source round.',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate: target must be after current round
|
||||
if (targetRound.sortOrder <= currentRound.sortOrder) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Target round must come after the current round (higher sortOrder).',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Find next round in same competition by sortOrder
|
||||
const nextRound = await ctx.prisma.round.findFirst({
|
||||
@@ -276,7 +302,7 @@ export const roundRouter = router({
|
||||
sortOrder: { gt: currentRound.sortOrder },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true },
|
||||
})
|
||||
if (!nextRound) {
|
||||
throw new TRPCError({
|
||||
@@ -287,35 +313,50 @@ export const roundRouter = router({
|
||||
targetRound = nextRound
|
||||
}
|
||||
|
||||
// Auto-pass all PENDING projects first (for intake/bulk workflows)
|
||||
let autoPassedCount = 0
|
||||
if (autoPassPending) {
|
||||
const result = await ctx.prisma.projectRoundState.updateMany({
|
||||
where: { roundId, state: 'PENDING' },
|
||||
data: { state: 'PASSED' },
|
||||
})
|
||||
autoPassedCount = result.count
|
||||
}
|
||||
|
||||
// Determine which projects to advance
|
||||
let idsToAdvance: string[]
|
||||
// Validate projectIds exist in current round if provided
|
||||
if (projectIds && projectIds.length > 0) {
|
||||
idsToAdvance = projectIds
|
||||
} else {
|
||||
// Default: all PASSED projects in current round
|
||||
const passedStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: 'PASSED' },
|
||||
const existingStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, projectId: { in: projectIds } },
|
||||
select: { projectId: true },
|
||||
})
|
||||
idsToAdvance = passedStates.map((s) => s.projectId)
|
||||
const existingIds = new Set(existingStates.map((s) => s.projectId))
|
||||
const missing = projectIds.filter((id) => !existingIds.has(id))
|
||||
if (missing.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Projects not found in current round: ${missing.join(', ')}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (idsToAdvance.length === 0) {
|
||||
return { advancedCount: 0, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
|
||||
}
|
||||
// Transaction: auto-pass + create entries in target round + mark current as COMPLETED
|
||||
let autoPassedCount = 0
|
||||
let idsToAdvance: string[]
|
||||
|
||||
// Transaction: create entries in target round + mark current as COMPLETED
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
// Auto-pass all PENDING projects first (for intake/bulk workflows) — inside tx
|
||||
if (autoPassPending) {
|
||||
const result = await tx.projectRoundState.updateMany({
|
||||
where: { roundId, state: 'PENDING' },
|
||||
data: { state: 'PASSED' },
|
||||
})
|
||||
autoPassedCount = result.count
|
||||
}
|
||||
|
||||
// Determine which projects to advance
|
||||
if (projectIds && projectIds.length > 0) {
|
||||
idsToAdvance = projectIds
|
||||
} else {
|
||||
// Default: all PASSED projects in current round
|
||||
const passedStates = await tx.projectRoundState.findMany({
|
||||
where: { roundId, state: 'PASSED' },
|
||||
select: { projectId: true },
|
||||
})
|
||||
idsToAdvance = passedStates.map((s) => s.projectId)
|
||||
}
|
||||
|
||||
if (idsToAdvance.length === 0) return
|
||||
|
||||
// Create ProjectRoundState in target round
|
||||
await tx.projectRoundState.createMany({
|
||||
data: idsToAdvance.map((projectId) => ({
|
||||
@@ -351,6 +392,12 @@ export const roundRouter = router({
|
||||
})
|
||||
})
|
||||
|
||||
// If nothing to advance (set inside tx), return early
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!idsToAdvance! || idsToAdvance!.length === 0) {
|
||||
return { advancedCount: 0, autoPassedCount, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
|
||||
}
|
||||
|
||||
// Audit
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
@@ -362,16 +409,105 @@ export const roundRouter = router({
|
||||
fromRound: currentRound.name,
|
||||
toRound: targetRound.name,
|
||||
targetRoundId: targetRound.id,
|
||||
projectCount: idsToAdvance.length,
|
||||
projectCount: idsToAdvance!.length,
|
||||
autoPassedCount,
|
||||
projectIds: idsToAdvance,
|
||||
projectIds: idsToAdvance!,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Fix 5: notifyOnEntry — notify team members when projects enter target round
|
||||
try {
|
||||
const targetConfig = (targetRound.configJson as Record<string, unknown>) || {}
|
||||
if (targetConfig.notifyOnEntry) {
|
||||
const teamMembers = await ctx.prisma.teamMember.findMany({
|
||||
where: { projectId: { in: idsToAdvance! } },
|
||||
select: { userId: true },
|
||||
})
|
||||
const userIds = [...new Set(teamMembers.map((tm) => tm.userId))]
|
||||
if (userIds.length > 0) {
|
||||
void createBulkNotifications({
|
||||
userIds,
|
||||
type: 'round_entry',
|
||||
title: `Projects entered: ${targetRound.name}`,
|
||||
message: `Your project has been advanced to the round "${targetRound.name}".`,
|
||||
linkUrl: '/dashboard',
|
||||
linkLabel: 'View Dashboard',
|
||||
icon: 'ArrowRight',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
console.error('[advanceProjects] notifyOnEntry notification failed (non-fatal):', notifyErr)
|
||||
}
|
||||
|
||||
// Fix 6: notifyOnAdvance — notify applicants from source round that projects advanced
|
||||
try {
|
||||
const sourceConfig = (currentRound.configJson as Record<string, unknown>) || {}
|
||||
if (sourceConfig.notifyOnAdvance) {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: idsToAdvance! } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
submittedByEmail: true,
|
||||
teamMembers: {
|
||||
select: { user: { select: { id: true, email: true, name: true } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Collect unique user IDs for in-app notifications
|
||||
const applicantUserIds = new Set<string>()
|
||||
for (const project of projects) {
|
||||
for (const tm of project.teamMembers) {
|
||||
applicantUserIds.add(tm.user.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (applicantUserIds.size > 0) {
|
||||
void createBulkNotifications({
|
||||
userIds: [...applicantUserIds],
|
||||
type: 'project_advanced',
|
||||
title: 'Your project has advanced!',
|
||||
message: `Congratulations! Your project has advanced from "${currentRound.name}" to "${targetRound.name}".`,
|
||||
linkUrl: '/dashboard',
|
||||
linkLabel: 'View Dashboard',
|
||||
icon: 'Trophy',
|
||||
priority: 'high',
|
||||
})
|
||||
}
|
||||
|
||||
// Send emails to team members (fire-and-forget)
|
||||
for (const project of projects) {
|
||||
const recipients = new Map<string, string | null>()
|
||||
for (const tm of project.teamMembers) {
|
||||
if (tm.user.email) recipients.set(tm.user.email, tm.user.name)
|
||||
}
|
||||
if (recipients.size === 0 && project.submittedByEmail) {
|
||||
recipients.set(project.submittedByEmail, null)
|
||||
}
|
||||
for (const [email, name] of recipients) {
|
||||
void sendAnnouncementEmail(
|
||||
email,
|
||||
name,
|
||||
`Your project has advanced to: ${targetRound.name}`,
|
||||
`Congratulations! Your project "${project.title}" has advanced from "${currentRound.name}" to "${targetRound.name}" in the Monaco Ocean Protection Challenge.`,
|
||||
'View Your Dashboard',
|
||||
`${process.env.NEXTAUTH_URL || 'https://monaco-opc.com'}/dashboard`,
|
||||
).catch((err) => {
|
||||
console.error(`[advanceProjects] notifyOnAdvance email failed for ${email}:`, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
console.error('[advanceProjects] notifyOnAdvance notification failed (non-fatal):', notifyErr)
|
||||
}
|
||||
|
||||
return {
|
||||
advancedCount: idsToAdvance.length,
|
||||
advancedCount: idsToAdvance!.length,
|
||||
autoPassedCount,
|
||||
targetRoundId: targetRound.id,
|
||||
targetRoundName: targetRound.name,
|
||||
|
||||
@@ -105,6 +105,7 @@ export const roundEngineRouter = router({
|
||||
input.newState,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
{ adminOverride: true },
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
@@ -133,6 +134,7 @@ export const roundEngineRouter = router({
|
||||
input.newState,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
{ adminOverride: true },
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -188,6 +190,14 @@ export const roundEngineRouter = router({
|
||||
|
||||
const roundIds = roundsToRemoveFrom.map((r) => r.id)
|
||||
|
||||
// Delete Assignment records first (Prisma cascade handles Evaluations)
|
||||
await ctx.prisma.assignment.deleteMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
roundId: { in: roundIds },
|
||||
},
|
||||
})
|
||||
|
||||
// Delete ProjectRoundState entries for this project in all affected rounds
|
||||
const deleted = await ctx.prisma.projectRoundState.deleteMany({
|
||||
where: {
|
||||
@@ -238,6 +248,14 @@ export const roundEngineRouter = router({
|
||||
|
||||
const roundIds = roundsToRemoveFrom.map((r) => r.id)
|
||||
|
||||
// Delete Assignment records first (Prisma cascade handles Evaluations)
|
||||
await ctx.prisma.assignment.deleteMany({
|
||||
where: {
|
||||
projectId: { in: input.projectIds },
|
||||
roundId: { in: roundIds },
|
||||
},
|
||||
})
|
||||
|
||||
const deleted = await ctx.prisma.projectRoundState.deleteMany({
|
||||
where: {
|
||||
projectId: { in: input.projectIds },
|
||||
|
||||
@@ -177,8 +177,9 @@ export async function cancelIntent(
|
||||
export async function expireIntentsForRound(
|
||||
roundId: string,
|
||||
actorId?: string,
|
||||
txClient?: Prisma.TransactionClient,
|
||||
): Promise<{ expired: number }> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const run = async (tx: Prisma.TransactionClient) => {
|
||||
const pending = await tx.assignmentIntent.findMany({
|
||||
where: { roundId, status: 'INTENT_PENDING' },
|
||||
})
|
||||
@@ -208,7 +209,13 @@ export async function expireIntentsForRound(
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -235,7 +235,7 @@ async function sendRemindersForRound(
|
||||
}
|
||||
|
||||
// Select email template type based on reminder type
|
||||
const emailTemplateType = type === '1H' ? 'REMINDER_1H' : 'REMINDER_24H'
|
||||
const emailTemplateType = type === '1H' ? 'REMINDER_1H' : type === '3_DAYS' ? 'REMINDER_3_DAYS' : 'REMINDER_24H'
|
||||
|
||||
for (const user of users) {
|
||||
const pendingCount = pendingCounts.get(user.id) || 0
|
||||
|
||||
@@ -268,9 +268,15 @@ export async function createBulkNotifications(params: {
|
||||
})),
|
||||
})
|
||||
|
||||
// Check email settings and send emails
|
||||
for (const userId of userIds) {
|
||||
await maybeSendEmail(userId, type, title, message, linkUrl, metadata)
|
||||
// Check email settings once, then send emails only if enabled
|
||||
const emailSetting = await prisma.notificationEmailSetting.findUnique({
|
||||
where: { notificationType: type },
|
||||
})
|
||||
|
||||
if (emailSetting?.sendEmail) {
|
||||
for (const userId of userIds) {
|
||||
await maybeSendEmailWithSetting(userId, type, title, message, emailSetting, linkUrl, metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,19 +396,36 @@ async function maybeSendEmail(
|
||||
return
|
||||
}
|
||||
|
||||
await maybeSendEmailWithSetting(userId, type, title, message, emailSetting, linkUrl, metadata)
|
||||
} catch (error) {
|
||||
// Log but don't fail the notification creation
|
||||
console.error('[Notification] Failed to send email:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email to a user using a pre-fetched email setting (skips the setting lookup)
|
||||
*/
|
||||
async function maybeSendEmailWithSetting(
|
||||
userId: string,
|
||||
type: string,
|
||||
title: string,
|
||||
message: string,
|
||||
emailSetting: { sendEmail: boolean; emailSubject: string | null },
|
||||
linkUrl?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check user's notification preference
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, name: true, notificationPreference: true },
|
||||
})
|
||||
|
||||
if (!user || user.notificationPreference === 'NONE') {
|
||||
if (!user || (user.notificationPreference !== 'EMAIL' && user.notificationPreference !== 'BOTH')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send styled email with full context
|
||||
// The styled template will use metadata for rich content
|
||||
// Subject can be overridden by admin settings
|
||||
await sendStyledNotificationEmail(
|
||||
user.email,
|
||||
user.name || 'User',
|
||||
@@ -416,7 +439,6 @@ async function maybeSendEmail(
|
||||
emailSetting.emailSubject || undefined
|
||||
)
|
||||
} catch (error) {
|
||||
// Log but don't fail the notification creation
|
||||
console.error('[Notification] Failed to send email:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,15 @@ const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
|
||||
ROUND_ARCHIVED: [],
|
||||
}
|
||||
|
||||
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
||||
PENDING: ['IN_PROGRESS', 'PASSED', 'REJECTED', 'WITHDRAWN'],
|
||||
IN_PROGRESS: ['PASSED', 'REJECTED', 'WITHDRAWN'],
|
||||
PASSED: ['COMPLETED', 'WITHDRAWN'],
|
||||
REJECTED: ['PENDING'], // re-include
|
||||
COMPLETED: [], // terminal
|
||||
WITHDRAWN: ['PENDING'], // re-include
|
||||
}
|
||||
|
||||
// ─── Round-Level Transitions ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -232,8 +241,8 @@ export async function closeRound(
|
||||
data: { status: 'ROUND_CLOSED' },
|
||||
})
|
||||
|
||||
// Expire pending intents
|
||||
await expireIntentsForRound(roundId, actorId)
|
||||
// Expire pending intents (using the transaction client)
|
||||
await expireIntentsForRound(roundId, actorId, tx)
|
||||
|
||||
// Auto-close any preceding active rounds (lower sortOrder, same competition)
|
||||
const precedingActiveRounds = await tx.round.findMany({
|
||||
@@ -540,6 +549,7 @@ export async function transitionProject(
|
||||
newState: ProjectRoundStateValue,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any,
|
||||
options?: { adminOverride?: boolean },
|
||||
): Promise<ProjectRoundTransitionResult> {
|
||||
try {
|
||||
const round = await prisma.round.findUnique({ where: { id: roundId } })
|
||||
@@ -569,6 +579,17 @@ export async function transitionProject(
|
||||
where: { projectId_roundId: { projectId, roundId } },
|
||||
})
|
||||
|
||||
// Enforce project state transition whitelist (unless admin override)
|
||||
if (existing && !options?.adminOverride) {
|
||||
const currentState = existing.state as string
|
||||
const allowed = VALID_PROJECT_TRANSITIONS[currentState] ?? []
|
||||
if (!allowed.includes(newState)) {
|
||||
throw new Error(
|
||||
`Invalid project transition: ${currentState} → ${newState}. Allowed: ${allowed.join(', ') || 'none (terminal state)'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let prs
|
||||
if (existing) {
|
||||
prs = await tx.projectRoundState.update({
|
||||
@@ -649,6 +670,7 @@ export async function batchTransitionProjects(
|
||||
newState: ProjectRoundStateValue,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any,
|
||||
options?: { adminOverride?: boolean },
|
||||
): Promise<BatchProjectTransitionResult> {
|
||||
const succeeded: string[] = []
|
||||
const failed: Array<{ projectId: string; errors: string[] }> = []
|
||||
@@ -657,7 +679,7 @@ export async function batchTransitionProjects(
|
||||
const batch = projectIds.slice(i, i + BATCH_SIZE)
|
||||
|
||||
const batchPromises = batch.map(async (projectId) => {
|
||||
const result = await transitionProject(projectId, roundId, newState, actorId, prisma)
|
||||
const result = await transitionProject(projectId, roundId, newState, actorId, prisma, options)
|
||||
|
||||
if (result.success) {
|
||||
succeeded.push(projectId)
|
||||
@@ -725,35 +747,74 @@ export async function checkRequirementsAndTransition(
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<{ transitioned: boolean; newState?: string }> {
|
||||
try {
|
||||
// Get all required FileRequirements for this round
|
||||
// Get all required FileRequirements for this round (legacy model)
|
||||
const requirements = await prisma.fileRequirement.findMany({
|
||||
where: { roundId, isRequired: true },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
// If the round has no file requirements, nothing to check
|
||||
if (requirements.length === 0) {
|
||||
// Also check SubmissionFileRequirement via the round's submissionWindow
|
||||
const round = await prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
select: { submissionWindowId: true },
|
||||
})
|
||||
|
||||
let submissionRequirements: Array<{ id: string }> = []
|
||||
if (round?.submissionWindowId) {
|
||||
submissionRequirements = await prisma.submissionFileRequirement.findMany({
|
||||
where: { submissionWindowId: round.submissionWindowId, required: true },
|
||||
select: { id: true },
|
||||
})
|
||||
}
|
||||
|
||||
// If the round has no file requirements at all, nothing to check
|
||||
if (requirements.length === 0 && submissionRequirements.length === 0) {
|
||||
return { transitioned: false }
|
||||
}
|
||||
|
||||
// Check which requirements this project has satisfied (has a file uploaded)
|
||||
const fulfilledFiles = await prisma.projectFile.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
roundId,
|
||||
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
|
||||
},
|
||||
select: { requirementId: true },
|
||||
})
|
||||
// Check which legacy requirements this project has satisfied
|
||||
let legacyAllMet = true
|
||||
if (requirements.length > 0) {
|
||||
const fulfilledFiles = await prisma.projectFile.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
roundId,
|
||||
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
|
||||
},
|
||||
select: { requirementId: true },
|
||||
})
|
||||
|
||||
const fulfilledIds = new Set(
|
||||
fulfilledFiles
|
||||
.map((f: { requirementId: string | null }) => f.requirementId)
|
||||
.filter(Boolean)
|
||||
)
|
||||
const fulfilledIds = new Set(
|
||||
fulfilledFiles
|
||||
.map((f: { requirementId: string | null }) => f.requirementId)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
// Check if all required requirements are met
|
||||
const allMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
|
||||
legacyAllMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
|
||||
}
|
||||
|
||||
// Check which SubmissionFileRequirements this project has satisfied
|
||||
let submissionAllMet = true
|
||||
if (submissionRequirements.length > 0) {
|
||||
const fulfilledSubmissionFiles = await prisma.projectFile.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
submissionFileRequirementId: { in: submissionRequirements.map((r: { id: string }) => r.id) },
|
||||
},
|
||||
select: { submissionFileRequirementId: true },
|
||||
})
|
||||
|
||||
const fulfilledSubIds = new Set(
|
||||
fulfilledSubmissionFiles
|
||||
.map((f: { submissionFileRequirementId: string | null }) => f.submissionFileRequirementId)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
submissionAllMet = submissionRequirements.every((r: { id: string }) => fulfilledSubIds.has(r.id))
|
||||
}
|
||||
|
||||
// All requirements from both models must be met
|
||||
const allMet = legacyAllMet && submissionAllMet
|
||||
|
||||
if (!allMet) {
|
||||
return { transitioned: false }
|
||||
|
||||
Reference in New Issue
Block a user