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:
701
docs/architecture/database.md
Normal file
701
docs/architecture/database.md
Normal file
@@ -0,0 +1,701 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user