702 lines
19 KiB
Markdown
702 lines
19 KiB
Markdown
# 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
|
|
// 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:
|
|
|
|
```sql
|
|
-- 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
|
|
```bash
|
|
# Push schema changes directly (no migration files)
|
|
npx prisma db push
|
|
|
|
# Generate client after schema changes
|
|
npx prisma generate
|
|
```
|
|
|
|
### Production
|
|
```bash
|
|
# 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
|
|
|
|
```typescript
|
|
// 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:
|
|
```bash
|
|
npx prisma db seed
|
|
```
|
|
|
|
## Query Patterns
|
|
|
|
### Get Jury's Assigned Projects
|
|
```typescript
|
|
const assignments = await prisma.assignment.findMany({
|
|
where: {
|
|
userId: currentUser.id,
|
|
round: {
|
|
status: 'ACTIVE',
|
|
},
|
|
},
|
|
include: {
|
|
project: {
|
|
include: {
|
|
files: true,
|
|
},
|
|
},
|
|
evaluation: true,
|
|
},
|
|
})
|
|
```
|
|
|
|
### Get Project Evaluation Stats
|
|
```typescript
|
|
const stats = await prisma.evaluation.groupBy({
|
|
by: ['status'],
|
|
where: {
|
|
assignment: {
|
|
projectId: projectId,
|
|
},
|
|
},
|
|
_count: true,
|
|
})
|
|
```
|
|
|
|
### Check Voting Window
|
|
```typescript
|
|
const isVotingOpen = await prisma.round.findFirst({
|
|
where: {
|
|
id: roundId,
|
|
status: 'ACTIVE',
|
|
votingStartAt: { lte: new Date() },
|
|
votingEndAt: { gte: new Date() },
|
|
},
|
|
})
|
|
```
|
|
|
|
## Backup Strategy
|
|
|
|
### Automated Backups
|
|
```bash
|
|
# 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
|
|
```bash
|
|
docker exec -t mopc-postgres pg_dump -U mopc mopc > backup.sql
|
|
```
|
|
|
|
### Restore
|
|
```bash
|
|
docker exec -i mopc-postgres psql -U mopc mopc < backup.sql
|
|
```
|
|
|
|
## Related Documentation
|
|
|
|
- [API Design](./api.md) - How the database is accessed via tRPC
|
|
- [Infrastructure](./infrastructure.md) - PostgreSQL deployment
|