Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
36
prisma/check-data.ts
Normal file
36
prisma/check-data.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function check() {
|
||||
const projects = await prisma.project.count()
|
||||
console.log('Total projects:', projects)
|
||||
|
||||
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 if projects have roundId set
|
||||
const projectsWithRound = await prisma.project.findMany({
|
||||
select: { id: true, title: true, roundId: true },
|
||||
take: 5
|
||||
})
|
||||
console.log('\nSample projects:')
|
||||
for (const p of projectsWithRound) {
|
||||
console.log(` ${p.title}: roundId=${p.roundId}`)
|
||||
}
|
||||
}
|
||||
|
||||
check()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (e) => {
|
||||
console.error(e)
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
68
prisma/cleanup-all-dummy.ts
Normal file
68
prisma/cleanup-all-dummy.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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 first
|
||||
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)
|
||||
})
|
||||
57
prisma/cleanup-dummy.ts
Normal file
57
prisma/cleanup-dummy.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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`)
|
||||
|
||||
// Disconnect projects from round first
|
||||
await prisma.round.update({
|
||||
where: { id: dummyRound.id },
|
||||
data: { projects: { disconnect: projectIds.map(id => ({ id })) } }
|
||||
})
|
||||
|
||||
// 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)
|
||||
})
|
||||
973
prisma/schema.prisma
Normal file
973
prisma/schema.prisma
Normal file
@@ -0,0 +1,973 @@
|
||||
// =============================================================================
|
||||
// MOPC Platform - Prisma Schema
|
||||
// =============================================================================
|
||||
// This schema defines the database structure for the Monaco Ocean Protection
|
||||
// Challenge jury voting platform.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "windows", "linux-musl-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ENUMS
|
||||
// =============================================================================
|
||||
|
||||
enum UserRole {
|
||||
SUPER_ADMIN
|
||||
PROGRAM_ADMIN
|
||||
JURY_MEMBER
|
||||
MENTOR
|
||||
OBSERVER
|
||||
APPLICANT
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
INVITED
|
||||
ACTIVE
|
||||
SUSPENDED
|
||||
}
|
||||
|
||||
enum ProgramStatus {
|
||||
DRAFT
|
||||
ACTIVE
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum RoundStatus {
|
||||
DRAFT
|
||||
ACTIVE
|
||||
CLOSED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum ProjectStatus {
|
||||
SUBMITTED
|
||||
ELIGIBLE
|
||||
ASSIGNED
|
||||
SEMIFINALIST
|
||||
FINALIST
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum EvaluationStatus {
|
||||
NOT_STARTED
|
||||
DRAFT
|
||||
SUBMITTED
|
||||
LOCKED
|
||||
}
|
||||
|
||||
enum AssignmentMethod {
|
||||
MANUAL
|
||||
BULK
|
||||
AI_SUGGESTED
|
||||
AI_AUTO
|
||||
ALGORITHM
|
||||
}
|
||||
|
||||
enum FileType {
|
||||
EXEC_SUMMARY
|
||||
PRESENTATION
|
||||
VIDEO
|
||||
OTHER
|
||||
BUSINESS_PLAN
|
||||
VIDEO_PITCH
|
||||
SUPPORTING_DOC
|
||||
}
|
||||
|
||||
enum SubmissionSource {
|
||||
MANUAL
|
||||
CSV
|
||||
NOTION
|
||||
TYPEFORM
|
||||
PUBLIC_FORM
|
||||
}
|
||||
|
||||
enum RoundType {
|
||||
FILTERING
|
||||
EVALUATION
|
||||
LIVE_EVENT
|
||||
}
|
||||
|
||||
enum SettingType {
|
||||
STRING
|
||||
NUMBER
|
||||
BOOLEAN
|
||||
JSON
|
||||
SECRET
|
||||
}
|
||||
|
||||
enum SettingCategory {
|
||||
AI
|
||||
BRANDING
|
||||
EMAIL
|
||||
STORAGE
|
||||
SECURITY
|
||||
DEFAULTS
|
||||
WHATSAPP
|
||||
}
|
||||
|
||||
enum NotificationChannel {
|
||||
EMAIL
|
||||
WHATSAPP
|
||||
BOTH
|
||||
NONE
|
||||
}
|
||||
|
||||
enum ResourceType {
|
||||
PDF
|
||||
VIDEO
|
||||
DOCUMENT
|
||||
LINK
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum CohortLevel {
|
||||
ALL
|
||||
SEMIFINALIST
|
||||
FINALIST
|
||||
}
|
||||
|
||||
enum PartnerVisibility {
|
||||
ADMIN_ONLY
|
||||
JURY_VISIBLE
|
||||
PUBLIC
|
||||
}
|
||||
|
||||
enum PartnerType {
|
||||
SPONSOR
|
||||
PARTNER
|
||||
SUPPORTER
|
||||
MEDIA
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum FormFieldType {
|
||||
TEXT
|
||||
TEXTAREA
|
||||
NUMBER
|
||||
EMAIL
|
||||
PHONE
|
||||
URL
|
||||
DATE
|
||||
DATETIME
|
||||
SELECT
|
||||
MULTI_SELECT
|
||||
RADIO
|
||||
CHECKBOX
|
||||
CHECKBOX_GROUP
|
||||
FILE
|
||||
FILE_MULTIPLE
|
||||
SECTION
|
||||
INSTRUCTIONS
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// APPLICANT SYSTEM ENUMS
|
||||
// =============================================================================
|
||||
|
||||
enum CompetitionCategory {
|
||||
STARTUP // Existing companies
|
||||
BUSINESS_CONCEPT // Students/graduates
|
||||
}
|
||||
|
||||
enum OceanIssue {
|
||||
POLLUTION_REDUCTION
|
||||
CLIMATE_MITIGATION
|
||||
TECHNOLOGY_INNOVATION
|
||||
SUSTAINABLE_SHIPPING
|
||||
BLUE_CARBON
|
||||
HABITAT_RESTORATION
|
||||
COMMUNITY_CAPACITY
|
||||
SUSTAINABLE_FISHING
|
||||
CONSUMER_AWARENESS
|
||||
OCEAN_ACIDIFICATION
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum TeamMemberRole {
|
||||
LEAD // Primary contact / team lead
|
||||
MEMBER // Regular team member
|
||||
ADVISOR // Advisor/mentor from team side
|
||||
}
|
||||
|
||||
enum MentorAssignmentMethod {
|
||||
MANUAL
|
||||
AI_SUGGESTED
|
||||
AI_AUTO
|
||||
ALGORITHM
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// USERS & AUTHENTICATION
|
||||
// =============================================================================
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
emailVerified DateTime? // Required by NextAuth Prisma adapter
|
||||
role UserRole @default(JURY_MEMBER)
|
||||
status UserStatus @default(INVITED)
|
||||
expertiseTags String[] @default([])
|
||||
maxAssignments Int? // Per-round limit
|
||||
metadataJson Json? @db.JsonB
|
||||
|
||||
// Profile image
|
||||
profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg")
|
||||
profileImageProvider String? // Storage provider used: 's3' or 'local'
|
||||
|
||||
// Phone and notification preferences (Phase 2)
|
||||
phoneNumber String?
|
||||
phoneNumberVerified Boolean @default(false)
|
||||
notificationPreference NotificationChannel @default(EMAIL)
|
||||
whatsappOptIn Boolean @default(false)
|
||||
|
||||
// Onboarding (Phase 2B)
|
||||
onboardingCompletedAt DateTime?
|
||||
|
||||
// Password authentication (hybrid auth)
|
||||
passwordHash String? // bcrypt hashed password
|
||||
passwordSetAt DateTime? // When password was set
|
||||
mustSetPassword Boolean @default(true) // Force setup on first login
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastLoginAt DateTime?
|
||||
|
||||
// Relations
|
||||
assignments Assignment[]
|
||||
auditLogs AuditLog[]
|
||||
gracePeriods GracePeriod[]
|
||||
grantedGracePeriods GracePeriod[] @relation("GrantedBy")
|
||||
notificationLogs NotificationLog[]
|
||||
createdResources LearningResource[] @relation("ResourceCreatedBy")
|
||||
resourceAccess ResourceAccess[]
|
||||
submittedProjects Project[] @relation("ProjectSubmittedBy")
|
||||
liveVotes LiveVote[]
|
||||
|
||||
// Team membership & mentorship
|
||||
teamMemberships TeamMember[]
|
||||
mentorAssignments MentorAssignment[] @relation("MentorAssignments")
|
||||
|
||||
// NextAuth relations
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
|
||||
@@index([email])
|
||||
@@index([role])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
// NextAuth.js required models
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROGRAMS & ROUNDS
|
||||
// =============================================================================
|
||||
|
||||
model Program {
|
||||
id String @id @default(cuid())
|
||||
name String // e.g., "Monaco Ocean Protection Challenge"
|
||||
year Int // e.g., 2026
|
||||
status ProgramStatus @default(DRAFT)
|
||||
description String?
|
||||
settingsJson Json? @db.JsonB
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
rounds Round[]
|
||||
learningResources LearningResource[]
|
||||
partners Partner[]
|
||||
applicationForms ApplicationForm[]
|
||||
|
||||
@@unique([name, year])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model Round {
|
||||
id String @id @default(cuid())
|
||||
programId String
|
||||
name String // e.g., "Round 1 - Semi-Finalists"
|
||||
slug String? @unique // URL-friendly identifier for public submissions
|
||||
status RoundStatus @default(DRAFT)
|
||||
roundType RoundType @default(EVALUATION)
|
||||
|
||||
// Submission window (for applicant portal)
|
||||
submissionDeadline DateTime? // Deadline for project submissions
|
||||
submissionStartDate DateTime? // When submissions open
|
||||
submissionEndDate DateTime? // When submissions close (replaces submissionDeadline if set)
|
||||
lateSubmissionGrace Int? // Hours of grace period after deadline
|
||||
|
||||
// Phase-specific deadlines
|
||||
phase1Deadline DateTime?
|
||||
phase2Deadline DateTime?
|
||||
|
||||
// Voting window
|
||||
votingStartAt DateTime?
|
||||
votingEndAt DateTime?
|
||||
|
||||
// Configuration
|
||||
requiredReviews Int @default(3) // Min evaluations per project
|
||||
settingsJson Json? @db.JsonB // Grace periods, visibility rules, etc.
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||
projects Project[]
|
||||
assignments Assignment[]
|
||||
evaluationForms EvaluationForm[]
|
||||
gracePeriods GracePeriod[]
|
||||
liveVotingSession LiveVotingSession?
|
||||
|
||||
@@index([programId])
|
||||
@@index([status])
|
||||
@@index([roundType])
|
||||
@@index([votingStartAt, votingEndAt])
|
||||
@@index([submissionStartDate, submissionEndDate])
|
||||
}
|
||||
|
||||
model EvaluationForm {
|
||||
id String @id @default(cuid())
|
||||
roundId String
|
||||
version Int @default(1)
|
||||
|
||||
// Form configuration
|
||||
// criteriaJson: Array of { id, label, description, scale, weight, required }
|
||||
criteriaJson Json @db.JsonB
|
||||
// scalesJson: { "1-5": { min, max, labels }, "1-10": { min, max, labels } }
|
||||
scalesJson Json? @db.JsonB
|
||||
isActive Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
evaluations Evaluation[]
|
||||
|
||||
@@unique([roundId, version])
|
||||
@@index([roundId, isActive])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROJECTS
|
||||
// =============================================================================
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
roundId String
|
||||
|
||||
// Core fields
|
||||
title String
|
||||
teamName String?
|
||||
description String? @db.Text
|
||||
status ProjectStatus @default(SUBMITTED)
|
||||
|
||||
// Competition category
|
||||
competitionCategory CompetitionCategory?
|
||||
oceanIssue OceanIssue?
|
||||
|
||||
// Location
|
||||
country String?
|
||||
geographicZone String? // "Europe, France"
|
||||
|
||||
// Institution (for students/Business Concepts)
|
||||
institution String?
|
||||
|
||||
// Mentorship
|
||||
wantsMentorship Boolean @default(false)
|
||||
|
||||
// Submission links (external, from CSV)
|
||||
phase1SubmissionUrl String?
|
||||
phase2SubmissionUrl String?
|
||||
|
||||
// Referral tracking
|
||||
referralSource String?
|
||||
|
||||
// Internal admin fields
|
||||
internalComments String? @db.Text
|
||||
applicationStatus String? // "Received", etc.
|
||||
|
||||
// Submission tracking
|
||||
submissionSource SubmissionSource @default(MANUAL)
|
||||
submittedByEmail String?
|
||||
submittedAt DateTime?
|
||||
submittedByUserId String?
|
||||
|
||||
// Project branding
|
||||
logoKey String? // Storage key (e.g., "logos/project456/1234567890.png")
|
||||
logoProvider String? // Storage provider used: 's3' or 'local'
|
||||
|
||||
// Flexible fields
|
||||
tags String[] @default([]) // "Ocean Conservation", "Tech", etc.
|
||||
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
|
||||
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
files ProjectFile[]
|
||||
assignments Assignment[]
|
||||
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
||||
teamMembers TeamMember[]
|
||||
mentorAssignment MentorAssignment?
|
||||
|
||||
@@index([roundId])
|
||||
@@index([status])
|
||||
@@index([tags])
|
||||
@@index([submissionSource])
|
||||
@@index([submittedByUserId])
|
||||
@@index([competitionCategory])
|
||||
@@index([oceanIssue])
|
||||
@@index([country])
|
||||
}
|
||||
|
||||
model ProjectFile {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
|
||||
// File info
|
||||
fileType FileType
|
||||
fileName String
|
||||
mimeType String
|
||||
size Int // bytes
|
||||
|
||||
// MinIO location
|
||||
bucket String
|
||||
objectKey String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([bucket, objectKey])
|
||||
@@index([projectId])
|
||||
@@index([fileType])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ASSIGNMENTS & EVALUATIONS
|
||||
// =============================================================================
|
||||
|
||||
model Assignment {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
projectId String
|
||||
roundId String
|
||||
|
||||
// Assignment info
|
||||
method AssignmentMethod @default(MANUAL)
|
||||
isRequired Boolean @default(true)
|
||||
isCompleted Boolean @default(false)
|
||||
|
||||
// AI assignment metadata
|
||||
aiConfidenceScore Float? // 0-1 confidence from AI
|
||||
expertiseMatchScore Float? // 0-1 match score
|
||||
aiReasoning String? @db.Text
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
createdBy String? // Admin who created the assignment
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
evaluation Evaluation?
|
||||
|
||||
@@unique([userId, projectId, roundId])
|
||||
@@index([userId])
|
||||
@@index([projectId])
|
||||
@@index([roundId])
|
||||
@@index([isCompleted])
|
||||
}
|
||||
|
||||
model Evaluation {
|
||||
id String @id @default(cuid())
|
||||
assignmentId String @unique
|
||||
formId String
|
||||
|
||||
// Status
|
||||
status EvaluationStatus @default(NOT_STARTED)
|
||||
|
||||
// Scores
|
||||
// criterionScoresJson: { "criterion_id": score, ... }
|
||||
criterionScoresJson Json? @db.JsonB
|
||||
globalScore Int? // 1-10
|
||||
binaryDecision Boolean? // Yes/No for semi-finalist
|
||||
feedbackText String? @db.Text
|
||||
|
||||
// Versioning
|
||||
version Int @default(1)
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
submittedAt DateTime?
|
||||
|
||||
// Relations
|
||||
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
form EvaluationForm @relation(fields: [formId], references: [id])
|
||||
|
||||
@@index([status])
|
||||
@@index([submittedAt])
|
||||
@@index([formId])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GRACE PERIODS
|
||||
// =============================================================================
|
||||
|
||||
model GracePeriod {
|
||||
id String @id @default(cuid())
|
||||
roundId String
|
||||
userId String
|
||||
projectId String? // Optional: specific project or all projects in round
|
||||
|
||||
extendedUntil DateTime
|
||||
reason String? @db.Text
|
||||
grantedById String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
grantedBy User @relation("GrantedBy", fields: [grantedById], references: [id])
|
||||
|
||||
@@index([roundId])
|
||||
@@index([userId])
|
||||
@@index([extendedUntil])
|
||||
@@index([grantedById])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SYSTEM SETTINGS
|
||||
// =============================================================================
|
||||
|
||||
model SystemSettings {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
value String @db.Text
|
||||
type SettingType @default(STRING)
|
||||
category SettingCategory
|
||||
|
||||
description String?
|
||||
isSecret Boolean @default(false) // If true, value is encrypted
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedBy String?
|
||||
|
||||
@@index([category])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AUDIT LOGGING
|
||||
// =============================================================================
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
|
||||
// Event info
|
||||
action String // "CREATE", "UPDATE", "DELETE", "LOGIN", "EXPORT", etc.
|
||||
entityType String // "Round", "Project", "Evaluation", etc.
|
||||
entityId String?
|
||||
|
||||
// Details
|
||||
detailsJson Json? @db.JsonB // Before/after values, additional context
|
||||
|
||||
// Request info
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
|
||||
timestamp DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@index([action])
|
||||
@@index([entityType, entityId])
|
||||
@@index([timestamp])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NOTIFICATION LOG (Phase 2)
|
||||
// =============================================================================
|
||||
|
||||
model NotificationLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
channel NotificationChannel
|
||||
provider String? // META, TWILIO, SMTP
|
||||
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION
|
||||
status String // PENDING, SENT, DELIVERED, FAILED
|
||||
externalId String? // Message ID from provider
|
||||
errorMsg String? @db.Text
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LEARNING HUB (Phase 2)
|
||||
// =============================================================================
|
||||
|
||||
model LearningResource {
|
||||
id String @id @default(cuid())
|
||||
programId String? // null = global resource
|
||||
title String
|
||||
description String? @db.Text
|
||||
contentJson Json? @db.JsonB // BlockNote document structure
|
||||
resourceType ResourceType
|
||||
cohortLevel CohortLevel @default(ALL)
|
||||
|
||||
// File storage (for uploaded resources)
|
||||
fileName String?
|
||||
mimeType String?
|
||||
size Int?
|
||||
bucket String?
|
||||
objectKey String?
|
||||
|
||||
// External link
|
||||
externalUrl String?
|
||||
|
||||
sortOrder Int @default(0)
|
||||
isPublished Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String
|
||||
|
||||
// Relations
|
||||
program Program? @relation(fields: [programId], references: [id], onDelete: SetNull)
|
||||
createdBy User @relation("ResourceCreatedBy", fields: [createdById], references: [id])
|
||||
accessLogs ResourceAccess[]
|
||||
|
||||
@@index([programId])
|
||||
@@index([cohortLevel])
|
||||
@@index([isPublished])
|
||||
@@index([sortOrder])
|
||||
}
|
||||
|
||||
model ResourceAccess {
|
||||
id String @id @default(cuid())
|
||||
resourceId String
|
||||
userId String
|
||||
accessedAt DateTime @default(now())
|
||||
ipAddress String?
|
||||
|
||||
// Relations
|
||||
resource LearningResource @relation(fields: [resourceId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([resourceId])
|
||||
@@index([userId])
|
||||
@@index([accessedAt])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PARTNER MANAGEMENT (Phase 2)
|
||||
// =============================================================================
|
||||
|
||||
model Partner {
|
||||
id String @id @default(cuid())
|
||||
programId String? // null = global partner
|
||||
name String
|
||||
description String? @db.Text
|
||||
website String?
|
||||
partnerType PartnerType @default(PARTNER)
|
||||
visibility PartnerVisibility @default(ADMIN_ONLY)
|
||||
|
||||
// Logo file
|
||||
logoFileName String?
|
||||
logoBucket String?
|
||||
logoObjectKey String?
|
||||
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
program Program? @relation(fields: [programId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([programId])
|
||||
@@index([partnerType])
|
||||
@@index([visibility])
|
||||
@@index([isActive])
|
||||
@@index([sortOrder])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// APPLICATION FORMS (Phase 2)
|
||||
// =============================================================================
|
||||
|
||||
model ApplicationForm {
|
||||
id String @id @default(cuid())
|
||||
programId String? // null = global form
|
||||
name String
|
||||
description String? @db.Text
|
||||
status String @default("DRAFT") // DRAFT, PUBLISHED, CLOSED
|
||||
|
||||
isPublic Boolean @default(false)
|
||||
publicSlug String? @unique // /apply/ocean-challenge-2026
|
||||
submissionLimit Int?
|
||||
opensAt DateTime?
|
||||
closesAt DateTime?
|
||||
|
||||
confirmationMessage String? @db.Text
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
program Program? @relation(fields: [programId], references: [id], onDelete: SetNull)
|
||||
fields ApplicationFormField[]
|
||||
submissions ApplicationFormSubmission[]
|
||||
|
||||
@@index([programId])
|
||||
@@index([status])
|
||||
@@index([isPublic])
|
||||
}
|
||||
|
||||
model ApplicationFormField {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
fieldType FormFieldType
|
||||
name String // Internal name (e.g., "project_title")
|
||||
label String // Display label (e.g., "Project Title")
|
||||
description String? @db.Text
|
||||
placeholder String?
|
||||
|
||||
required Boolean @default(false)
|
||||
minLength Int?
|
||||
maxLength Int?
|
||||
minValue Float? // For NUMBER type
|
||||
maxValue Float? // For NUMBER type
|
||||
|
||||
optionsJson Json? @db.JsonB // For select/radio: [{ value, label }]
|
||||
conditionJson Json? @db.JsonB // Conditional logic: { fieldId, operator, value }
|
||||
|
||||
sortOrder Int @default(0)
|
||||
width String @default("full") // full, half
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([formId])
|
||||
@@index([sortOrder])
|
||||
}
|
||||
|
||||
model ApplicationFormSubmission {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
email String?
|
||||
name String?
|
||||
dataJson Json @db.JsonB // Field values: { fieldName: value, ... }
|
||||
status String @default("SUBMITTED") // SUBMITTED, REVIEWED, APPROVED, REJECTED
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
files SubmissionFile[]
|
||||
|
||||
@@index([formId])
|
||||
@@index([status])
|
||||
@@index([email])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model SubmissionFile {
|
||||
id String @id @default(cuid())
|
||||
submissionId String
|
||||
fieldName String
|
||||
fileName String
|
||||
mimeType String?
|
||||
size Int?
|
||||
bucket String
|
||||
objectKey String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
submission ApplicationFormSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([submissionId])
|
||||
@@unique([bucket, objectKey])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXPERTISE TAGS (Phase 2B)
|
||||
// =============================================================================
|
||||
|
||||
model ExpertiseTag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String?
|
||||
category String? // "Marine Science", "Technology", "Policy"
|
||||
color String? // Hex for badge
|
||||
isActive Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([category])
|
||||
@@index([isActive])
|
||||
@@index([sortOrder])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LIVE VOTING (Phase 2B)
|
||||
// =============================================================================
|
||||
|
||||
model LiveVotingSession {
|
||||
id String @id @default(cuid())
|
||||
roundId String @unique
|
||||
status String @default("NOT_STARTED") // NOT_STARTED, IN_PROGRESS, PAUSED, COMPLETED
|
||||
currentProjectIndex Int @default(0)
|
||||
currentProjectId String?
|
||||
votingStartedAt DateTime?
|
||||
votingEndsAt DateTime?
|
||||
projectOrderJson Json? @db.JsonB // Array of project IDs in presentation order
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
votes LiveVote[]
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model LiveVote {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
projectId String
|
||||
userId String
|
||||
score Int // 1-10
|
||||
votedAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sessionId, projectId, userId])
|
||||
@@index([sessionId])
|
||||
@@index([projectId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TEAM MEMBERSHIP
|
||||
// =============================================================================
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
userId String
|
||||
role TeamMemberRole @default(MEMBER)
|
||||
title String? // "CEO", "CTO", etc.
|
||||
joinedAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([projectId, userId])
|
||||
@@index([projectId])
|
||||
@@index([userId])
|
||||
@@index([role])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MENTOR ASSIGNMENT
|
||||
// =============================================================================
|
||||
|
||||
model MentorAssignment {
|
||||
id String @id @default(cuid())
|
||||
projectId String @unique // One mentor per project
|
||||
mentorId String // User with MENTOR role or expertise
|
||||
|
||||
// Assignment tracking
|
||||
method MentorAssignmentMethod @default(MANUAL)
|
||||
assignedAt DateTime @default(now())
|
||||
assignedBy String? // Admin who assigned
|
||||
|
||||
// AI assignment metadata
|
||||
aiConfidenceScore Float?
|
||||
expertiseMatchScore Float?
|
||||
aiReasoning String? @db.Text
|
||||
|
||||
// Relations
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
mentor User @relation("MentorAssignments", fields: [mentorId], references: [id])
|
||||
|
||||
@@index([mentorId])
|
||||
@@index([method])
|
||||
}
|
||||
510
prisma/seed-candidatures.ts
Normal file
510
prisma/seed-candidatures.ts
Normal file
@@ -0,0 +1,510 @@
|
||||
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: 'ACTIVE',
|
||||
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: {
|
||||
roundId: round.id,
|
||||
title: projectName,
|
||||
description: row['Comment ']?.trim() || null,
|
||||
status: 'SUBMITTED',
|
||||
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: 'INVITED',
|
||||
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()
|
||||
})
|
||||
21
prisma/seed-jury-assignments.sql
Normal file
21
prisma/seed-jury-assignments.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
jury_id TEXT;
|
||||
round_id TEXT;
|
||||
proj RECORD;
|
||||
BEGIN
|
||||
SELECT id INTO jury_id FROM "User" WHERE email = 'jury.demo@monaco-opc.com';
|
||||
SELECT id INTO round_id FROM "Round" WHERE slug = 'mopc-2026-round-1';
|
||||
|
||||
UPDATE "Round" SET status = 'ACTIVE', "votingStartAt" = NOW() - INTERVAL '7 days', "votingEndAt" = NOW() + INTERVAL '30 days' WHERE id = round_id;
|
||||
|
||||
FOR proj IN SELECT id, title FROM "Project" WHERE "roundId" = round_id ORDER BY "createdAt" DESC LIMIT 8
|
||||
LOOP
|
||||
INSERT INTO "Assignment" (id, "userId", "projectId", "roundId", method, "isRequired", "isCompleted", "createdAt")
|
||||
VALUES ('demo-assign-' || substr(proj.id, 1, 15), jury_id, proj.id, round_id, 'MANUAL', true, false, NOW())
|
||||
ON CONFLICT ("userId", "projectId", "roundId") DO NOTHING;
|
||||
RAISE NOTICE 'Assigned: %', proj.title;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Done! Assigned projects to jury member.';
|
||||
END $$;
|
||||
95
prisma/seed-jury-demo.sql
Normal file
95
prisma/seed-jury-demo.sql
Normal file
@@ -0,0 +1,95 @@
|
||||
-- Create demo jury member
|
||||
INSERT INTO "User" (id, email, name, role, status, "passwordHash", "mustSetPassword", "passwordSetAt", "onboardingCompletedAt", "expertiseTags", "notificationPreference", "createdAt", "updatedAt")
|
||||
VALUES (
|
||||
'demo-jury-member-001',
|
||||
'jury.demo@monaco-opc.com',
|
||||
'Dr. Marie Laurent',
|
||||
'JURY_MEMBER',
|
||||
'ACTIVE',
|
||||
'$2b$12$xUQpxLay9.0CJ08GvXrjm.yls.bp0Yeaa4TF5b4kLsIJGLrVMCVZ.',
|
||||
false,
|
||||
NOW(),
|
||||
NOW(),
|
||||
ARRAY['Marine Biology', 'Ocean Conservation', 'Sustainable Innovation'],
|
||||
'EMAIL',
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
"passwordHash" = EXCLUDED."passwordHash",
|
||||
"mustSetPassword" = false,
|
||||
status = 'ACTIVE',
|
||||
"onboardingCompletedAt" = NOW(),
|
||||
"updatedAt" = NOW();
|
||||
|
||||
-- Get the user ID
|
||||
DO $$
|
||||
DECLARE
|
||||
jury_id TEXT;
|
||||
round_id TEXT;
|
||||
proj RECORD;
|
||||
form_exists BOOLEAN;
|
||||
BEGIN
|
||||
SELECT id INTO jury_id FROM "User" WHERE email = 'jury.demo@monaco-opc.com';
|
||||
RAISE NOTICE 'Jury user ID: %', jury_id;
|
||||
|
||||
-- Get round
|
||||
SELECT id INTO round_id FROM "Round" WHERE slug = 'mopc-2026-round-1';
|
||||
RAISE NOTICE 'Round ID: %', round_id;
|
||||
|
||||
IF round_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Round not found!';
|
||||
END IF;
|
||||
|
||||
-- Open voting window
|
||||
UPDATE "Round" SET
|
||||
status = 'ACTIVE',
|
||||
"votingStartAt" = NOW() - INTERVAL '7 days',
|
||||
"votingEndAt" = NOW() + INTERVAL '30 days'
|
||||
WHERE id = round_id;
|
||||
RAISE NOTICE 'Voting window opened';
|
||||
|
||||
-- Assign 8 projects
|
||||
FOR proj IN
|
||||
SELECT id, title FROM "Project" WHERE "roundId" = round_id ORDER BY "createdAt" DESC LIMIT 8
|
||||
LOOP
|
||||
INSERT INTO "Assignment" (id, "userId", "projectId", "roundId", method, "isRequired", "isCompleted", "createdAt")
|
||||
VALUES (
|
||||
'demo-assign-' || substr(proj.id, 1, 15),
|
||||
jury_id,
|
||||
proj.id,
|
||||
round_id,
|
||||
'MANUAL',
|
||||
true,
|
||||
false,
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT ("userId", "projectId", "roundId") DO NOTHING;
|
||||
RAISE NOTICE 'Assigned: %', proj.title;
|
||||
END LOOP;
|
||||
|
||||
-- Check if evaluation form exists
|
||||
SELECT EXISTS(SELECT 1 FROM "EvaluationForm" WHERE "roundId" = round_id) INTO form_exists;
|
||||
|
||||
IF NOT form_exists THEN
|
||||
INSERT INTO "EvaluationForm" (id, "roundId", name, "isActive", "criteriaJson", "createdAt", "updatedAt")
|
||||
VALUES (
|
||||
'demo-eval-form-001',
|
||||
round_id,
|
||||
'Round 1 Evaluation',
|
||||
true,
|
||||
'[{"id":"innovation","label":"Innovation & Originality","description":"How innovative is the proposed solution?","scale":10,"weight":25,"required":true},{"id":"feasibility","label":"Technical Feasibility","description":"Is the solution technically viable?","scale":10,"weight":25,"required":true},{"id":"impact","label":"Environmental Impact","description":"What is the potential positive impact on ocean health?","scale":10,"weight":30,"required":true},{"id":"team","label":"Team Capability","description":"Does the team have the skills to execute?","scale":10,"weight":20,"required":true}]'::jsonb,
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
RAISE NOTICE 'Created evaluation form';
|
||||
ELSE
|
||||
RAISE NOTICE 'Evaluation form already exists';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '========================================';
|
||||
RAISE NOTICE 'Setup complete!';
|
||||
RAISE NOTICE 'Email: jury.demo@monaco-opc.com';
|
||||
RAISE NOTICE 'Password: JuryDemo2026!';
|
||||
RAISE NOTICE '========================================';
|
||||
END $$;
|
||||
180
prisma/seed-jury-demo.ts
Normal file
180
prisma/seed-jury-demo.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
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,
|
||||
name: 'Round 1 Evaluation',
|
||||
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()
|
||||
})
|
||||
488
prisma/seed.ts
Normal file
488
prisma/seed.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
import {
|
||||
PrismaClient,
|
||||
UserRole,
|
||||
UserStatus,
|
||||
ProgramStatus,
|
||||
RoundStatus,
|
||||
SettingType,
|
||||
SettingCategory,
|
||||
} from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Seeding database...')
|
||||
|
||||
// ==========================================================================
|
||||
// Create System Settings
|
||||
// ==========================================================================
|
||||
console.log('📋 Creating system settings...')
|
||||
|
||||
const settings = [
|
||||
// AI Settings
|
||||
{
|
||||
key: 'ai_enabled',
|
||||
value: 'false',
|
||||
type: SettingType.BOOLEAN,
|
||||
category: SettingCategory.AI,
|
||||
description: 'Enable AI-powered jury assignment suggestions',
|
||||
},
|
||||
{
|
||||
key: 'ai_provider',
|
||||
value: 'openai',
|
||||
type: SettingType.STRING,
|
||||
category: SettingCategory.AI,
|
||||
description: 'AI provider for smart assignment (openai)',
|
||||
},
|
||||
{
|
||||
key: 'ai_model',
|
||||
value: 'gpt-4o',
|
||||
type: SettingType.STRING,
|
||||
category: SettingCategory.AI,
|
||||
description: 'OpenAI model to use for suggestions',
|
||||
},
|
||||
{
|
||||
key: 'ai_send_descriptions',
|
||||
value: 'false',
|
||||
type: SettingType.BOOLEAN,
|
||||
category: SettingCategory.AI,
|
||||
description: 'Send anonymized project descriptions to AI',
|
||||
},
|
||||
// Branding Settings
|
||||
{
|
||||
key: 'platform_name',
|
||||
value: 'Monaco Ocean Protection Challenge',
|
||||
type: SettingType.STRING,
|
||||
category: SettingCategory.BRANDING,
|
||||
description: 'Platform display name',
|
||||
},
|
||||
{
|
||||
key: 'primary_color',
|
||||
value: '#de0f1e',
|
||||
type: SettingType.STRING,
|
||||
category: SettingCategory.BRANDING,
|
||||
description: 'Primary brand color (hex)',
|
||||
},
|
||||
{
|
||||
key: 'secondary_color',
|
||||
value: '#053d57',
|
||||
type: SettingType.STRING,
|
||||
category: SettingCategory.BRANDING,
|
||||
description: 'Secondary brand color (hex)',
|
||||
},
|
||||
{
|
||||
key: 'accent_color',
|
||||
value: '#557f8c',
|
||||
type: SettingType.STRING,
|
||||
category: SettingCategory.BRANDING,
|
||||
description: 'Accent color (hex)',
|
||||
},
|
||||
// Security Settings
|
||||
{
|
||||
key: 'session_duration_hours',
|
||||
value: '24',
|
||||
type: SettingType.NUMBER,
|
||||
category: SettingCategory.SECURITY,
|
||||
description: 'Session duration in hours',
|
||||
},
|
||||
{
|
||||
key: 'magic_link_expiry_minutes',
|
||||
value: '15',
|
||||
type: SettingType.NUMBER,
|
||||
category: SettingCategory.SECURITY,
|
||||
description: 'Magic link expiry time in minutes',
|
||||
},
|
||||
{
|
||||
key: 'rate_limit_requests_per_minute',
|
||||
value: '60',
|
||||
type: SettingType.NUMBER,
|
||||
category: SettingCategory.SECURITY,
|
||||
description: 'API rate limit per minute',
|
||||
},
|
||||
// Storage Settings
|
||||
{
|
||||
key: 'storage_provider',
|
||||
value: 's3',
|
||||
type: SettingType.STRING,
|
||||
category: SettingCategory.STORAGE,
|
||||
description: 'Storage provider: s3 (MinIO) or local (filesystem)',
|
||||
},
|
||||
{
|
||||
key: 'local_storage_path',
|
||||
value: './uploads',
|
||||
type: SettingType.STRING,
|
||||
category: SettingCategory.STORAGE,
|
||||
description: 'Base path for local file storage',
|
||||
},
|
||||
{
|
||||
key: 'max_file_size_mb',
|
||||
value: '500',
|
||||
type: SettingType.NUMBER,
|
||||
category: SettingCategory.STORAGE,
|
||||
description: 'Maximum file upload size in MB',
|
||||
},
|
||||
{
|
||||
key: 'avatar_max_size_mb',
|
||||
value: '5',
|
||||
type: SettingType.NUMBER,
|
||||
category: SettingCategory.STORAGE,
|
||||
description: 'Maximum avatar image size in MB',
|
||||
},
|
||||
{
|
||||
key: 'allowed_file_types',
|
||||
value: JSON.stringify(['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg']),
|
||||
type: SettingType.JSON,
|
||||
category: SettingCategory.STORAGE,
|
||||
description: 'Allowed MIME types for file uploads',
|
||||
},
|
||||
{
|
||||
key: 'allowed_image_types',
|
||||
value: JSON.stringify(['image/png', 'image/jpeg', 'image/webp']),
|
||||
type: SettingType.JSON,
|
||||
category: SettingCategory.STORAGE,
|
||||
description: 'Allowed MIME types for avatar/logo uploads',
|
||||
},
|
||||
// Default Settings
|
||||
{
|
||||
key: 'default_timezone',
|
||||
value: 'Europe/Monaco',
|
||||
type: SettingType.STRING,
|
||||
category: SettingCategory.DEFAULTS,
|
||||
description: 'Default timezone for date displays',
|
||||
},
|
||||
{
|
||||
key: 'default_page_size',
|
||||
value: '20',
|
||||
type: SettingType.NUMBER,
|
||||
category: SettingCategory.DEFAULTS,
|
||||
description: 'Default pagination size',
|
||||
},
|
||||
{
|
||||
key: 'autosave_interval_seconds',
|
||||
value: '30',
|
||||
type: SettingType.NUMBER,
|
||||
category: SettingCategory.DEFAULTS,
|
||||
description: 'Autosave interval for evaluation forms',
|
||||
},
|
||||
// WhatsApp Settings (Phase 2)
|
||||
{
|
||||
key: 'whatsapp_enabled',
|
||||
value: 'false',
|
||||
type: SettingType.BOOLEAN,
|
||||
category: SettingCategory.WHATSAPP,
|
||||
description: 'Enable WhatsApp notifications',
|
||||
},
|
||||
{
|
||||
key: 'whatsapp_provider',
|
||||
value: 'META',
|
||||
type: SettingType.STRING,
|
||||
category: SettingCategory.WHATSAPP,
|
||||
description: 'WhatsApp provider (META or TWILIO)',
|
||||
},
|
||||
{
|
||||
key: 'whatsapp_meta_phone_number_id',
|
||||
value: '',
|
||||
type: SettingType.STRING,
|
||||
category: SettingCategory.WHATSAPP,
|
||||
description: 'Meta WhatsApp Phone Number ID',
|
||||
},
|
||||
{
|
||||
key: 'whatsapp_meta_access_token',
|
||||
value: '',
|
||||
type: SettingType.SECRET,
|
||||
category: SettingCategory.WHATSAPP,
|
||||
description: 'Meta WhatsApp Access Token',
|
||||
isSecret: true,
|
||||
},
|
||||
{
|
||||
key: 'whatsapp_meta_business_account_id',
|
||||
value: '',
|
||||
type: SettingType.STRING,
|
||||
category: SettingCategory.WHATSAPP,
|
||||
description: 'Meta WhatsApp Business Account ID',
|
||||
},
|
||||
{
|
||||
key: 'whatsapp_twilio_account_sid',
|
||||
value: '',
|
||||
type: SettingType.SECRET,
|
||||
category: SettingCategory.WHATSAPP,
|
||||
description: 'Twilio Account SID',
|
||||
isSecret: true,
|
||||
},
|
||||
{
|
||||
key: 'whatsapp_twilio_auth_token',
|
||||
value: '',
|
||||
type: SettingType.SECRET,
|
||||
category: SettingCategory.WHATSAPP,
|
||||
description: 'Twilio Auth Token',
|
||||
isSecret: true,
|
||||
},
|
||||
{
|
||||
key: 'whatsapp_twilio_phone_number',
|
||||
value: '',
|
||||
type: SettingType.STRING,
|
||||
category: SettingCategory.WHATSAPP,
|
||||
description: 'Twilio WhatsApp Phone Number',
|
||||
},
|
||||
// OpenAI API Key (Phase 2)
|
||||
{
|
||||
key: 'openai_api_key',
|
||||
value: '',
|
||||
type: SettingType.SECRET,
|
||||
category: SettingCategory.AI,
|
||||
description: 'OpenAI API Key for AI-powered features',
|
||||
isSecret: true,
|
||||
},
|
||||
]
|
||||
|
||||
for (const setting of settings) {
|
||||
await prisma.systemSettings.upsert({
|
||||
where: { key: setting.key },
|
||||
update: {},
|
||||
create: setting,
|
||||
})
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Create Super Admin
|
||||
// ==========================================================================
|
||||
console.log('👤 Creating super admin...')
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'matt.ciaccio@gmail.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'matt.ciaccio@gmail.com',
|
||||
name: 'Matt Ciaccio',
|
||||
role: UserRole.SUPER_ADMIN,
|
||||
status: UserStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(` Created admin: ${admin.email}`)
|
||||
|
||||
// ==========================================================================
|
||||
// Create Sample Program
|
||||
// ==========================================================================
|
||||
console.log('📁 Creating sample program...')
|
||||
|
||||
const program = await prisma.program.upsert({
|
||||
where: { name_year: { name: 'Monaco Ocean Protection Challenge', year: 2026 } },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Monaco Ocean Protection Challenge',
|
||||
year: 2026,
|
||||
status: ProgramStatus.ACTIVE,
|
||||
description: 'Annual ocean conservation startup competition supporting innovative solutions for ocean protection.',
|
||||
},
|
||||
})
|
||||
|
||||
console.log(` Created program: ${program.name} ${program.year}`)
|
||||
|
||||
// ==========================================================================
|
||||
// Create Round 1
|
||||
// ==========================================================================
|
||||
console.log('🔄 Creating Round 1...')
|
||||
|
||||
const round1 = await prisma.round.upsert({
|
||||
where: {
|
||||
id: 'round-1-2026', // Use a deterministic ID for upsert
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
id: 'round-1-2026',
|
||||
programId: program.id,
|
||||
name: 'Round 1 - Semi-Finalists Selection',
|
||||
status: RoundStatus.DRAFT,
|
||||
requiredReviews: 3,
|
||||
votingStartAt: new Date('2026-02-18T09:00:00Z'),
|
||||
votingEndAt: new Date('2026-02-23T18:00:00Z'),
|
||||
settingsJson: {
|
||||
allowGracePeriods: true,
|
||||
showAggregatesAfterClose: true,
|
||||
juryCanSeeOwnPastEvaluations: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log(` Created round: ${round1.name}`)
|
||||
|
||||
// ==========================================================================
|
||||
// Create Evaluation Form for Round 1
|
||||
// ==========================================================================
|
||||
console.log('📝 Creating evaluation form...')
|
||||
|
||||
await prisma.evaluationForm.upsert({
|
||||
where: {
|
||||
roundId_version: {
|
||||
roundId: round1.id,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
roundId: round1.id,
|
||||
version: 1,
|
||||
isActive: true,
|
||||
criteriaJson: [
|
||||
{
|
||||
id: 'need_clarity',
|
||||
label: 'Need Clarity',
|
||||
description: 'How clearly is the problem/need articulated?',
|
||||
scale: '1-5',
|
||||
weight: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'solution_relevance',
|
||||
label: 'Solution Relevance',
|
||||
description: 'How relevant and innovative is the proposed solution?',
|
||||
scale: '1-5',
|
||||
weight: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'gap_analysis',
|
||||
label: 'Gap Analysis',
|
||||
description: 'How well does the project analyze existing gaps in the market?',
|
||||
scale: '1-5',
|
||||
weight: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'target_customers',
|
||||
label: 'Target Customer Clarity',
|
||||
description: 'How clearly are target customers/beneficiaries defined?',
|
||||
scale: '1-5',
|
||||
weight: 1,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'ocean_impact',
|
||||
label: 'Ocean Impact',
|
||||
description: 'What is the potential positive impact on ocean conservation?',
|
||||
scale: '1-5',
|
||||
weight: 2, // Higher weight for ocean impact
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
scalesJson: {
|
||||
'1-5': {
|
||||
min: 1,
|
||||
max: 5,
|
||||
labels: {
|
||||
1: 'Poor',
|
||||
2: 'Below Average',
|
||||
3: 'Average',
|
||||
4: 'Good',
|
||||
5: 'Excellent',
|
||||
},
|
||||
},
|
||||
'1-10': {
|
||||
min: 1,
|
||||
max: 10,
|
||||
labels: {
|
||||
1: 'Poor',
|
||||
5: 'Average',
|
||||
10: 'Excellent',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log(' Created evaluation form v1')
|
||||
|
||||
// ==========================================================================
|
||||
// Create Sample Jury Members
|
||||
// ==========================================================================
|
||||
console.log('👥 Creating sample jury members...')
|
||||
|
||||
const juryMembers = [
|
||||
{
|
||||
email: 'jury1@example.com',
|
||||
name: 'Dr. Marine Expert',
|
||||
expertiseTags: ['marine_biology', 'conservation', 'policy'],
|
||||
},
|
||||
{
|
||||
email: 'jury2@example.com',
|
||||
name: 'Tech Innovator',
|
||||
expertiseTags: ['technology', 'innovation', 'startups'],
|
||||
},
|
||||
{
|
||||
email: 'jury3@example.com',
|
||||
name: 'Ocean Advocate',
|
||||
expertiseTags: ['conservation', 'sustainability', 'education'],
|
||||
},
|
||||
]
|
||||
|
||||
for (const jury of juryMembers) {
|
||||
await prisma.user.upsert({
|
||||
where: { email: jury.email },
|
||||
update: {},
|
||||
create: {
|
||||
email: jury.email,
|
||||
name: jury.name,
|
||||
role: UserRole.JURY_MEMBER,
|
||||
status: UserStatus.INVITED,
|
||||
expertiseTags: jury.expertiseTags,
|
||||
maxAssignments: 15,
|
||||
},
|
||||
})
|
||||
console.log(` Created jury member: ${jury.email}`)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Create Sample Projects
|
||||
// ==========================================================================
|
||||
console.log('📦 Creating sample projects...')
|
||||
|
||||
const sampleProjects = [
|
||||
{
|
||||
title: 'OceanAI - Plastic Detection System',
|
||||
teamName: 'BlueWave Tech',
|
||||
description: 'AI-powered system using satellite imagery and drones to detect and map ocean plastic concentrations for targeted cleanup operations.',
|
||||
tags: ['technology', 'ai', 'plastic_pollution'],
|
||||
},
|
||||
{
|
||||
title: 'Coral Restoration Network',
|
||||
teamName: 'ReefGuard Foundation',
|
||||
description: 'Community-driven coral nursery and transplantation program using innovative 3D-printed substrates.',
|
||||
tags: ['conservation', 'coral', 'community'],
|
||||
},
|
||||
{
|
||||
title: 'SeaTrack - Sustainable Fishing Tracker',
|
||||
teamName: 'FishRight Solutions',
|
||||
description: 'Blockchain-based supply chain tracking system ensuring sustainable fishing practices from ocean to table.',
|
||||
tags: ['technology', 'sustainable_fishing', 'blockchain'],
|
||||
},
|
||||
]
|
||||
|
||||
for (const project of sampleProjects) {
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
roundId: round1.id,
|
||||
title: project.title,
|
||||
teamName: project.teamName,
|
||||
description: project.description,
|
||||
tags: project.tags,
|
||||
},
|
||||
})
|
||||
console.log(` Created project: ${project.title}`)
|
||||
}
|
||||
|
||||
console.log('')
|
||||
console.log('✅ Seeding completed successfully!')
|
||||
console.log('')
|
||||
console.log('📧 Admin login: matt.ciaccio@gmail.com')
|
||||
console.log(' (Use magic link authentication)')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Seeding failed:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
1
prisma/set-admin-pw.sql
Normal file
1
prisma/set-admin-pw.sql
Normal file
@@ -0,0 +1 @@
|
||||
UPDATE "User" SET "passwordHash" = '$2b$12$W79XaxCcUvrSFDg6rY7/8ebMFZD7RsD1OSHYvIUeftzZL9blvTI8q', "mustSetPassword" = false WHERE email = 'admin@monaco-opc.com';
|
||||
Reference in New Issue
Block a user