MOPC-App/docs/architecture/database.md

19 KiB

MOPC Platform - Database Design

Overview

The MOPC platform uses PostgreSQL as its primary database, accessed via Prisma ORM. The schema is designed for:

  1. Type Safety: Prisma generates TypeScript types from the schema
  2. Extensibility: JSON fields allow future attributes without migrations
  3. Auditability: All significant changes are logged
  4. Performance: Strategic indexes for common query patterns

Entity Relationship Diagram

┌─────────────┐
│   Program   │
│─────────────│
│ id          │
│ name        │
│ year        │
│ status      │
└──────┬──────┘
       │ 1:N
       ▼
┌─────────────┐        ┌─────────────────┐
│    Round    │        │ EvaluationForm  │
│─────────────│        │─────────────────│
│ id          │◄──────►│ id              │
│ programId   │  1:N   │ roundId         │
│ name        │        │ version         │
│ status      │        │ criteriaJson    │
│ votingStart │        │ scalesJson      │
│ votingEnd   │        └─────────────────┘
│ settings    │
└──────┬──────┘
       │ 1:N
       ▼
┌─────────────┐        ┌─────────────────┐
│   Project   │        │   ProjectFile   │
│─────────────│        │─────────────────│
│ id          │◄──────►│ id              │
│ roundId     │  1:N   │ projectId       │
│ title       │        │ fileType        │
│ teamName    │        │ bucket          │
│ description │        │ objectKey       │
│ status      │        │ mimeType        │
│ tags[]      │        │ size            │
│ metadata    │        └─────────────────┘
└──────┬──────┘
       │
       │ N:M (via Assignment)
       │
┌──────┴──────┐
│ Assignment  │
│─────────────│
│ id          │
│ userId      │◄─────────┐
│ projectId   │          │
│ roundId     │          │
│ method      │          │
│ completed   │          │
└──────┬──────┘          │
       │ 1:1             │
       ▼                 │
┌─────────────┐    ┌─────┴───────┐
│ Evaluation  │    │    User     │
│─────────────│    │─────────────│
│ id          │    │ id          │
│ assignmentId│    │ email       │
│ status      │    │ name        │
│ scores      │    │ role        │
│ globalScore │    │ status      │
│ decision    │    │ expertise[] │
│ feedback    │    │ metadata    │
└─────────────┘    └─────────────┘

                   ┌─────────────┐
                   │  AuditLog   │
                   │─────────────│
                   │ id          │
                   │ userId      │
                   │ action      │
                   │ entityType  │
                   │ entityId    │
                   │ details     │
                   │ ipAddress   │
                   │ timestamp   │
                   └─────────────┘

┌─────────────────────┐
│   SystemSettings    │
│─────────────────────│
│ id                  │
│ key                 │
│ value               │
│ type                │
│ category            │
│ description         │
│ updatedAt           │
│ updatedBy           │
└─────────────────────┘

┌─────────────────────┐
│   GracePeriod       │
│─────────────────────│
│ id                  │
│ roundId             │
│ userId              │
│ projectId (opt)     │
│ extendedUntil       │
│ reason              │
│ grantedBy           │
│ createdAt           │
└─────────────────────┘

Prisma Schema

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// =============================================================================
// ENUMS
// =============================================================================

enum UserRole {
  SUPER_ADMIN
  PROGRAM_ADMIN
  JURY_MEMBER
  OBSERVER
}

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
  AUTO
  BULK
}

enum FileType {
  EXEC_SUMMARY
  PRESENTATION
  VIDEO
  OTHER
}

// =============================================================================
// USERS & AUTHENTICATION
// =============================================================================

model User {
  id            String      @id @default(cuid())
  email         String      @unique
  name          String?
  role          UserRole    @default(JURY_MEMBER)
  status        UserStatus  @default(INVITED)
  expertiseTags String[]    @default([])
  metadataJson  Json?       @db.JsonB

  createdAt     DateTime    @default(now())
  updatedAt     DateTime    @updatedAt
  lastLoginAt   DateTime?

  // Relations
  assignments   Assignment[]
  auditLogs     AuditLog[]

  // Indexes
  @@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?

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
}

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[]

  // Indexes
  @@unique([name, year])
  @@index([status])
}

model Round {
  id               String      @id @default(cuid())
  programId        String
  name             String      // e.g., "Round 1 - Semi-Finalists"
  status           RoundStatus @default(DRAFT)

  // 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[]

  // Indexes
  @@index([programId])
  @@index([status])
  @@index([votingStartAt, votingEndAt])
}

model EvaluationForm {
  id           String   @id @default(cuid())
  roundId      String
  version      Int      @default(1)

  // Form configuration
  criteriaJson Json     @db.JsonB  // Array of criteria with labels, scales
  scalesJson   Json?    @db.JsonB  // Scale definitions (1-5, 1-10, etc.)
  isActive     Boolean  @default(false)

  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  // Relations
  round        Round    @relation(fields: [roundId], references: [id], onDelete: Cascade)
  evaluations  Evaluation[]

  // Indexes
  @@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)

  // 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[]

  // Indexes
  @@index([roundId])
  @@index([status])
  @@index([tags])
}

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)

  // Indexes
  @@index([projectId])
  @@index([fileType])
  @@unique([bucket, objectKey])
}

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

  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?

  // Constraints
  @@unique([userId, projectId, roundId])

  // Indexes
  @@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 Json?         @db.JsonB  // { "criterion1": 4, "criterion2": 5 }
  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])

  // Indexes
  @@index([status])
  @@index([submittedAt])
}

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

  // Indexes
  @@index([userId])
  @@index([action])
  @@index([entityType, entityId])
  @@index([timestamp])
}

Indexing Strategy

Primary Indexes (Automatic)

  • All @id fields have primary key indexes
  • All @unique constraints have unique indexes

Query-Optimized Indexes

Table Index Purpose
User email Login lookup
User role Filter by role
User status Filter active users
Program status List active programs
Round programId Get rounds for program
Round status Filter active rounds
Round votingStartAt, votingEndAt Check voting window
Project roundId Get projects in round
Project status Filter by status
Project tags Filter by tag (GIN index)
Assignment userId Get user's assignments
Assignment projectId Get project's reviewers
Assignment roundId Get all assignments for round
Assignment isCompleted Track progress
Evaluation status Filter by completion
Evaluation submittedAt Sort by submission time
AuditLog timestamp Time-based queries
AuditLog entityType, entityId Entity history

JSON Field Indexes

For PostgreSQL JSONB fields, we can add GIN indexes for complex queries:

-- Add via migration if needed
CREATE INDEX idx_project_metadata ON "Project" USING GIN ("metadataJson");
CREATE INDEX idx_evaluation_scores ON "Evaluation" USING GIN ("criterionScoresJson");

Migration Strategy

Development

# Push schema changes directly (no migration files)
npx prisma db push

# Generate client after schema changes
npx prisma generate

Production

# Create migration from schema changes
npx prisma migrate dev --name description_of_change

# Apply migrations in production
npx prisma migrate deploy

Migration Best Practices

  1. Never use db push in production
  2. Always backup before migrations
  3. Test migrations on staging first
  4. Review generated SQL before applying
  5. Keep migrations small and focused

Data Seeding

// prisma/seed.ts

import { PrismaClient, UserRole, ProgramStatus, RoundStatus } from '@prisma/client'

const prisma = new PrismaClient()

async function main() {
  // Create super admin
  const admin = await prisma.user.upsert({
    where: { email: 'admin@mopc.org' },
    update: {},
    create: {
      email: 'admin@mopc.org',
      name: 'System Admin',
      role: UserRole.SUPER_ADMIN,
      status: 'ACTIVE',
    },
  })

  // Create 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',
    },
  })

  // Create Round 1
  const round1 = await prisma.round.create({
    data: {
      programId: program.id,
      name: 'Round 1 - Semi-Finalists Selection',
      status: RoundStatus.DRAFT,
      requiredReviews: 3,
      votingStartAt: new Date('2026-02-18'),
      votingEndAt: new Date('2026-02-23'),
    },
  })

  // Create evaluation form for Round 1
  await prisma.evaluationForm.create({
    data: {
      roundId: round1.id,
      version: 1,
      isActive: true,
      criteriaJson: [
        { id: 'need_clarity', label: 'Need clarity', scale: '1-5', weight: 1 },
        { id: 'solution_relevance', label: 'Solution relevance', scale: '1-5', weight: 1 },
        { id: 'gap_analysis', label: 'Gap analysis', scale: '1-5', weight: 1 },
        { id: 'target_customers', label: 'Target customers clarity', scale: '1-5', weight: 1 },
        { id: 'ocean_impact', label: 'Ocean impact', scale: '1-5', weight: 1 },
      ],
      scalesJson: {
        '1-5': { min: 1, max: 5, labels: { 1: 'Poor', 3: 'Average', 5: 'Excellent' } },
        '1-10': { min: 1, max: 10, labels: { 1: 'Poor', 5: 'Average', 10: 'Excellent' } },
      },
    },
  })

  console.log('Seed completed')
}

main()
  .catch((e) => {
    console.error(e)
    process.exit(1)
  })
  .finally(async () => {
    await prisma.$disconnect()
  })

Run seed:

npx prisma db seed

Query Patterns

Get Jury's Assigned Projects

const assignments = await prisma.assignment.findMany({
  where: {
    userId: currentUser.id,
    round: {
      status: 'ACTIVE',
    },
  },
  include: {
    project: {
      include: {
        files: true,
      },
    },
    evaluation: true,
  },
})

Get Project Evaluation Stats

const stats = await prisma.evaluation.groupBy({
  by: ['status'],
  where: {
    assignment: {
      projectId: projectId,
    },
  },
  _count: true,
})

Check Voting Window

const isVotingOpen = await prisma.round.findFirst({
  where: {
    id: roundId,
    status: 'ACTIVE',
    votingStartAt: { lte: new Date() },
    votingEndAt: { gte: new Date() },
  },
})

Backup Strategy

Automated Backups

# Daily backup cron job
0 2 * * * pg_dump -h localhost -U mopc -d mopc -F c -f /backups/mopc_$(date +\%Y\%m\%d).dump

# Keep last 30 days
find /backups -name "mopc_*.dump" -mtime +30 -delete

Manual Backup

docker exec -t mopc-postgres pg_dump -U mopc mopc > backup.sql

Restore

docker exec -i mopc-postgres psql -U mopc mopc < backup.sql