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

## 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:
Matt
2026-02-19 12:59:35 +01:00
parent ee8b12e59c
commit baca483fcb
12 changed files with 1814 additions and 609 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,9 @@ import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
@@ -85,6 +88,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
const [quickAddOpen, setQuickAddOpen] = useState(false)
const [addProjectOpen, setAddProjectOpen] = useState(false)
const utils = trpc.useUtils()
@@ -274,16 +278,10 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => { setQuickAddOpen(true) }}>
<Button size="sm" variant="outline" onClick={() => { setAddProjectOpen(true) }}>
<Plus className="h-4 w-4 mr-1.5" />
Quick Add
Add Project
</Button>
<Link href={poolLink}>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1.5" />
Add from Pool
</Button>
</Link>
</div>
</div>
@@ -436,7 +434,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
)}
</div>
{/* Quick Add Dialog */}
{/* Quick Add Dialog (legacy, kept for empty state) */}
<QuickAddDialog
open={quickAddOpen}
onOpenChange={setQuickAddOpen}
@@ -447,6 +445,17 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
}}
/>
{/* Add Project Dialog (Create New + From Pool) */}
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
onAssigned={() => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
}}
/>
{/* Single Remove Confirmation */}
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
<AlertDialogContent>
@@ -673,3 +682,287 @@ function QuickAddDialog({
</Dialog>
)
}
/**
* Add Project Dialog — two tabs: "Create New" and "From Pool".
* Create New: form to create a project and assign it directly to the round.
* From Pool: search existing projects not yet in this round and assign them.
*/
function AddProjectDialog({
open,
onOpenChange,
roundId,
competitionId,
onAssigned,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
competitionId: string
onAssigned: () => void
}) {
const [activeTab, setActiveTab] = useState<'create' | 'pool'>('create')
// ── Create New tab state ──
const [title, setTitle] = useState('')
const [teamName, setTeamName] = useState('')
const [description, setDescription] = useState('')
const [country, setCountry] = useState('')
const [category, setCategory] = useState<string>('')
// ── From Pool tab state ──
const [poolSearch, setPoolSearch] = useState('')
const [selectedPoolIds, setSelectedPoolIds] = useState<Set<string>>(new Set())
const utils = trpc.useUtils()
// Get the competition to find programId (for pool search)
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId },
{ enabled: open && !!competitionId },
)
const programId = (competition as any)?.programId || ''
// Pool query
const { data: poolResults, isLoading: poolLoading } = trpc.projectPool.listUnassigned.useQuery(
{
programId,
excludeRoundId: roundId,
search: poolSearch.trim() || undefined,
perPage: 50,
},
{ enabled: open && activeTab === 'pool' && !!programId },
)
// Create mutation
const createMutation = trpc.project.createAndAssignToRound.useMutation({
onSuccess: () => {
toast.success('Project created and added to round')
utils.roundEngine.getProjectStates.invalidate({ roundId })
onAssigned()
resetAndClose()
},
onError: (err) => toast.error(err.message),
})
// Assign from pool mutation
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (data) => {
toast.success(`${data.assignedCount} project(s) added to round`)
utils.roundEngine.getProjectStates.invalidate({ roundId })
onAssigned()
resetAndClose()
},
onError: (err) => toast.error(err.message),
})
const resetAndClose = () => {
setTitle('')
setTeamName('')
setDescription('')
setCountry('')
setCategory('')
setPoolSearch('')
setSelectedPoolIds(new Set())
onOpenChange(false)
}
const handleCreate = () => {
if (!title.trim()) return
createMutation.mutate({
title: title.trim(),
teamName: teamName.trim() || undefined,
description: description.trim() || undefined,
country: country.trim() || undefined,
competitionCategory: category === 'STARTUP' || category === 'BUSINESS_CONCEPT' ? category : undefined,
roundId,
})
}
const handleAssignFromPool = () => {
if (selectedPoolIds.size === 0) return
assignMutation.mutate({
projectIds: Array.from(selectedPoolIds),
roundId,
})
}
const togglePoolProject = (id: string) => {
setSelectedPoolIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const isMutating = createMutation.isPending || assignMutation.isPending
return (
<Dialog open={open} onOpenChange={(isOpen) => {
if (!isOpen) resetAndClose()
else onOpenChange(true)
}}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add Project to Round</DialogTitle>
<DialogDescription>
Create a new project or select existing ones to add to this round.
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'create' | 'pool')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="create">Create New</TabsTrigger>
<TabsTrigger value="pool">From Pool</TabsTrigger>
</TabsList>
{/* ── Create New Tab ── */}
<TabsContent value="create" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="add-project-title">Title *</Label>
<Input
id="add-project-title"
placeholder="Project title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="add-project-team">Team Name</Label>
<Input
id="add-project-team"
placeholder="Team or organization name"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="add-project-country">Country</Label>
<Input
id="add-project-country"
placeholder="e.g. France"
value={country}
onChange={(e) => setCountry(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="add-project-desc">Description</Label>
<Input
id="add-project-desc"
placeholder="Brief description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
<Button
onClick={handleCreate}
disabled={!title.trim() || isMutating}
>
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Create & Add to Round
</Button>
</DialogFooter>
</TabsContent>
{/* ── From Pool Tab ── */}
<TabsContent value="pool" className="space-y-4 mt-4">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search by project title or team..."
value={poolSearch}
onChange={(e) => setPoolSearch(e.target.value)}
className="pl-8"
/>
</div>
<ScrollArea className="h-[320px] rounded-md border">
<div className="p-2 space-y-0.5">
{poolLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!poolLoading && poolResults?.projects.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{poolSearch.trim() ? `No projects found matching "${poolSearch}"` : 'No projects available to add'}
</p>
)}
{poolResults?.projects.map((project: any) => {
const isSelected = selectedPoolIds.has(project.id)
return (
<label
key={project.id}
className={`flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors ${
isSelected ? 'bg-accent' : 'hover:bg-muted/50'
}`}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => togglePoolProject(project.id)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
{project.country && <> &middot; {project.country}</>}
</p>
</div>
{project.competitionCategory && (
<Badge variant="outline" className="text-[10px] ml-2 shrink-0">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
</Badge>
)}
</div>
</label>
)
})}
</div>
</ScrollArea>
{poolResults && poolResults.total > 50 && (
<p className="text-xs text-muted-foreground text-center">
Showing 50 of {poolResults.total} &mdash; refine your search for more specific results
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
<Button
onClick={handleAssignFromPool}
disabled={selectedPoolIds.size === 0 || isMutating}
>
{assignMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
{selectedPoolIds.size <= 1
? 'Add to Round'
: `Add ${selectedPoolIds.size} Projects to Round`
}
</Button>
</DialogFooter>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)
}

View File

@@ -1083,6 +1083,60 @@ Together for a healthier ocean.
}
}
/**
* Generate "3 Days Remaining" email template (for jury)
*/
function getReminder3DaysTemplate(
name: string,
pendingCount: number,
roundName: string,
deadline: string,
assignmentsUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const urgentBox = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="color: #92400e; margin: 0; font-size: 14px; font-weight: 600;">&#9888; 3 Days Remaining</p>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${urgentBox}
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 3 days.`)}
${statCard('Pending Evaluations', pendingCount)}
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
${paragraph('Please plan to complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
`
return {
subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 3 days`,
html: getEmailWrapper(content),
text: `
${greeting}
This is a reminder that ${roundName} closes in 3 days.
You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''}.
Deadline: ${deadline}
Please plan to complete your remaining evaluations before the deadline.
${assignmentsUrl ? `Complete evaluations: ${assignmentsUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "1 Hour Reminder" email template (for jury)
*/
@@ -1457,6 +1511,14 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
ctx.metadata?.deadline as string | undefined,
ctx.linkUrl
),
REMINDER_3_DAYS: (ctx) =>
getReminder3DaysTemplate(
ctx.name || '',
(ctx.metadata?.pendingCount as number) || 0,
(ctx.metadata?.roundName as string) || 'this round',
(ctx.metadata?.deadline as string) || 'Soon',
ctx.linkUrl
),
REMINDER_24H: (ctx) =>
getReminder24HTemplate(
ctx.name || '',

View File

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

View File

@@ -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',

View File

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

View File

@@ -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,

View File

@@ -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 },

View File

@@ -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)
}
// ============================================================================

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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 }