Simplify routing to award assignment, seed all CSV entries, fix category mapping
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m3s

- Remove RoutingRule model and routing engine (replaced by direct award assignment)
- Simplify RoutingMode enum: PARALLEL/POST_MAIN → SHARED, keep EXCLUSIVE
- Remove routing router, routing-rules-editor, and related tests
- Update pipeline, award, and notification code to remove routing references
- Seed: include all CSV entries (no filtering/dedup), AI screening handles duplicates
- Seed: fix non-breaking space (U+00A0) bug in category/issue mapping
- Stage filtering: add duplicate detection that flags projects for admin review

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 14:25:05 +01:00
parent 382570cebd
commit 9ab4717f96
23 changed files with 249 additions and 2449 deletions

View File

@@ -124,26 +124,7 @@ async function runChecks(): Promise<CheckResult[]> {
: `${emptyPipelineCount} empty pipelines, ${emptyTrackCount} empty tracks`,
})
// 8. RoutingRule destinations reference valid tracks in same pipeline
const badRoutingRules = await prisma.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count FROM "RoutingRule" rr
WHERE rr."destinationTrackId" IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM "Track" t
WHERE t.id = rr."destinationTrackId"
AND t."pipelineId" = rr."pipelineId"
)
`
const badRouteCount = Number(badRoutingRules[0]?.count ?? 0)
results.push({
name: 'RoutingRule destinations reference valid tracks in same pipeline',
passed: badRouteCount === 0,
details: badRouteCount === 0
? 'All routing rules reference valid destination tracks'
: `Found ${badRouteCount} routing rules with invalid destinations`,
})
// 9. LiveProgressCursor references valid stage
// 8. LiveProgressCursor references valid stage
const badCursors = await prisma.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count FROM "LiveProgressCursor" lpc
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = lpc."stageId")

View File

@@ -0,0 +1,28 @@
-- Simplify RoutingMode enum: remove POST_MAIN, rename PARALLEL → SHARED
-- Drop RoutingRule table (routing is now handled via award assignment)
-- 1. Update existing PARALLEL values to SHARED, POST_MAIN to SHARED
UPDATE "Track" SET "routingMode" = 'PARALLEL' WHERE "routingMode" = 'POST_MAIN';
-- 2. Rename PARALLEL → SHARED in the enum
ALTER TYPE "RoutingMode" RENAME VALUE 'PARALLEL' TO 'SHARED';
-- 3. Remove POST_MAIN from the enum
-- PostgreSQL doesn't support DROP VALUE directly, so we recreate the enum
-- Since we already converted POST_MAIN values to PARALLEL (now SHARED), this is safe
-- Create new enum without POST_MAIN
-- Actually, since we already renamed PARALLEL to SHARED and converted POST_MAIN rows,
-- we just need to remove the POST_MAIN value. PostgreSQL 13+ doesn't support dropping
-- enum values natively, but since all rows are already migrated, we can:
CREATE TYPE "RoutingMode_new" AS ENUM ('SHARED', 'EXCLUSIVE');
ALTER TABLE "Track"
ALTER COLUMN "routingMode" TYPE "RoutingMode_new"
USING ("routingMode"::text::"RoutingMode_new");
DROP TYPE "RoutingMode";
ALTER TYPE "RoutingMode_new" RENAME TO "RoutingMode";
-- 4. Drop the RoutingRule table (no longer needed)
DROP TABLE IF EXISTS "RoutingRule";

View File

@@ -163,9 +163,8 @@ enum TrackKind {
}
enum RoutingMode {
PARALLEL
SHARED
EXCLUSIVE
POST_MAIN
}
enum StageStatus {
@@ -1846,7 +1845,6 @@ model Pipeline {
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
tracks Track[]
routingRules RoutingRule[]
@@index([programId])
@@index([status])
@@ -1871,8 +1869,6 @@ model Track {
stages Stage[]
projectStageStates ProjectStageState[]
specialAward SpecialAward?
routingRulesAsSource RoutingRule[] @relation("RoutingSourceTrack")
routingRulesAsDestination RoutingRule[] @relation("RoutingDestinationTrack")
@@unique([pipelineId, slug])
@@unique([pipelineId, sortOrder])
@@ -1969,30 +1965,6 @@ model ProjectStageState {
@@index([projectId, trackId])
}
model RoutingRule {
id String @id @default(cuid())
pipelineId String
name String
scope String @default("global") // global, track, stage
sourceTrackId String?
destinationTrackId String
destinationStageId String?
predicateJson Json @db.JsonB // { field, operator, value } or compound
priority Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
sourceTrack Track? @relation("RoutingSourceTrack", fields: [sourceTrackId], references: [id], onDelete: SetNull)
destinationTrack Track @relation("RoutingDestinationTrack", fields: [destinationTrackId], references: [id], onDelete: Cascade)
@@index([pipelineId])
@@index([priority])
@@index([isActive])
}
model Cohort {
id String @id @default(cuid())

View File

@@ -50,9 +50,14 @@ const issueMap: Record<string, OceanIssue> = {
'Other': OceanIssue.OTHER,
}
function normalizeSpaces(s: string): string {
// Replace non-breaking spaces (U+00A0) and other whitespace variants with regular spaces
return s.replace(/\u00A0/g, ' ')
}
function mapCategory(raw: string | undefined): CompetitionCategory | null {
if (!raw) return null
const trimmed = raw.trim()
const trimmed = normalizeSpaces(raw.trim())
for (const [prefix, value] of Object.entries(categoryMap)) {
if (trimmed.startsWith(prefix)) return value
}
@@ -61,7 +66,7 @@ function mapCategory(raw: string | undefined): CompetitionCategory | null {
function mapIssue(raw: string | undefined): OceanIssue | null {
if (!raw) return null
const trimmed = raw.trim()
const trimmed = normalizeSpaces(raw.trim())
for (const [prefix, value] of Object.entries(issueMap)) {
if (trimmed.startsWith(prefix)) return value
}
@@ -76,17 +81,11 @@ function parseFoundedDate(raw: string | undefined): Date | null {
return isNaN(d.getTime()) ? null : d
}
function isValidEntry(row: Record<string, string>): boolean {
const status = (row['Application status'] || '').trim().toLowerCase()
if (status === 'ignore' || status === 'doublon') return false
function isEmptyRow(row: Record<string, string>): boolean {
const name = (row['Full name'] || '').trim()
if (name.length <= 2) return false // skip test entries
const email = (row['E-mail'] || '').trim()
if (!email || !email.includes('@')) return false
return true
const project = (row["Project's name"] || '').trim()
return !name && !email && !project
}
// =============================================================================
@@ -476,7 +475,7 @@ async function main() {
name: 'Ocean Innovation Award',
slug: 'innovation-award',
kind: TrackKind.AWARD,
routingMode: RoutingMode.PARALLEL,
routingMode: RoutingMode.SHARED,
decisionMode: DecisionMode.JURY_VOTE,
sortOrder: 1,
settingsJson: { description: 'Award for most innovative ocean technology' },
@@ -506,16 +505,16 @@ async function main() {
name: "People's Choice",
slug: 'peoples-choice',
kind: TrackKind.SHOWCASE,
routingMode: RoutingMode.POST_MAIN,
routingMode: RoutingMode.SHARED,
sortOrder: 3,
settingsJson: { description: 'Public audience voting for fan favorite' },
},
})
console.log(` ✓ Main Competition (MAIN)`)
console.log(` ✓ Ocean Innovation Award (AWARD, PARALLEL)`)
console.log(` ✓ Ocean Innovation Award (AWARD, SHARED)`)
console.log(` ✓ Ocean Impact Award (AWARD, EXCLUSIVE)`)
console.log(` ✓ People's Choice (SHOWCASE, POST_MAIN)`)
console.log(` ✓ People's Choice (SHOWCASE, SHARED)`)
// ==========================================================================
// 9. Stages
@@ -814,21 +813,9 @@ async function main() {
console.log(` Raw CSV rows: ${records.length}`)
// Filter and deduplicate
const seenEmails = new Set<string>()
const validRecords: Record<string, string>[] = []
for (const row of records) {
if (!isValidEntry(row)) continue
const email = (row['E-mail'] || '').trim().toLowerCase()
if (seenEmails.has(email)) continue
seenEmails.add(email)
validRecords.push(row)
}
console.log(` Valid entries after filtering: ${validRecords.length}`)
// Skip only completely empty rows (no name, no email, no project)
const validRecords = records.filter((row: Record<string, string>) => !isEmptyRow(row))
console.log(` Entries to seed: ${validRecords.length}`)
// Create applicant users and projects
console.log('\n🚀 Creating applicant users and projects...')
@@ -836,7 +823,9 @@ async function main() {
const intakeStage = mainStages[0] // INTAKE - CLOSED
const filterStage = mainStages[1] // FILTER - ACTIVE
for (const row of validRecords) {
let skippedNoEmail = 0
for (let rowIdx = 0; rowIdx < validRecords.length; rowIdx++) {
const row = validRecords[rowIdx]
const email = (row['E-mail'] || '').trim().toLowerCase()
const name = (row['Full name'] || '').trim()
const phone = (row['Téléphone'] || '').trim() || null
@@ -855,7 +844,14 @@ async function main() {
const phase2Url = (row['PHASE 2 - Submission'] || '').trim() || null
const foundedAt = parseFoundedDate(row['Date of creation'])
// Create or get applicant user
// Skip rows with no usable email (can't create user without one)
if (!email || !email.includes('@')) {
skippedNoEmail++
console.log(` ⚠ Row ${rowIdx + 2}: skipped (no valid email)`)
continue
}
// Create or get applicant user (upsert handles duplicate emails)
const user = await prisma.user.upsert({
where: { email },
update: {
@@ -864,7 +860,7 @@ async function main() {
},
create: {
email,
name,
name: name || `Applicant ${rowIdx + 1}`,
role: UserRole.APPLICANT,
status: UserStatus.NONE,
phoneNumber: phone,
@@ -930,6 +926,9 @@ async function main() {
}
console.log(` ✓ Created ${projectCount} projects with stage states`)
if (skippedNoEmail > 0) {
console.log(` ⚠ Skipped ${skippedNoEmail} rows with no valid email`)
}
}
// ==========================================================================
@@ -998,58 +997,7 @@ async function main() {
console.log(' ✓ Ocean Impact Award → impact-award track')
// ==========================================================================
// 14. Routing Rules
// ==========================================================================
console.log('\n🔀 Creating routing rules...')
const existingTechRule = await prisma.routingRule.findFirst({
where: { pipelineId: pipeline.id, name: 'Route Tech Innovation to Innovation Award' },
})
if (!existingTechRule) {
await prisma.routingRule.create({
data: {
pipelineId: pipeline.id,
name: 'Route Tech Innovation to Innovation Award',
scope: 'global',
destinationTrackId: innovationTrack.id,
predicateJson: {
field: 'oceanIssue',
operator: 'eq',
value: 'TECHNOLOGY_INNOVATION',
},
priority: 10,
isActive: true,
},
})
}
const existingImpactRule = await prisma.routingRule.findFirst({
where: { pipelineId: pipeline.id, name: 'Route Community Impact to Impact Award' },
})
if (!existingImpactRule) {
await prisma.routingRule.create({
data: {
pipelineId: pipeline.id,
name: 'Route Community Impact to Impact Award',
scope: 'global',
destinationTrackId: impactTrack.id,
predicateJson: {
or: [
{ field: 'oceanIssue', operator: 'eq', value: 'COMMUNITY_CAPACITY' },
{ field: 'oceanIssue', operator: 'eq', value: 'HABITAT_RESTORATION' },
],
},
priority: 5,
isActive: true,
},
})
}
console.log(' ✓ Tech Innovation → Innovation Award (PARALLEL)')
console.log(' ✓ Community Impact → Impact Award (EXCLUSIVE)')
// ==========================================================================
// 15. Notification Email Settings
// 14. Notification Email Settings
// ==========================================================================
console.log('\n🔔 Creating notification email settings...')