Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,36 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function check() {
|
||||
const projectCount = await prisma.project.count()
|
||||
console.log('Total projects:', projectCount)
|
||||
|
||||
const rounds = await prisma.round.findMany({
|
||||
include: {
|
||||
_count: { select: { projects: true } }
|
||||
}
|
||||
})
|
||||
|
||||
for (const r of rounds) {
|
||||
console.log(`Round: ${r.name} (id: ${r.id})`)
|
||||
console.log(` Projects: ${r._count.projects}`)
|
||||
}
|
||||
|
||||
// Check sample projects with their round
|
||||
const sampleProjects = await prisma.project.findMany({
|
||||
select: { id: true, title: true, roundId: true },
|
||||
take: 5
|
||||
})
|
||||
console.log('\nSample projects:')
|
||||
for (const p of sampleProjects) {
|
||||
console.log(` ${p.title}: roundId=${p.roundId}`)
|
||||
}
|
||||
}
|
||||
|
||||
check()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (e) => {
|
||||
console.error(e)
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function cleanup() {
|
||||
console.log('Checking all rounds...\n')
|
||||
|
||||
const rounds = await prisma.round.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
projects: { select: { id: true, title: true } },
|
||||
_count: { select: { projects: true } }
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Found ${rounds.length} rounds:`)
|
||||
for (const round of rounds) {
|
||||
console.log(`- ${round.name} (slug: ${round.slug}): ${round._count.projects} projects`)
|
||||
}
|
||||
|
||||
// Find rounds with 9 or fewer projects (dummy data)
|
||||
const dummyRounds = rounds.filter(r => r._count.projects <= 9)
|
||||
|
||||
if (dummyRounds.length > 0) {
|
||||
console.log(`\nDeleting ${dummyRounds.length} dummy round(s)...`)
|
||||
|
||||
for (const round of dummyRounds) {
|
||||
console.log(`\nProcessing: ${round.name}`)
|
||||
|
||||
const projectIds = round.projects.map(p => p.id)
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
// Delete team members
|
||||
const teamDeleted = await prisma.teamMember.deleteMany({
|
||||
where: { projectId: { in: projectIds } }
|
||||
})
|
||||
console.log(` Deleted ${teamDeleted.count} team members`)
|
||||
|
||||
// Delete projects
|
||||
const projDeleted = await prisma.project.deleteMany({
|
||||
where: { id: { in: projectIds } }
|
||||
})
|
||||
console.log(` Deleted ${projDeleted.count} projects`)
|
||||
}
|
||||
|
||||
// Delete the round
|
||||
await prisma.round.delete({ where: { id: round.id } })
|
||||
console.log(` Deleted round: ${round.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
const remaining = await prisma.round.count()
|
||||
const projects = await prisma.project.count()
|
||||
console.log(`\n✅ Cleanup complete!`)
|
||||
console.log(` Remaining rounds: ${remaining}`)
|
||||
console.log(` Total projects: ${projects}`)
|
||||
}
|
||||
|
||||
cleanup()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (e) => {
|
||||
console.error(e)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function cleanup() {
|
||||
console.log('Cleaning up dummy data...\n')
|
||||
|
||||
// Find and delete the dummy round
|
||||
const dummyRound = await prisma.round.findFirst({
|
||||
where: { slug: 'round-1-2026' },
|
||||
include: { projects: true }
|
||||
})
|
||||
|
||||
if (dummyRound) {
|
||||
console.log(`Found dummy round: ${dummyRound.name}`)
|
||||
console.log(`Projects in round: ${dummyRound.projects.length}`)
|
||||
|
||||
// Get project IDs to delete
|
||||
const projectIds = dummyRound.projects.map(p => p.id)
|
||||
|
||||
// Delete team members for these projects
|
||||
if (projectIds.length > 0) {
|
||||
const teamDeleted = await prisma.teamMember.deleteMany({
|
||||
where: { projectId: { in: projectIds } }
|
||||
})
|
||||
console.log(`Deleted ${teamDeleted.count} team members`)
|
||||
|
||||
// Delete the projects
|
||||
const projDeleted = await prisma.project.deleteMany({
|
||||
where: { id: { in: projectIds } }
|
||||
})
|
||||
console.log(`Deleted ${projDeleted.count} dummy projects`)
|
||||
}
|
||||
|
||||
// Delete the round
|
||||
await prisma.round.delete({ where: { id: dummyRound.id } })
|
||||
console.log('Deleted dummy round')
|
||||
} else {
|
||||
console.log('No dummy round found')
|
||||
}
|
||||
|
||||
console.log('\nCleanup complete!')
|
||||
}
|
||||
|
||||
cleanup()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (e) => {
|
||||
console.error(e)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
246
prisma/integrity-checks.ts
Normal file
246
prisma/integrity-checks.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
interface CheckResult {
|
||||
name: string
|
||||
passed: boolean
|
||||
details: string
|
||||
}
|
||||
|
||||
async function runChecks(): Promise<CheckResult[]> {
|
||||
const results: CheckResult[] = []
|
||||
|
||||
// 1. No orphan ProjectStageState (every PSS references valid project, track, stage)
|
||||
const orphanStates = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "ProjectStageState" pss
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Project" p WHERE p.id = pss."projectId")
|
||||
OR NOT EXISTS (SELECT 1 FROM "Track" t WHERE t.id = pss."trackId")
|
||||
OR NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = pss."stageId")
|
||||
`
|
||||
const orphanCount = Number(orphanStates[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'No orphan ProjectStageState',
|
||||
passed: orphanCount === 0,
|
||||
details: orphanCount === 0 ? 'All PSS records reference valid entities' : `Found ${orphanCount} orphan records`,
|
||||
})
|
||||
|
||||
// 2. Every project has at least one stage state
|
||||
const projectsWithoutState = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "Project" p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "ProjectStageState" pss WHERE pss."projectId" = p.id)
|
||||
`
|
||||
const noStateCount = Number(projectsWithoutState[0]?.count ?? 0)
|
||||
const totalProjects = await prisma.project.count()
|
||||
results.push({
|
||||
name: 'Every project has at least one stage state',
|
||||
passed: noStateCount === 0,
|
||||
details: noStateCount === 0
|
||||
? `All ${totalProjects} projects have stage states`
|
||||
: `${noStateCount} projects missing stage states`,
|
||||
})
|
||||
|
||||
// 3. No duplicate active states per (project, track, stage)
|
||||
const duplicateStates = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM (
|
||||
SELECT "projectId", "trackId", "stageId", COUNT(*) as cnt
|
||||
FROM "ProjectStageState"
|
||||
WHERE "exitedAt" IS NULL
|
||||
GROUP BY "projectId", "trackId", "stageId"
|
||||
HAVING COUNT(*) > 1
|
||||
) dupes
|
||||
`
|
||||
const dupeCount = Number(duplicateStates[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'No duplicate active states per (project, track, stage)',
|
||||
passed: dupeCount === 0,
|
||||
details: dupeCount === 0 ? 'No duplicates found' : `Found ${dupeCount} duplicate active states`,
|
||||
})
|
||||
|
||||
// 4. All transitions stay within same pipeline
|
||||
const crossPipelineTransitions = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "StageTransition" st
|
||||
JOIN "Stage" sf ON sf.id = st."fromStageId"
|
||||
JOIN "Track" tf ON tf.id = sf."trackId"
|
||||
JOIN "Stage" sto ON sto.id = st."toStageId"
|
||||
JOIN "Track" tt ON tt.id = sto."trackId"
|
||||
WHERE tf."pipelineId" != tt."pipelineId"
|
||||
`
|
||||
const crossCount = Number(crossPipelineTransitions[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'All transitions stay within same pipeline',
|
||||
passed: crossCount === 0,
|
||||
details: crossCount === 0 ? 'All transitions are within pipeline' : `Found ${crossCount} cross-pipeline transitions`,
|
||||
})
|
||||
|
||||
// 5. Stage sortOrder unique per track
|
||||
const duplicateSortOrders = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM (
|
||||
SELECT "trackId", "sortOrder", COUNT(*) as cnt
|
||||
FROM "Stage"
|
||||
GROUP BY "trackId", "sortOrder"
|
||||
HAVING COUNT(*) > 1
|
||||
) dupes
|
||||
`
|
||||
const dupeSortCount = Number(duplicateSortOrders[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Stage sortOrder unique per track',
|
||||
passed: dupeSortCount === 0,
|
||||
details: dupeSortCount === 0 ? 'All sort orders unique' : `Found ${dupeSortCount} duplicate sort orders`,
|
||||
})
|
||||
|
||||
// 6. Track sortOrder unique per pipeline
|
||||
const duplicateTrackOrders = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM (
|
||||
SELECT "pipelineId", "sortOrder", COUNT(*) as cnt
|
||||
FROM "Track"
|
||||
GROUP BY "pipelineId", "sortOrder"
|
||||
HAVING COUNT(*) > 1
|
||||
) dupes
|
||||
`
|
||||
const dupeTrackCount = Number(duplicateTrackOrders[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Track sortOrder unique per pipeline',
|
||||
passed: dupeTrackCount === 0,
|
||||
details: dupeTrackCount === 0 ? 'All track orders unique' : `Found ${dupeTrackCount} duplicate track orders`,
|
||||
})
|
||||
|
||||
// 7. Every Pipeline has at least one Track; every Track has at least one Stage
|
||||
const emptyPipelines = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "Pipeline" p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Track" t WHERE t."pipelineId" = p.id)
|
||||
`
|
||||
const emptyPipelineCount = Number(emptyPipelines[0]?.count ?? 0)
|
||||
const emptyTracks = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "Track" t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s."trackId" = t.id)
|
||||
`
|
||||
const emptyTrackCount = Number(emptyTracks[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Every Pipeline has Tracks; every Track has Stages',
|
||||
passed: emptyPipelineCount === 0 && emptyTrackCount === 0,
|
||||
details: emptyPipelineCount === 0 && emptyTrackCount === 0
|
||||
? 'All pipelines have tracks and all tracks have stages'
|
||||
: `${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
|
||||
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")
|
||||
`
|
||||
const badCursorCount = Number(badCursors[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'LiveProgressCursor references valid stage',
|
||||
passed: badCursorCount === 0,
|
||||
details: badCursorCount === 0
|
||||
? 'All cursors reference valid stages'
|
||||
: `Found ${badCursorCount} cursors with invalid stage references`,
|
||||
})
|
||||
|
||||
// 10. Cohort references valid stage
|
||||
const badCohorts = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "Cohort" c
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = c."stageId")
|
||||
`
|
||||
const badCohortCount = Number(badCohorts[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Cohort references valid stage',
|
||||
passed: badCohortCount === 0,
|
||||
details: badCohortCount === 0
|
||||
? 'All cohorts reference valid stages'
|
||||
: `Found ${badCohortCount} cohorts with invalid stage references`,
|
||||
})
|
||||
|
||||
// 11. Every EvaluationForm has a valid stageId
|
||||
const badEvalForms = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "EvaluationForm" ef
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = ef."stageId")
|
||||
`
|
||||
const badFormCount = Number(badEvalForms[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Every EvaluationForm references valid stage',
|
||||
passed: badFormCount === 0,
|
||||
details: badFormCount === 0
|
||||
? 'All evaluation forms reference valid stages'
|
||||
: `Found ${badFormCount} forms with invalid stage references`,
|
||||
})
|
||||
|
||||
// 12. Every FileRequirement has a valid stageId
|
||||
const badFileReqs = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "FileRequirement" fr
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = fr."stageId")
|
||||
`
|
||||
const badFileReqCount = Number(badFileReqs[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Every FileRequirement references valid stage',
|
||||
passed: badFileReqCount === 0,
|
||||
details: badFileReqCount === 0
|
||||
? 'All file requirements reference valid stages'
|
||||
: `Found ${badFileReqCount} file requirements with invalid stage references`,
|
||||
})
|
||||
|
||||
// 13. Count validation
|
||||
const projectCountResult = await prisma.project.count()
|
||||
const stageCount = await prisma.stage.count()
|
||||
const trackCount = await prisma.track.count()
|
||||
const pipelineCount = await prisma.pipeline.count()
|
||||
const pssCount = await prisma.projectStageState.count()
|
||||
results.push({
|
||||
name: 'Count validation',
|
||||
passed: projectCountResult > 0 && stageCount > 0 && trackCount > 0,
|
||||
details: `Pipelines: ${pipelineCount}, Tracks: ${trackCount}, Stages: ${stageCount}, Projects: ${projectCountResult}, StageStates: ${pssCount}`,
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🔍 Running integrity checks...\n')
|
||||
|
||||
const results = await runChecks()
|
||||
|
||||
let allPassed = true
|
||||
for (const result of results) {
|
||||
const icon = result.passed ? '✅' : '❌'
|
||||
console.log(`${icon} ${result.name}`)
|
||||
console.log(` ${result.details}\n`)
|
||||
if (!result.passed) allPassed = false
|
||||
}
|
||||
|
||||
console.log('='.repeat(50))
|
||||
if (allPassed) {
|
||||
console.log('✅ All integrity checks passed!')
|
||||
} else {
|
||||
console.log('❌ Some integrity checks failed!')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Integrity check failed:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
1465
prisma/schema.prisma
1465
prisma/schema.prisma
File diff suppressed because it is too large
Load Diff
@@ -1,510 +0,0 @@
|
||||
import { PrismaClient, CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import Papa from 'papaparse'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
// CSV Column Mapping
|
||||
interface CandidatureRow {
|
||||
'Full name': string
|
||||
'Application status': string
|
||||
'Category': string
|
||||
'Comment ': string // Note the space after 'Comment'
|
||||
'Country': string
|
||||
'Date of creation': string
|
||||
'E-mail': string
|
||||
'How did you hear about MOPC?': string
|
||||
'Issue': string
|
||||
'Jury 1 attribués': string
|
||||
'MOPC team comments': string
|
||||
'Mentorship': string
|
||||
'PHASE 1 - Submission': string
|
||||
'PHASE 2 - Submission': string
|
||||
"Project's name": string
|
||||
'Team members': string
|
||||
'Tri par zone': string
|
||||
'Téléphone': string
|
||||
'University': string
|
||||
}
|
||||
|
||||
// Map CSV category strings to enum values
|
||||
function mapCategory(category: string): CompetitionCategory | null {
|
||||
if (!category) return null
|
||||
const lower = category.toLowerCase()
|
||||
if (lower.includes('start-up') || lower.includes('startup')) {
|
||||
return 'STARTUP'
|
||||
}
|
||||
if (lower.includes('business concept')) {
|
||||
return 'BUSINESS_CONCEPT'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Map CSV issue strings to enum values
|
||||
function mapOceanIssue(issue: string): OceanIssue | null {
|
||||
if (!issue) return null
|
||||
const lower = issue.toLowerCase()
|
||||
|
||||
if (lower.includes('pollution')) return 'POLLUTION_REDUCTION'
|
||||
if (lower.includes('climate') || lower.includes('sea-level')) return 'CLIMATE_MITIGATION'
|
||||
if (lower.includes('technology') || lower.includes('innovation')) return 'TECHNOLOGY_INNOVATION'
|
||||
if (lower.includes('shipping') || lower.includes('yachting')) return 'SUSTAINABLE_SHIPPING'
|
||||
if (lower.includes('blue carbon')) return 'BLUE_CARBON'
|
||||
if (lower.includes('habitat') || lower.includes('restoration') || lower.includes('ecosystem')) return 'HABITAT_RESTORATION'
|
||||
if (lower.includes('community') || lower.includes('capacity') || lower.includes('coastal')) return 'COMMUNITY_CAPACITY'
|
||||
if (lower.includes('fishing') || lower.includes('aquaculture') || lower.includes('blue food')) return 'SUSTAINABLE_FISHING'
|
||||
if (lower.includes('awareness') || lower.includes('education') || lower.includes('consumer')) return 'CONSUMER_AWARENESS'
|
||||
if (lower.includes('acidification')) return 'OCEAN_ACIDIFICATION'
|
||||
|
||||
return 'OTHER'
|
||||
}
|
||||
|
||||
// Parse team members string into array
|
||||
function parseTeamMembers(teamMembersStr: string): { name: string; email?: string }[] {
|
||||
if (!teamMembersStr) return []
|
||||
|
||||
// Split by comma or semicolon
|
||||
const members = teamMembersStr.split(/[,;]/).map((m) => m.trim()).filter(Boolean)
|
||||
|
||||
return members.map((name) => ({
|
||||
name: name.trim(),
|
||||
// No emails in CSV, just names with titles
|
||||
}))
|
||||
}
|
||||
|
||||
// Extract country code from location string or return ISO code directly
|
||||
function extractCountry(location: string): string | null {
|
||||
if (!location) return null
|
||||
|
||||
// If already a 2-letter ISO code, return it directly
|
||||
const trimmed = location.trim()
|
||||
if (/^[A-Z]{2}$/.test(trimmed)) return trimmed
|
||||
|
||||
// Common country mappings from the CSV data
|
||||
const countryMappings: Record<string, string> = {
|
||||
'tunisie': 'TN',
|
||||
'tunisia': 'TN',
|
||||
'royaume-uni': 'GB',
|
||||
'uk': 'GB',
|
||||
'united kingdom': 'GB',
|
||||
'angleterre': 'GB',
|
||||
'england': 'GB',
|
||||
'espagne': 'ES',
|
||||
'spain': 'ES',
|
||||
'inde': 'IN',
|
||||
'india': 'IN',
|
||||
'france': 'FR',
|
||||
'états-unis': 'US',
|
||||
'usa': 'US',
|
||||
'united states': 'US',
|
||||
'allemagne': 'DE',
|
||||
'germany': 'DE',
|
||||
'italie': 'IT',
|
||||
'italy': 'IT',
|
||||
'portugal': 'PT',
|
||||
'monaco': 'MC',
|
||||
'suisse': 'CH',
|
||||
'switzerland': 'CH',
|
||||
'belgique': 'BE',
|
||||
'belgium': 'BE',
|
||||
'pays-bas': 'NL',
|
||||
'netherlands': 'NL',
|
||||
'australia': 'AU',
|
||||
'australie': 'AU',
|
||||
'japon': 'JP',
|
||||
'japan': 'JP',
|
||||
'chine': 'CN',
|
||||
'china': 'CN',
|
||||
'brésil': 'BR',
|
||||
'brazil': 'BR',
|
||||
'mexique': 'MX',
|
||||
'mexico': 'MX',
|
||||
'canada': 'CA',
|
||||
'maroc': 'MA',
|
||||
'morocco': 'MA',
|
||||
'egypte': 'EG',
|
||||
'egypt': 'EG',
|
||||
'afrique du sud': 'ZA',
|
||||
'south africa': 'ZA',
|
||||
'nigeria': 'NG',
|
||||
'kenya': 'KE',
|
||||
'ghana': 'GH',
|
||||
'senegal': 'SN',
|
||||
'sénégal': 'SN',
|
||||
'côte d\'ivoire': 'CI',
|
||||
'ivory coast': 'CI',
|
||||
'indonesia': 'ID',
|
||||
'indonésie': 'ID',
|
||||
'philippines': 'PH',
|
||||
'vietnam': 'VN',
|
||||
'thaïlande': 'TH',
|
||||
'thailand': 'TH',
|
||||
'malaisie': 'MY',
|
||||
'malaysia': 'MY',
|
||||
'singapour': 'SG',
|
||||
'singapore': 'SG',
|
||||
'grèce': 'GR',
|
||||
'greece': 'GR',
|
||||
'turquie': 'TR',
|
||||
'turkey': 'TR',
|
||||
'pologne': 'PL',
|
||||
'poland': 'PL',
|
||||
'norvège': 'NO',
|
||||
'norway': 'NO',
|
||||
'suède': 'SE',
|
||||
'sweden': 'SE',
|
||||
'danemark': 'DK',
|
||||
'denmark': 'DK',
|
||||
'finlande': 'FI',
|
||||
'finland': 'FI',
|
||||
'irlande': 'IE',
|
||||
'ireland': 'IE',
|
||||
'autriche': 'AT',
|
||||
'austria': 'AT',
|
||||
// Additional mappings from CSV data (French names, accented variants)
|
||||
'nigéria': 'NG',
|
||||
'tanzanie': 'TZ',
|
||||
'tanzania': 'TZ',
|
||||
'ouganda': 'UG',
|
||||
'uganda': 'UG',
|
||||
'zambie': 'ZM',
|
||||
'zambia': 'ZM',
|
||||
'somalie': 'SO',
|
||||
'somalia': 'SO',
|
||||
'jordanie': 'JO',
|
||||
'jordan': 'JO',
|
||||
'bulgarie': 'BG',
|
||||
'bulgaria': 'BG',
|
||||
'indonesie': 'ID',
|
||||
'macédoine du nord': 'MK',
|
||||
'north macedonia': 'MK',
|
||||
'jersey': 'JE',
|
||||
'kazakhstan': 'KZ',
|
||||
'cameroun': 'CM',
|
||||
'cameroon': 'CM',
|
||||
'vanuatu': 'VU',
|
||||
'bénin': 'BJ',
|
||||
'benin': 'BJ',
|
||||
'argentine': 'AR',
|
||||
'argentina': 'AR',
|
||||
'srbija': 'RS',
|
||||
'serbia': 'RS',
|
||||
'kraljevo': 'RS',
|
||||
'kosovo': 'XK',
|
||||
'pristina': 'XK',
|
||||
'xinjiang': 'CN',
|
||||
'haïti': 'HT',
|
||||
'haiti': 'HT',
|
||||
'sri lanka': 'LK',
|
||||
'luxembourg': 'LU',
|
||||
'congo': 'CG',
|
||||
'brazzaville': 'CG',
|
||||
'colombie': 'CO',
|
||||
'colombia': 'CO',
|
||||
'bogota': 'CO',
|
||||
'ukraine': 'UA',
|
||||
}
|
||||
|
||||
const lower = location.toLowerCase()
|
||||
|
||||
for (const [key, code] of Object.entries(countryMappings)) {
|
||||
if (lower.includes(key)) {
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Starting candidatures import...\n')
|
||||
|
||||
// Read the CSV file
|
||||
const csvPath = path.join(__dirname, '../docs/candidatures_2026.csv')
|
||||
|
||||
if (!fs.existsSync(csvPath)) {
|
||||
console.error(`CSV file not found at ${csvPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const csvContent = fs.readFileSync(csvPath, 'utf-8')
|
||||
|
||||
// Parse CSV
|
||||
const parseResult = Papa.parse<CandidatureRow>(csvContent, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
})
|
||||
|
||||
if (parseResult.errors.length > 0) {
|
||||
console.warn('CSV parsing warnings:', parseResult.errors)
|
||||
}
|
||||
|
||||
const rows = parseResult.data
|
||||
console.log(`Found ${rows.length} candidatures in CSV\n`)
|
||||
|
||||
// Get or create program
|
||||
let program = await prisma.program.findFirst({
|
||||
where: {
|
||||
name: 'Monaco Ocean Protection Challenge',
|
||||
year: 2026,
|
||||
},
|
||||
})
|
||||
|
||||
if (!program) {
|
||||
program = await prisma.program.create({
|
||||
data: {
|
||||
name: 'Monaco Ocean Protection Challenge',
|
||||
year: 2026,
|
||||
status: 'ACTIVE',
|
||||
description: 'The Monaco Ocean Protection Challenge is a flagship program promoting innovative solutions for ocean conservation.',
|
||||
},
|
||||
})
|
||||
console.log('Created program:', program.name, program.year)
|
||||
} else {
|
||||
console.log('Using existing program:', program.name, program.year)
|
||||
}
|
||||
|
||||
// Get or create Round 1
|
||||
let round = await prisma.round.findFirst({
|
||||
where: {
|
||||
programId: program.id,
|
||||
slug: 'mopc-2026-round-1',
|
||||
},
|
||||
})
|
||||
|
||||
if (!round) {
|
||||
round = await prisma.round.create({
|
||||
data: {
|
||||
programId: program.id,
|
||||
name: 'Round 1 - Semi-Finalists Selection',
|
||||
slug: 'mopc-2026-round-1',
|
||||
status: 'ACTIVE',
|
||||
roundType: 'EVALUATION',
|
||||
submissionStartDate: new Date('2025-09-01'),
|
||||
submissionEndDate: new Date('2026-01-31'),
|
||||
votingStartAt: new Date('2026-02-15'),
|
||||
votingEndAt: new Date('2026-02-28'),
|
||||
requiredReviews: 3,
|
||||
settingsJson: {
|
||||
gracePeriod: { hours: 24 },
|
||||
allowLateSubmissions: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
console.log('Created round:', round.name)
|
||||
} else {
|
||||
console.log('Using existing round:', round.name)
|
||||
}
|
||||
|
||||
console.log('\nImporting candidatures...\n')
|
||||
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
let errors = 0
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const projectName = row["Project's name"]?.trim()
|
||||
const email = row['E-mail']?.trim()
|
||||
|
||||
if (!projectName || !email) {
|
||||
console.log(`Skipping row: missing project name or email`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if project already exists
|
||||
const existingProject = await prisma.project.findFirst({
|
||||
where: {
|
||||
roundId: round.id,
|
||||
OR: [
|
||||
{ title: projectName },
|
||||
{ submittedByEmail: email },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (existingProject) {
|
||||
console.log(`Skipping duplicate: ${projectName} (${email})`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Get or create user
|
||||
let user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: row['Full name']?.trim() || 'Unknown',
|
||||
role: 'APPLICANT',
|
||||
status: 'NONE',
|
||||
phoneNumber: row['Téléphone']?.trim() || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Parse date
|
||||
let submittedAt: Date | null = null
|
||||
if (row['Date of creation']) {
|
||||
const dateStr = row['Date of creation'].trim()
|
||||
const parsed = new Date(dateStr)
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
submittedAt = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Create project
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
programId: round.programId,
|
||||
roundId: round.id,
|
||||
title: projectName,
|
||||
description: row['Comment ']?.trim() || null,
|
||||
competitionCategory: mapCategory(row['Category']),
|
||||
oceanIssue: mapOceanIssue(row['Issue']),
|
||||
country: extractCountry(row['Country']),
|
||||
geographicZone: row['Tri par zone']?.trim() || null,
|
||||
institution: row['University']?.trim() || null,
|
||||
wantsMentorship: row['Mentorship']?.toLowerCase() === 'true',
|
||||
phase1SubmissionUrl: row['PHASE 1 - Submission']?.trim() || null,
|
||||
phase2SubmissionUrl: row['PHASE 2 - Submission']?.trim() || null,
|
||||
referralSource: row['How did you hear about MOPC?']?.trim() || null,
|
||||
applicationStatus: row['Application status']?.trim() || 'Received',
|
||||
internalComments: row['MOPC team comments']?.trim() || null,
|
||||
submissionSource: 'CSV',
|
||||
submittedByEmail: email,
|
||||
submittedByUserId: user.id,
|
||||
submittedAt: submittedAt || new Date(),
|
||||
metadataJson: {
|
||||
importedFrom: 'candidatures_2026.csv',
|
||||
importedAt: new Date().toISOString(),
|
||||
originalPhone: row['Téléphone']?.trim(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Create team lead membership
|
||||
await prisma.teamMember.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
userId: user.id,
|
||||
role: 'LEAD',
|
||||
title: 'Team Lead',
|
||||
},
|
||||
})
|
||||
|
||||
// Parse and create team members
|
||||
const teamMembers = parseTeamMembers(row['Team members'])
|
||||
const leadName = row['Full name']?.trim().toLowerCase()
|
||||
|
||||
for (const member of teamMembers) {
|
||||
// Skip if it's the lead (already added)
|
||||
if (member.name.toLowerCase() === leadName) continue
|
||||
|
||||
// Since we don't have emails for team members, we create placeholder accounts
|
||||
// They can claim their accounts later
|
||||
const memberEmail = `${member.name.toLowerCase().replace(/[^a-z0-9]/g, '.')}@pending.mopc.local`
|
||||
|
||||
let memberUser = await prisma.user.findUnique({
|
||||
where: { email: memberEmail },
|
||||
})
|
||||
|
||||
if (!memberUser) {
|
||||
memberUser = await prisma.user.create({
|
||||
data: {
|
||||
email: memberEmail,
|
||||
name: member.name,
|
||||
role: 'APPLICANT',
|
||||
status: 'NONE',
|
||||
metadataJson: {
|
||||
isPendingEmailVerification: true,
|
||||
originalName: member.name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Check if membership already exists
|
||||
const existingMembership = await prisma.teamMember.findUnique({
|
||||
where: {
|
||||
projectId_userId: {
|
||||
projectId: project.id,
|
||||
userId: memberUser.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingMembership) {
|
||||
await prisma.teamMember.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
userId: memberUser.id,
|
||||
role: 'MEMBER',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Imported: ${projectName} (${email}) - ${teamMembers.length} team members`)
|
||||
imported++
|
||||
} catch (err) {
|
||||
console.error(`Error importing row:`, err)
|
||||
errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill: update any existing projects with null country
|
||||
console.log('\nBackfilling missing country codes...\n')
|
||||
let backfilled = 0
|
||||
const nullCountryProjects = await prisma.project.findMany({
|
||||
where: { roundId: round.id, country: null },
|
||||
select: { id: true, submittedByEmail: true, title: true },
|
||||
})
|
||||
|
||||
for (const project of nullCountryProjects) {
|
||||
// Find the matching CSV row by email or title
|
||||
const matchingRow = rows.find(
|
||||
(r) =>
|
||||
r['E-mail']?.trim() === project.submittedByEmail ||
|
||||
r["Project's name"]?.trim() === project.title
|
||||
)
|
||||
if (matchingRow?.['Country']) {
|
||||
const countryCode = extractCountry(matchingRow['Country'])
|
||||
if (countryCode) {
|
||||
await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: { country: countryCode },
|
||||
})
|
||||
console.log(` Updated: ${project.title} → ${countryCode}`)
|
||||
backfilled++
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` Backfilled: ${backfilled} projects\n`)
|
||||
|
||||
console.log('\n========================================')
|
||||
console.log(`Import complete!`)
|
||||
console.log(` Imported: ${imported}`)
|
||||
console.log(` Skipped: ${skipped}`)
|
||||
console.log(` Errors: ${errors}`)
|
||||
console.log(` Backfilled: ${backfilled}`)
|
||||
console.log('========================================\n')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -1,179 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
console.log('Setting up demo jury member...\n')
|
||||
|
||||
// Hash a password for the demo jury account
|
||||
const password = 'JuryDemo2026!'
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
|
||||
// Create or update jury member
|
||||
const juryUser = await prisma.user.upsert({
|
||||
where: { email: 'jury.demo@monaco-opc.com' },
|
||||
update: {
|
||||
passwordHash,
|
||||
mustSetPassword: false,
|
||||
status: 'ACTIVE',
|
||||
onboardingCompletedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
email: 'jury.demo@monaco-opc.com',
|
||||
name: 'Dr. Marie Laurent',
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'ACTIVE',
|
||||
passwordHash,
|
||||
mustSetPassword: false,
|
||||
passwordSetAt: new Date(),
|
||||
onboardingCompletedAt: new Date(),
|
||||
expertiseTags: ['Marine Biology', 'Ocean Conservation', 'Sustainable Innovation'],
|
||||
notificationPreference: 'EMAIL',
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Jury user: ${juryUser.email} (${juryUser.id})`)
|
||||
console.log(`Password: ${password}\n`)
|
||||
|
||||
// Find the round
|
||||
const round = await prisma.round.findFirst({
|
||||
where: { slug: 'mopc-2026-round-1' },
|
||||
})
|
||||
|
||||
if (!round) {
|
||||
console.error('Round not found! Run seed-candidatures first.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Round: ${round.name} (${round.id})`)
|
||||
|
||||
// Ensure voting window is open
|
||||
const now = new Date()
|
||||
const votingStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) // 7 days ago
|
||||
const votingEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) // 30 days from now
|
||||
|
||||
await prisma.round.update({
|
||||
where: { id: round.id },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
votingStartAt: votingStart,
|
||||
votingEndAt: votingEnd,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Voting window: ${votingStart.toISOString()} → ${votingEnd.toISOString()}\n`)
|
||||
|
||||
// Get some projects to assign
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { roundId: round.id },
|
||||
take: 8,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
|
||||
if (projects.length === 0) {
|
||||
console.error('No projects found! Run seed-candidatures first.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Found ${projects.length} projects to assign\n`)
|
||||
|
||||
// Create assignments
|
||||
let created = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const project of projects) {
|
||||
try {
|
||||
await prisma.assignment.upsert({
|
||||
where: {
|
||||
userId_projectId_roundId: {
|
||||
userId: juryUser.id,
|
||||
projectId: project.id,
|
||||
roundId: round.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
userId: juryUser.id,
|
||||
projectId: project.id,
|
||||
roundId: round.id,
|
||||
method: 'MANUAL',
|
||||
isRequired: true,
|
||||
},
|
||||
})
|
||||
console.log(` Assigned: ${project.title}`)
|
||||
created++
|
||||
} catch {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure evaluation criteria exist for this round
|
||||
const existingForm = await prisma.evaluationForm.findFirst({
|
||||
where: { roundId: round.id },
|
||||
})
|
||||
|
||||
if (!existingForm) {
|
||||
await prisma.evaluationForm.create({
|
||||
data: {
|
||||
roundId: round.id,
|
||||
isActive: true,
|
||||
criteriaJson: [
|
||||
{
|
||||
id: 'innovation',
|
||||
label: 'Innovation & Originality',
|
||||
description: 'How innovative is the proposed solution? Does it bring a new approach to ocean conservation?',
|
||||
scale: 10,
|
||||
weight: 25,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'feasibility',
|
||||
label: 'Technical Feasibility',
|
||||
description: 'Is the solution technically viable? Can it be realistically implemented?',
|
||||
scale: 10,
|
||||
weight: 25,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'impact',
|
||||
label: 'Environmental Impact',
|
||||
description: 'What is the potential positive impact on ocean health and marine ecosystems?',
|
||||
scale: 10,
|
||||
weight: 30,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
label: 'Team Capability',
|
||||
description: 'Does the team have the skills, experience, and commitment to execute?',
|
||||
scale: 10,
|
||||
weight: 20,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
console.log('\nCreated evaluation form with 4 criteria')
|
||||
} else {
|
||||
console.log('\nEvaluation form already exists')
|
||||
}
|
||||
|
||||
console.log('\n========================================')
|
||||
console.log('Demo jury member setup complete!')
|
||||
console.log(` Email: jury.demo@monaco-opc.com`)
|
||||
console.log(` Password: ${password}`)
|
||||
console.log(` Assignments: ${created} created, ${skipped} skipped`)
|
||||
console.log(` Voting: OPEN (${round.name})`)
|
||||
console.log('========================================\n')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
1104
prisma/seed.ts
1104
prisma/seed.ts
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user