MOPC-App/docs/architecture/database.md

702 lines
19 KiB
Markdown
Raw Normal View History

# 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