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:
2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

374
docs/architecture/README.md Normal file
View File

@@ -0,0 +1,374 @@
# MOPC Platform - Architecture Overview
## System Overview
The MOPC Platform is a secure jury voting system built as a modern full-stack TypeScript application for the **Monaco Ocean Protection Challenge**. It follows a layered architecture with clear separation of concerns.
**Phase 1 Focus**: Jury selection rounds (130→60→6 projects)
**Domain**: `monaco-opc.com`
## Finalized Decisions
| Decision | Choice |
|----------|--------|
| **Domain** | `monaco-opc.com` |
| **Evaluation Criteria** | Fully configurable per round (admin defines) |
| **CSV Import** | Flexible column mapping (admin maps columns) |
| **Max File Size** | 500MB (for videos) |
| **Observer Role** | Included in Phase 1 |
| **First Admin** | Database seed script |
| **Past Evaluations** | Visible read-only after submit |
| **Grace Period** | Admin-configurable per juror/project |
| **Smart Assignment** | AI-powered (GPT) + Smart Algorithm fallback |
| **AI Provider** | Admin-configurable (OpenAI GPT) |
| **AI Data Privacy** | All data anonymized/encoded before sending to GPT |
## Tech Stack
| Layer | Technology | Version |
|-------|-----------|---------|
| **Framework** | Next.js (App Router) | 15.x |
| **Language** | TypeScript | 5.x |
| **UI Components** | shadcn/ui | latest |
| **Styling** | Tailwind CSS | 3.x |
| **API Layer** | tRPC | 11.x |
| **Database** | PostgreSQL | 16.x |
| **ORM** | Prisma | 6.x |
| **Authentication** | NextAuth.js (Auth.js) | 5.x |
| **AI** | OpenAI GPT | 4.x SDK |
| **File Storage** | MinIO (S3-compatible) | External |
| **Email** | Nodemailer + Poste.io | External |
| **Animation** | Motion (Framer Motion) | 11.x |
| **Notifications** | Sonner | 1.x |
| **Command Palette** | cmdk | 1.x |
| **Containerization** | Docker Compose | 2.x |
| **Reverse Proxy** | Nginx | External |
## Brand Identity
### Colors
| Name | Hex | Usage |
|------|-----|-------|
| Primary Red | `#de0f1e` | Accents, CTAs, alerts |
| Dark Blue | `#053d57` | Headers, sidebar, primary text |
| White | `#fefefe` | Backgrounds |
| Teal | `#557f8c` | Secondary elements, links |
### Typography
- **Headings**: Montserrat (600/700 weight)
- **Body**: Montserrat Light (300/400 weight)
## High-Level Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Admin Views │ │ Jury Views │ │ Auth Views │ │
│ │ │ │ │ │ │ │
│ │ - Dashboard │ │ - Project List │ │ - Login │ │
│ │ - Rounds │ │ - Project View │ │ - Magic Link │ │
│ │ - Projects │ │ - Evaluation │ │ - Verify │ │
│ │ - Jury Mgmt │ │ - My Progress │ │ │ │
│ │ - Assignments │ │ │ │ │ │
│ │ - Reports │ │ │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
│ Built with: Next.js App Router + React Server Components + shadcn/ui │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ API LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ tRPC Router │ │
│ │ │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │program │ │ round │ │project │ │ user │ │assign- │ │evalua- │ │ │
│ │ │Router │ │Router │ │Router │ │Router │ │ment │ │tion │ │ │
│ │ │ │ │ │ │ │ │ │ │Router │ │Router │ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Auth Middleware │ │ RBAC Middleware │ │ Audit Logger │ │
│ │ (NextAuth.js) │ │ (role checks) │ │ (all actions) │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ SERVICE LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Program │ │ Round │ │ Assignment │ │ Evaluation │ │
│ │ Service │ │ Service │ │ Service │ │ Service │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ File │ │ Email │ │ Export │ │ Audit │ │
│ │ Service │ │ Service │ │ Service │ │ Service │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ DATA LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Prisma ORM │ │
│ │ │ │
│ │ Type-safe database access, migrations, query building │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ PostgreSQL │ │ MinIO │ │
│ │ (Database) │ │ (File Store) │ │
│ │ │ │ │ │
│ │ - Users │ │ - PDFs │ │
│ │ - Programs │ │ - Videos │ │
│ │ - Rounds │ │ - Exports │ │
│ │ - Projects │ │ │ │
│ │ - Evaluations │ │ │ │
│ │ - Audit Logs │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Component Responsibilities
### Presentation Layer
| Component | Responsibility |
|-----------|----------------|
| **Admin Views** | Program/round management, project import, jury management, assignments, dashboards |
| **Jury Views** | View assigned projects, evaluate projects, track progress |
| **Auth Views** | Login, magic link verification, session management |
| **Layouts** | Responsive navigation, sidebar, mobile adaptations |
| **UI Components** | shadcn/ui based, reusable, accessible |
### API Layer
| Component | Responsibility |
|-----------|----------------|
| **tRPC Routers** | Type-safe API endpoints grouped by domain |
| **Auth Middleware** | Session validation via NextAuth.js |
| **RBAC Middleware** | Role-based access control enforcement |
| **Audit Logger** | Record all significant actions |
| **Validators** | Zod schemas for input validation |
### Service Layer
| Service | Responsibility |
|---------|----------------|
| **ProgramService** | CRUD for programs (e.g., "MOPC 2026") |
| **RoundService** | Round lifecycle, voting windows, form versions |
| **AssignmentService** | Jury-project assignments, load balancing |
| **EvaluationService** | Form submission, autosave, scoring |
| **FileService** | MinIO uploads, pre-signed URLs |
| **EmailService** | Magic links, notifications via Nodemailer |
| **ExportService** | CSV/Excel generation |
| **AuditService** | Immutable event logging |
### Data Layer
| Component | Responsibility |
|-----------|----------------|
| **Prisma Client** | Type-safe database queries |
| **PostgreSQL** | Primary data store (relational) |
| **MinIO** | S3-compatible file storage |
| **Migrations** | Schema versioning and evolution |
## Data Flow Examples
### 1. Jury Login Flow
```
User Next.js NextAuth PostgreSQL
│ │ │ │
│ 1. Enter email │ │ │
│───────────────────────>│ │ │
│ │ 2. Request magic link │ │
│ │───────────────────────>│ │
│ │ │ 3. Store token │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ │ 4. Send email │ │
│ │<───────────────────────│ │
│ 5. Email with link │ │ │
│<───────────────────────│ │ │
│ │ │ │
│ 6. Click link │ │ │
│───────────────────────>│ │ │
│ │ 7. Verify token │ │
│ │───────────────────────>│ │
│ │ │ 8. Validate │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ │ 9. Create session │ │
│ │<───────────────────────│ │
│ 10. Redirect to dash │ │ │
│<───────────────────────│ │ │
```
### 2. Jury Evaluation Flow
```
Jury Member Next.js/tRPC Service Layer PostgreSQL/MinIO
│ │ │ │
│ 1. View project list │ │ │
│───────────────────────>│ 2. project.listAssigned() │
│ │───────────────────────>│ 3. Query assignments │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ 4. Show assigned projects │ │
│<───────────────────────│ │ │
│ │ │ │
│ 5. Open project │ │ │
│───────────────────────>│ 6. project.getDetails() │
│ │───────────────────────>│ 7. Get project + files│
│ │ │───────────────────────>│
│ │ │ 8. Generate pre-signed URLs
│ │ │<───────────────────────│
│ 9. Show project with file links │ │
│<───────────────────────│ │ │
│ │ │ │
│ 10. Fill evaluation │ │ │
│ (typing...) │ 11. evaluation.autosave() (debounced) │
│───────────────────────>│───────────────────────>│ 12. Save draft │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ 13. Autosaved indicator │ │
│<───────────────────────│ │ │
│ │ │ │
│ 14. Submit final │ │ │
│───────────────────────>│ 15. evaluation.submit() │
│ │───────────────────────>│ 16. Validate window │
│ │ │ 17. Lock evaluation │
│ │ │ 18. Log audit event │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ 19. Success, mark complete │ │
│<───────────────────────│ │ │
```
### 3. Admin Export Flow
```
Admin Next.js/tRPC Service Layer PostgreSQL/MinIO
│ │ │ │
│ 1. Request CSV export │ │ │
│───────────────────────>│ 2. export.generateCSV() │
│ │───────────────────────>│ 3. Query all evals │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ │ │ 4. Build CSV │
│ │ │ 5. Upload to MinIO │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ │ │ 6. Log audit event │
│ │ │───────────────────────>│
│ │ 7. Return download URL│ │
│ │<───────────────────────│ │
│ 8. Download file │ │ │
│<───────────────────────│ │ │
```
## Security Architecture
### Authentication
- **Method**: Email magic links (passwordless)
- **Sessions**: JWT stored in HTTP-only cookies
- **Provider**: NextAuth.js with custom email provider
### Authorization (RBAC)
- **Enforcement**: tRPC middleware checks role before procedure
- **Granularity**: Role + resource-level (jury sees only assigned projects)
- **Storage**: User.role field in database
### Data Security
- **File Access**: Pre-signed URLs with short TTL (15 min)
- **SQL Injection**: Prevented by Prisma parameterized queries
- **XSS**: React's built-in escaping + CSP headers
- **CSRF**: NextAuth.js built-in protection
### Audit Trail
- **Coverage**: All admin actions, all state changes
- **Immutability**: Append-only audit_logs table
- **Fields**: user, action, entity, details, timestamp, IP
## Scalability Considerations
### Current Design (Phase 1)
- Single PostgreSQL instance (sufficient for ~200 concurrent users)
- Single Next.js instance behind Nginx
- MinIO for file storage (horizontally scalable)
### Future Scale Path
1. **Database**: Read replicas for dashboards, connection pooling (PgBouncer)
2. **Application**: Multiple Next.js instances behind load balancer
3. **Caching**: Redis for session storage and query caching
4. **CDN**: Static assets via CDN
5. **Background Jobs**: BullMQ for email queues, exports
## Smart Assignment System
The platform includes two assignment modes:
### 1. AI-Powered Assignment (GPT)
- Analyzes juror expertise and project tags
- Optimizes for balanced workload
- Respects organizational conflicts
- **Privacy**: All data is anonymized before sending to GPT
- Names → `JUROR_A`, `JUROR_B`
- Projects → `PROJECT_1`, `PROJECT_2`
- Organizations → `ORG_X`, `ORG_Y`
- Emails and personal details are never sent
### 2. Smart Algorithm (Rule-Based Fallback)
- Fully featured scoring algorithm
- No external API required
- Handles 200 projects × 50 jurors in < 1 second
- Deterministic results
**Scoring Formula**:
```
Score = (expertise_match × 40) + (load_balance × 25) +
(specialty_match × 20) + (diversity × 10) - (conflict × 100)
```
## Admin Settings Panel
Centralized configuration for:
- **AI Configuration**: Provider, API key, model, budget limits
- **Platform Branding**: Logo, colors, name
- **Email/SMTP**: Server, credentials, templates
- **File Storage**: MinIO endpoint, bucket, limits
- **Security**: Session duration, rate limits
- **Defaults**: Timezone, pagination, autosave interval
## User Roles (RBAC)
| Role | Permissions |
|------|------------|
| **SUPER_ADMIN** | Full system access, all programs, user management |
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
| **JURY_MEMBER** | View assigned projects only, submit evaluations |
| **OBSERVER** | Read-only access to dashboards |
## Related Documentation
- [Database Design](./database.md) - Schema, relationships, indexes
- [API Design](./api.md) - tRPC routers, endpoints, auth flow
- [Infrastructure](./infrastructure.md) - Docker, Nginx, deployment
- [UI/UX Patterns](./ui.md) - Components, responsive design

1138
docs/architecture/api.md Normal file

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -0,0 +1,651 @@
# MOPC Platform - Infrastructure
## Overview
The MOPC platform is self-hosted on a Linux VPS at **monaco-opc.com** with the following architecture:
- **Nginx** (host-level) - Reverse proxy with SSL termination
- **Docker Compose** (MOPC stack) - Next.js + PostgreSQL
- **MinIO** (separate stack) - S3-compatible file storage
- **Poste.io** (separate stack) - Self-hosted email server
**Key Configurations:**
- Max file size: 500MB (for video uploads)
- SSL via Certbot (Let's Encrypt)
## Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ INTERNET │
└─────────────────────────────────┬────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ LINUX VPS │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ NGINX (Host Level) │ │
│ │ │ │
│ │ - SSL termination via Certbot │ │
│ │ - Reverse proxy to Docker services │ │
│ │ - Rate limiting │ │
│ │ - Security headers │ │
│ │ │ │
│ │ Ports: 80 (HTTP → HTTPS redirect), 443 (HTTPS) │ │
│ └─────────────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────┼───────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ MOPC Stack │ │ MinIO Stack │ │ Poste.io Stack │ │
│ │ (Docker Compose)│ │ (Docker Compose)│ │ (Docker Compose)│ │
│ │ │ │ │ │ │ │
│ │ ┌────────────┐ │ │ ┌────────────┐ │ │ ┌────────────┐ │ │
│ │ │ Next.js │ │ │ │ MinIO │ │ │ │ Poste.io │ │ │
│ │ │ :3000 │ │ │ │ :9000 │ │ │ │ :25,587 │ │ │
│ │ └────────────┘ │ │ │ :9001 │ │ │ └────────────┘ │ │
│ │ │ │ └────────────┘ │ │ │ │
│ │ ┌────────────┐ │ │ │ │ │ │
│ │ │ PostgreSQL │ │ │ │ │ │ │
│ │ │ :5432 │ │ │ │ │ │ │
│ │ └────────────┘ │ │ │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
│ Data Volumes: │
│ /data/mopc/postgres /data/minio /data/poste │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
## Docker Compose Configuration
### MOPC Stack
```yaml
# docker/docker-compose.yml
version: '3.8'
services:
app:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: mopc-app
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- MINIO_BUCKET=${MINIO_BUCKET}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM}
depends_on:
postgres:
condition: service_healthy
networks:
- mopc-network
postgres:
image: postgres:16-alpine
container_name: mopc-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=mopc
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=mopc
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mopc"]
interval: 10s
timeout: 5s
retries: 5
networks:
- mopc-network
volumes:
postgres_data:
driver: local
driver_opts:
type: none
o: bind
device: /data/mopc/postgres
networks:
mopc-network:
driver: bridge
```
### Development Stack
The development stack includes PostgreSQL, MinIO, and the Next.js app running in Docker containers.
```yaml
# docker/docker-compose.dev.yml
services:
postgres:
image: postgres:16-alpine
container_name: mopc-postgres-dev
ports:
- "5432:5432"
environment:
- POSTGRES_USER=mopc
- POSTGRES_PASSWORD=devpassword
- POSTGRES_DB=mopc
volumes:
- postgres_dev_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mopc"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio
container_name: mopc-minio-dev
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
volumes:
- minio_dev_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# MinIO client to create default bucket on startup
createbuckets:
image: minio/mc
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set myminio http://minio:9000 minioadmin minioadmin;
mc mb --ignore-existing myminio/mopc-files;
mc anonymous set download myminio/mopc-files;
echo 'Bucket created successfully';
"
# Next.js application
app:
build:
context: ..
dockerfile: docker/Dockerfile.dev
container_name: mopc-app-dev
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://mopc:devpassword@postgres:5432/mopc
- NEXTAUTH_URL=http://localhost:3000
- NEXTAUTH_SECRET=dev-secret-key-for-local-development-only
- MINIO_ENDPOINT=http://minio:9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin
- MINIO_BUCKET=mopc-files
- NODE_ENV=development
volumes:
- ../src:/app/src
- ../public:/app/public
- ../prisma:/app/prisma
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
volumes:
postgres_dev_data:
minio_dev_data:
```
### Quick Start (Development)
```bash
# 1. Start all services (PostgreSQL, MinIO, Next.js)
docker compose -f docker/docker-compose.dev.yml up --build -d
# 2. Push database schema
docker exec mopc-app-dev npx prisma db push
# 3. Seed test data
docker exec mopc-app-dev npx tsx prisma/seed.ts
# 4. Open http://localhost:3000
# Login with: admin@monaco-opc.com (magic link)
```
### Development URLs
| Service | URL | Credentials |
|---------|-----|-------------|
| Next.js App | http://localhost:3000 | See seed data |
| MinIO Console | http://localhost:9001 | minioadmin / minioadmin |
| PostgreSQL | localhost:5432 | mopc / devpassword |
### Test Accounts (after seeding)
| Role | Email |
|------|-------|
| Super Admin | admin@monaco-opc.com |
| Jury Member | jury1@example.com |
| Jury Member | jury2@example.com |
| Jury Member | jury3@example.com |
## Dockerfile
```dockerfile
# docker/Dockerfile
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build Next.js
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
```
## Nginx Configuration
```nginx
# /etc/nginx/sites-available/mopc-platform
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=mopc_limit:10m rate=10r/s;
# MOPC Platform
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name monaco-opc.com;
# SSL certificates (managed by Certbot)
ssl_certificate /etc/letsencrypt/live/monaco-opc.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/monaco-opc.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';" always;
# File upload size (500MB for videos)
client_max_body_size 500M;
# Rate limiting
limit_req zone=mopc_limit burst=20 nodelay;
# Logging
access_log /var/log/nginx/mopc-access.log;
error_log /var/log/nginx/mopc-error.log;
# Next.js application
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Timeouts for large file uploads
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# Static files caching
location /_next/static {
proxy_pass http://127.0.0.1:3000;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Health check endpoint
location /api/health {
proxy_pass http://127.0.0.1:3000;
access_log off;
}
}
# HTTP to HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name monaco-opc.com;
return 301 https://$host$request_uri;
}
```
## SSL Setup with Certbot
```bash
# Install Certbot
sudo apt update
sudo apt install certbot python3-certbot-nginx
# Obtain certificate
sudo certbot --nginx -d monaco-opc.com
# Auto-renewal is configured automatically
# Test renewal
sudo certbot renew --dry-run
```
## Environment Variables
### Production (.env)
```env
# Application
NODE_ENV=production
NEXTAUTH_URL=https://monaco-opc.com
NEXTAUTH_SECRET=generate-a-secure-random-string-here
# Database
DB_PASSWORD=your-secure-database-password
DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc
# MinIO (external stack)
MINIO_ENDPOINT=http://localhost:9000
MINIO_ACCESS_KEY=your-minio-access-key
MINIO_SECRET_KEY=your-minio-secret-key
MINIO_BUCKET=mopc-files
# Email (Poste.io)
SMTP_HOST=localhost
SMTP_PORT=587
SMTP_USER=noreply@monaco-opc.com
SMTP_PASS=your-smtp-password
EMAIL_FROM=MOPC Platform <noreply@monaco-opc.com>
```
### Generate Secrets
```bash
# Generate NEXTAUTH_SECRET
openssl rand -base64 32
# Generate DB_PASSWORD
openssl rand -base64 24
```
## Deployment Commands
### Initial Deployment
```bash
# 1. Clone repository
git clone https://github.com/your-org/mopc-platform.git /opt/mopc
cd /opt/mopc
# 2. Create environment file
cp .env.example .env
nano .env # Edit with production values
# 3. Create data directories
sudo mkdir -p /data/mopc/postgres
sudo chown -R 1000:1000 /data/mopc
# 4. Start the stack
cd docker
docker compose up -d
# 5. Run database migrations
docker compose exec app npx prisma migrate deploy
# 6. Seed initial data (optional)
docker compose exec app npx prisma db seed
# 7. Enable Nginx site
sudo ln -s /etc/nginx/sites-available/mopc-platform /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
# 8. Set up SSL
sudo certbot --nginx -d monaco-opc.com
```
### Updates
```bash
cd /opt/mopc
# 1. Pull latest code
git pull origin main
# 2. Rebuild and restart
cd docker
docker compose build app
docker compose up -d app
# 3. Run any new migrations
docker compose exec app npx prisma migrate deploy
```
### Rollback
```bash
# Revert to previous image
docker compose down
git checkout HEAD~1
docker compose build app
docker compose up -d
# Or restore from specific tag
git checkout v1.0.0
docker compose build app
docker compose up -d
```
## Backup Strategy
### Database Backups
```bash
# Create backup script
cat > /opt/mopc/scripts/backup-db.sh << 'EOF'
#!/bin/bash
BACKUP_DIR=/data/backups/mopc
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE=$BACKUP_DIR/mopc_$DATE.sql.gz
mkdir -p $BACKUP_DIR
docker exec mopc-postgres pg_dump -U mopc mopc | gzip > $BACKUP_FILE
# Keep last 30 days
find $BACKUP_DIR -name "mopc_*.sql.gz" -mtime +30 -delete
echo "Backup completed: $BACKUP_FILE"
EOF
chmod +x /opt/mopc/scripts/backup-db.sh
# Add to crontab (daily at 2 AM)
echo "0 2 * * * /opt/mopc/scripts/backup-db.sh >> /var/log/mopc-backup.log 2>&1" | sudo tee -a /etc/cron.d/mopc-backup
```
### Restore Database
```bash
# Restore from backup
gunzip < /data/backups/mopc/mopc_20260115_020000.sql.gz | docker exec -i mopc-postgres psql -U mopc mopc
```
## Monitoring
### Health Check Endpoint
```typescript
// src/app/api/health/route.ts
import { prisma } from '@/lib/prisma'
export async function GET() {
try {
// Check database connection
await prisma.$queryRaw`SELECT 1`
return Response.json({
status: 'healthy',
timestamp: new Date().toISOString(),
})
} catch (error) {
return Response.json(
{
status: 'unhealthy',
error: 'Database connection failed',
},
{ status: 503 }
)
}
}
```
### Log Viewing
```bash
# Application logs
docker compose logs -f app
# Nginx access logs
tail -f /var/log/nginx/mopc-access.log
# Nginx error logs
tail -f /var/log/nginx/mopc-error.log
# PostgreSQL logs
docker compose logs -f postgres
```
### Resource Monitoring
```bash
# Docker stats
docker stats mopc-app mopc-postgres
# System resources
htop
```
## Security Checklist
- [ ] SSL certificate active and auto-renewing
- [ ] Database password is strong and unique
- [ ] NEXTAUTH_SECRET is randomly generated
- [ ] MinIO credentials are secure
- [ ] SMTP credentials are secure
- [ ] Firewall allows only ports 80, 443, 22
- [ ] Docker daemon not exposed to network
- [ ] Regular backups configured
- [ ] Log rotation configured
- [ ] Security headers enabled in Nginx
## Troubleshooting
### Application Won't Start
```bash
# Check logs
docker compose logs app
# Check if database is ready
docker compose exec postgres pg_isready -U mopc
# Restart stack
docker compose restart
```
### Database Connection Issues
```bash
# Test connection from app container
docker compose exec app sh
nc -zv postgres 5432
# Check PostgreSQL logs
docker compose logs postgres
```
### SSL Certificate Issues
```bash
# Test certificate
sudo certbot certificates
# Force renewal
sudo certbot renew --force-renewal
# Check Nginx configuration
sudo nginx -t
```
## Related Documentation
- [Database Design](./database.md) - Schema and migrations
- [API Design](./api.md) - tRPC endpoints

749
docs/architecture/ui.md Normal file
View File

@@ -0,0 +1,749 @@
# MOPC Platform - UI/UX Architecture
## Overview
The MOPC platform uses a mobile-first responsive design built with:
- **Next.js App Router** - Server Components by default
- **shadcn/ui** - Accessible, customizable component library
- **Tailwind CSS** - Utility-first styling
- **Radix UI** - Headless accessible primitives
- **Motion** (Framer Motion) - Buttery smooth animations
- **Vaul** - Native-feeling mobile drawers
- **Sonner** - Beautiful toast notifications
- **cmdk** - Command palette (⌘K)
## Design Philosophy
**CRITICAL: Avoid "AI-built" aesthetic. Platform must look professionally designed.**
### What to AVOID (typical AI-generated look)
| Don't | Why | Instead |
|-------|-----|---------|
| Cards everywhere | Generic, lazy layout | Use varied layouts: tables, lists, grids, hero sections |
| Same border-radius on everything | Monotonous | Vary: sharp corners for data, rounded for actions |
| Identical padding/spacing | Robotic feel | Use intentional rhythm: tight for data, generous for CTAs |
| Blue/purple gradients | Screams "AI template" | Use brand colors with restraint |
| Stock icons everywhere | Impersonal | Custom icons or carefully curated set |
| Centered everything | No visual hierarchy | Left-align content, strategic centering |
| Gray backgrounds | Dull, corporate | Subtle off-white textures, strategic white space |
| "Dashboard" with 6 equal cards | The #1 AI cliché | Prioritize: hero metric, then supporting data |
### What TO DO (professional design)
| Do | Why | Example |
|----|-----|---------|
| Visual hierarchy | Guides the eye | Large numbers for KPIs, smaller for details |
| Intentional white space | Breathability | 32-48px between sections, not uniform 16px |
| Typography scale | Professional rhythm | 12/14/16/20/24/32/48px - skip sizes intentionally |
| Micro-interactions | Delight users | Button hover states, loading skeletons |
| Consistent but varied | Not monotonous | Same colors, different layouts per page |
| Data density where needed | Efficient | Tables for lists, not cards |
| Strategic color accents | Draw attention | Red only for primary CTAs, not decoration |
| Real content sizes | Accommodate reality | Long project names, international characters |
## Brand Colors
```css
:root {
/* Brand Colors */
--color-primary: #de0f1e; /* Primary Red - CTAs, alerts */
--color-primary-hover: #c00d1a;
--color-secondary: #053d57; /* Dark Blue - headers, sidebar */
--color-accent: #557f8c; /* Teal - links, secondary elements */
--color-background: #fefefe; /* White - backgrounds */
/* Semantic */
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
/* Neutrals (warm, not cold gray) */
--color-gray-50: #fafaf9;
--color-gray-100: #f5f5f4;
--color-gray-200: #e7e5e4;
--color-gray-500: #78716c;
--color-gray-900: #1c1917;
}
```
## Typography
- **Font Family**: Montserrat
- **Headings**: 600/700 weight
- **Body**: 300/400 weight (Montserrat Light)
```css
:root {
--font-family: 'Montserrat', system-ui, sans-serif;
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-lg: 1.25rem; /* 20px */
--font-size-xl: 1.5rem; /* 24px */
--font-size-2xl: 2rem; /* 32px */
--font-size-3xl: 3rem; /* 48px */
}
```
## Design Principles
1. **Mobile First**: Base styles for mobile, enhanced for larger screens
2. **Accessibility**: WCAG 2.1 AA compliance, keyboard navigation, screen reader support
3. **Performance**: Server Components, minimal client JavaScript
4. **Consistency**: Design tokens, component library, consistent patterns
5. **Feedback**: Loading states, error messages, success confirmations
## Responsive Breakpoints
```css
/* Tailwind CSS default breakpoints */
sm: 640px /* Small tablets */
md: 768px /* Tablets */
lg: 1024px /* Laptops */
xl: 1280px /* Desktops */
2xl: 1536px /* Large monitors */
```
## Layout Architecture
### Application Shell
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ DESKTOP LAYOUT │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────────────────────────────────────────────┐ │
│ │ │ │ HEADER │ │
│ │ │ │ Logo Search (optional) User Menu │ │
│ │ │ └──────────────────────────────────────────────────────┘ │
│ │ │ ┌──────────────────────────────────────────────────────┐ │
│ │ SIDEBAR │ │ │ │
│ │ │ │ │ │
│ │ - Dashboard│ │ MAIN CONTENT │ │
│ │ - Rounds │ │ │ │
│ │ - Projects │ │ │ │
│ │ - Jury │ │ │ │
│ │ - Reports │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ └─────────────┘ └──────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ MOBILE LAYOUT │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ ☰ Logo User Avatar │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ MAIN CONTENT │ │
│ │ (full width) │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 🏠 📋 📊 👤 │ │
│ │ Home Projects Reports Profile │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Layout Components
```typescript
// src/components/layouts/app-layout.tsx
import { Sidebar } from './sidebar'
import { Header } from './header'
import { MobileNav } from './mobile-nav'
export function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-background">
{/* Desktop Sidebar */}
<Sidebar className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64" />
{/* Main Content Area */}
<div className="lg:pl-64">
<Header />
<main className="p-4 lg:p-8">{children}</main>
</div>
{/* Mobile Bottom Navigation */}
<MobileNav className="fixed bottom-0 left-0 right-0 lg:hidden" />
</div>
)
}
```
## Component Hierarchy
```
src/components/
├── ui/ # shadcn/ui base components
│ ├── button.tsx
│ ├── input.tsx
│ ├── card.tsx
│ ├── table.tsx
│ ├── dialog.tsx
│ ├── dropdown-menu.tsx
│ ├── form.tsx
│ ├── select.tsx
│ ├── textarea.tsx
│ ├── badge.tsx
│ ├── progress.tsx
│ ├── skeleton.tsx
│ ├── toast.tsx
│ └── ...
├── layouts/ # Layout components
│ ├── app-layout.tsx
│ ├── auth-layout.tsx
│ ├── sidebar.tsx
│ ├── header.tsx
│ └── mobile-nav.tsx
├── forms/ # Form components
│ ├── evaluation-form.tsx
│ ├── project-import-form.tsx
│ ├── round-settings-form.tsx
│ └── user-invite-form.tsx
├── data-display/ # Data display components
│ ├── project-card.tsx
│ ├── project-list.tsx
│ ├── project-table.tsx
│ ├── evaluation-summary.tsx
│ ├── progress-tracker.tsx
│ └── stats-card.tsx
└── shared/ # Shared utility components
├── file-viewer.tsx
├── loading-state.tsx
├── error-state.tsx
├── empty-state.tsx
└── confirm-dialog.tsx
```
## Page Layouts by View
### Admin Dashboard
```
┌─────────────────────────────────────────────────────────────────┐
│ ADMIN DASHBOARD │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
│ │ Projects │ │ Evaluations │ │ Jury Active │ │ Time Left │ │
│ │ 130 │ │ 234/390 │ │ 12/15 │ │ 5 days │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ PROGRESS BY PROJECT │ │
│ │ ████████████████████████████░░░░░░░░░░░░ 60% Complete │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ ┌────────────────────────────┐ │
│ │ JURY PROGRESS │ │ RECENT ACTIVITY │ │
│ │ │ │ │ │
│ │ Alice ████████░░ 80% │ │ • John submitted eval... │ │
│ │ Bob ██████░░░░ 60% │ │ • Sarah started eval... │ │
│ │ Carol ████░░░░░░ 40% │ │ • Admin extended window │ │
│ │ ... │ │ • ... │ │
│ └─────────────────────────────┘ └────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Mobile: Stats stack vertically, Progress & Activity in tabs
```
### Jury Project List
```
DESKTOP:
┌─────────────────────────────────────────────────────────────────┐
│ MY ASSIGNED PROJECTS Filter ▼ Search │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Title │ Team │ Status │ Actions │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ Ocean Cleanup AI │ BlueWave │ ✅ Done │ View │ │
│ │ Coral Restoration │ ReefGuard │ 📝 Draft │ Continue │ │
│ │ Plastic Tracker │ CleanSeas │ ⏳ Pending│ Start │ │
│ │ ... │ │ │ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ Showing 1-10 of 15 < 1 2 > │
└─────────────────────────────────────────────────────────────────┘
MOBILE (Card View):
┌─────────────────────────────────────┐
│ MY PROJECTS (15) 🔍 Filter │
├─────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────┐│
│ │ Ocean Cleanup AI ││
│ │ Team: BlueWave ││
│ │ ✅ Completed ││
│ │ [View →] ││
│ └─────────────────────────────────┘│
│ │
│ ┌─────────────────────────────────┐│
│ │ Coral Restoration ││
│ │ Team: ReefGuard ││
│ │ 📝 Draft saved ││
│ │ [Continue →] ││
│ └─────────────────────────────────┘│
│ │
│ ┌─────────────────────────────────┐│
│ │ Plastic Tracker ││
│ │ Team: CleanSeas ││
│ │ ⏳ Not started ││
│ │ [Start →] ││
│ └─────────────────────────────────┘│
│ │
└─────────────────────────────────────┘
```
### Evaluation Form
```
DESKTOP (Side Panel):
┌─────────────────────────────────────────────────────────────────┐
│ PROJECT DETAILS │ EVALUATION FORM │
├─────────────────────────────────────────────────────────────────┤
│ │ │
│ Ocean Cleanup AI │ Need Clarity │
│ Team: BlueWave Tech │ ○ 1 ○ 2 ○ 3 ● 4 ○ 5 │
│ │ │
│ [📄 Exec Summary] [📊 Deck] │ Solution Relevance │
│ [🎬 Video] │ ○ 1 ○ 2 ● 3 ○ 4 ○ 5 │
│ │ │
│ Description: │ Gap Analysis │
│ Our AI-powered system uses │ ○ 1 ○ 2 ○ 3 ○ 4 ● 5 │
│ machine learning to identify │ │
│ ocean plastic concentrations... │ Target Customers │
│ │ ○ 1 ○ 2 ○ 3 ● 4 ○ 5 │
│ Tags: AI, Plastic, Monitoring │ │
│ │ Ocean Impact │
│ │ ○ 1 ○ 2 ○ 3 ○ 4 ● 5 │
│ │ │
│ │ Global Score (1-10) │
│ │ [ 8 ] │
│ │ │
│ │ Semi-finalist? │
│ │ (●) Yes ( ) No │
│ │ │
│ │ Feedback │
│ │ ┌────────────────────┐ │
│ │ │ Strong technical │ │
│ │ │ approach with... │ │
│ │ └────────────────────┘ │
│ │ │
│ │ Autosaved 2s ago │
│ │ [Submit Evaluation] │
│ │ │
└─────────────────────────────────────────────────────────────────┘
MOBILE (Full Screen Wizard):
┌─────────────────────────────────────┐
│ ← Ocean Cleanup AI Step 3/7 │
├─────────────────────────────────────┤
│ │
│ Gap Analysis │
│ │
│ How well does the project │
│ analyze market gaps? │
│ │
│ ┌─────────────────────────────────┐│
│ │ ││
│ │ 1 2 3 4 5 ││
│ │ (○) (○) (○) (○) (●) ││
│ │ Poor Excellent ││
│ │ ││
│ └─────────────────────────────────┘│
│ │
│ │
│ │
│ │
│ │
│ ┌─────────────────────────────────┐│
│ │ ○ ○ ● ○ ○ ○ ○ ││
│ └─────────────────────────────────┘│
│ │
│ [← Previous] [Next →] │
│ │
└─────────────────────────────────────┘
```
## Design System
### Color Palette (MOPC Brand)
```css
/* CSS Variables in tailwind.config.ts - MOPC Brand Colors */
:root {
/* Brand Colors */
--color-primary: 354 90% 47%; /* #de0f1e - Primary Red */
--color-secondary: 198 85% 18%; /* #053d57 - Dark Blue */
--color-accent: 194 25% 44%; /* #557f8c - Teal */
/* shadcn/ui mapped to MOPC brand */
--background: 0 0% 100%; /* #fefefe */
--foreground: 198 85% 18%; /* Dark Blue for text */
--card: 0 0% 100%;
--card-foreground: 198 85% 18%;
--popover: 0 0% 100%;
--popover-foreground: 198 85% 18%;
--primary: 354 90% 47%; /* Primary Red - main actions */
--primary-foreground: 0 0% 100%;
--secondary: 30 6% 96%; /* Warm gray */
--secondary-foreground: 198 85% 18%;
--muted: 30 6% 96%;
--muted-foreground: 30 8% 45%;
--accent: 194 25% 44%; /* Teal */
--accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 100%;
--border: 30 6% 91%;
--input: 30 6% 91%;
--ring: 354 90% 47%; /* Primary Red for focus */
--radius: 0.5rem;
/* Semantic colors */
--success: 142.1 76.2% 36.3%;
--warning: 38 92% 50%;
--info: 194 25% 44%; /* Teal */
}
```
### Typography (Montserrat)
```typescript
// tailwind.config.ts
const config = {
theme: {
extend: {
fontFamily: {
sans: ['Montserrat', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
fontWeight: {
light: '300',
normal: '400',
semibold: '600',
bold: '700',
},
fontSize: {
'display-lg': ['3rem', { lineHeight: '1.1', fontWeight: '700' }],
'display': ['2.25rem', { lineHeight: '1.2', fontWeight: '700' }],
'heading': ['1.5rem', { lineHeight: '1.3', fontWeight: '600' }],
'subheading': ['1.125rem', { lineHeight: '1.4', fontWeight: '600' }],
'body': ['1rem', { lineHeight: '1.5', fontWeight: '400' }],
'small': ['0.875rem', { lineHeight: '1.5', fontWeight: '400' }],
'tiny': ['0.75rem', { lineHeight: '1.5', fontWeight: '400' }],
},
},
},
}
```
### Spacing System
```
Base unit: 4px
0 = 0px
1 = 4px
2 = 8px
3 = 12px
4 = 16px
5 = 20px
6 = 24px
8 = 32px
10 = 40px
12 = 48px
16 = 64px
20 = 80px
24 = 96px
```
## Component Patterns
### Loading States
```typescript
// Always show skeleton while loading
function ProjectList() {
const { data, isLoading } = trpc.project.list.useQuery({ roundId })
if (isLoading) {
return (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
)
}
return <ProjectTable projects={data.projects} />
}
```
### Error States
```typescript
// Consistent error display
function ErrorState({
title = 'Something went wrong',
message,
onRetry,
}: {
title?: string
message: string
onRetry?: () => void
}) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<AlertCircle className="h-12 w-12 text-destructive" />
<h3 className="mt-4 text-lg font-semibold">{title}</h3>
<p className="mt-2 text-muted-foreground">{message}</p>
{onRetry && (
<Button onClick={onRetry} className="mt-4">
Try Again
</Button>
)}
</div>
)
}
```
### Empty States
```typescript
function EmptyState({
icon: Icon,
title,
description,
action,
}: {
icon: React.ComponentType<{ className?: string }>
title: string
description: string
action?: React.ReactNode
}) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<Icon className="h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold">{title}</h3>
<p className="mt-2 text-muted-foreground">{description}</p>
{action && <div className="mt-4">{action}</div>}
</div>
)
}
```
### Responsive Patterns
```typescript
// Table on desktop, cards on mobile
function ProjectDisplay({ projects }: { projects: Project[] }) {
return (
<>
{/* Desktop: Table */}
<div className="hidden md:block">
<ProjectTable projects={projects} />
</div>
{/* Mobile: Cards */}
<div className="md:hidden space-y-4">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
</>
)
}
```
## Touch Targets
All interactive elements must have a minimum touch target of 44x44px on mobile:
```typescript
// Good: Large touch target
<Button className="min-h-[44px] min-w-[44px] px-4">
Click me
</Button>
// Good: Icon button with padding
<Button variant="ghost" size="icon" className="h-11 w-11">
<Menu className="h-5 w-5" />
</Button>
```
## Form Patterns
### Autosave with Debounce
```typescript
function EvaluationForm({ evaluation }: { evaluation: Evaluation }) {
const utils = trpc.useUtils()
const autosave = trpc.evaluation.autosave.useMutation({
onSuccess: () => {
utils.evaluation.get.invalidate({ assignmentId: evaluation.assignmentId })
},
})
const debouncedSave = useMemo(
() => debounce((data: FormData) => autosave.mutate(data), 1000),
[autosave]
)
return (
<Form
onChange={(data) => {
debouncedSave(data)
}}
>
{/* Form fields */}
<div className="text-sm text-muted-foreground">
{autosave.isPending ? 'Saving...' : 'Autosaved'}
</div>
</Form>
)
}
```
### Form Validation
```typescript
const evaluationSchema = z.object({
criterionScores: z.record(z.number().min(1).max(5)),
globalScore: z.number().min(1).max(10),
binaryDecision: z.boolean(),
feedbackText: z.string().min(10, 'Please provide at least 10 characters'),
})
function EvaluationForm() {
const form = useForm<z.infer<typeof evaluationSchema>>({
resolver: zodResolver(evaluationSchema),
})
return (
<Form {...form}>
<FormField
control={form.control}
name="feedbackText"
render={({ field }) => (
<FormItem>
<FormLabel>Feedback</FormLabel>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormMessage /> {/* Shows validation error */}
</FormItem>
)}
/>
</Form>
)
}
```
## Accessibility Checklist
- [ ] All images have alt text
- [ ] Form inputs have labels
- [ ] Color is not the only indicator (icons + text)
- [ ] Focus states are visible
- [ ] Skip links for main content
- [ ] Keyboard navigation works
- [ ] Screen reader tested
- [ ] Reduced motion respected
- [ ] Sufficient color contrast (4.5:1 for text)
## Animation Patterns
### Page Transitions (Motion)
```typescript
const pageVariants = {
initial: { opacity: 0, y: 20 },
enter: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] } },
exit: { opacity: 0, y: -10, transition: { duration: 0.2 } }
}
```
### List Stagger (Items enter one by one)
```typescript
const listVariants = {
visible: { transition: { staggerChildren: 0.05 } }
}
```
### Spring Physics (Natural movement)
```typescript
const springConfig = { type: "spring", stiffness: 400, damping: 30 }
```
## Mobile-Specific UX Patterns
| Pattern | Implementation |
|---------|----------------|
| Bottom sheets instead of modals | Vaul drawer, thumb-reachable |
| Swipe gestures | Motion drag handlers |
| Pull-to-refresh | Custom spring animation |
| Haptic feedback hints | Visual bounce on limits |
| Large touch targets | Min 44x44px, generous spacing |
| Thumb-zone navigation | Bottom nav, not hamburger menu |
| Native-feeling scrolls | CSS scroll-snap, momentum |
## Performance Targets
| Metric | Target | How |
|--------|--------|-----|
| First Contentful Paint | < 1.5s | SSR, optimized fonts |
| Largest Contentful Paint | < 2.5s | Image optimization, lazy loading |
| Time to Interactive | < 3.5s | Code splitting, minimal JS |
| Cumulative Layout Shift | < 0.1 | Reserved space, skeleton loaders |
| Touch response | < 100ms | Optimistic UI, spring animations |
| Scroll performance | 60fps | CSS transforms, will-change |
## Component Design Rules
### Buttons
- Primary: Solid brand red (#de0f1e), 12px radius, subtle shadow
- Secondary: Ghost/outline, same radius
- Hover: Scale 1.02, slight lift shadow
- Active: Scale 0.98, pressed feel
- Loading: Spinner replaces text, same width
### Tables (for data density)
- Zebra striping: Subtle, not harsh
- Row hover: Slight highlight, not full color change
- Sortable headers: Subtle indicator, not loud
- Mobile: Horizontal scroll with sticky first column
### Forms
- Labels above inputs (not placeholder-as-label)
- Clear focus states (brand color ring)
- Inline validation (not modal alerts)
- Autosave indicator: Subtle, top-right
### Empty States
- Illustration + helpful text
- Clear CTA to fix the empty state
- Not just "No data found"
## Related Documentation
- [Architecture Overview](./README.md) - System design
- [API Design](./api.md) - tRPC endpoints