commit a606292aaa3392d12a51eae435b2bc7dacefb79b Author: Matt Date: Fri Jan 30 13:41:32 2026 +0100 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7e670b3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +# Dependencies +node_modules +.pnp +.pnp.js + +# Testing +coverage + +# Next.js +.next +out + +# Production +build +dist + +# Misc +.DS_Store +*.pem + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.idea +.vscode +*.swp +*.swo + +# Git +.git +.gitignore + +# Docker +docker-output.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4b8acef --- /dev/null +++ b/.env.example @@ -0,0 +1,70 @@ +# ============================================================================= +# MOPC Platform Environment Variables +# ============================================================================= +# Copy this file to .env.local for local development +# Copy to .env for production + +# ============================================================================= +# DATABASE +# ============================================================================= +DATABASE_URL="postgresql://mopc:password@localhost:5432/mopc" + +# Docker Compose database credentials +POSTGRES_USER="mopc" +POSTGRES_PASSWORD="devpassword" +POSTGRES_DB="mopc" + +# ============================================================================= +# AUTHENTICATION (NextAuth.js) +# ============================================================================= +# Production URL (no trailing slash) +NEXTAUTH_URL="https://monaco-opc.com" + +# Generate with: openssl rand -base64 32 +NEXTAUTH_SECRET="your-secret-key-here" + +# ============================================================================= +# FILE STORAGE (MinIO) +# ============================================================================= +# Internal endpoint for server-to-server communication +MINIO_ENDPOINT="http://localhost:9000" + +# Public endpoint for browser-accessible URLs (pre-signed URLs) +# Set this when MinIO is behind a reverse proxy or external to Docker network +# If not set, falls back to MINIO_ENDPOINT +# MINIO_PUBLIC_ENDPOINT="https://storage.monaco-opc.com" + +MINIO_ACCESS_KEY="minioadmin" +MINIO_SECRET_KEY="minioadmin" +MINIO_BUCKET="mopc-files" + +# ============================================================================= +# EMAIL (SMTP via Poste.io) +# ============================================================================= +SMTP_HOST="localhost" +SMTP_PORT="587" +SMTP_USER="noreply@monaco-opc.com" +SMTP_PASS="your-smtp-password" +EMAIL_FROM="MOPC Platform " + +# ============================================================================= +# AI (OpenAI for Smart Assignment) +# ============================================================================= +# Optional: Enable AI-powered jury assignment suggestions +OPENAI_API_KEY="" +OPENAI_MODEL="gpt-4o" + +# ============================================================================= +# APPLICATION SETTINGS +# ============================================================================= +# Node environment +NODE_ENV="development" + +# Maximum file upload size in bytes (500MB for videos) +MAX_FILE_SIZE="524288000" + +# Session duration in seconds (24 hours) +SESSION_MAX_AGE="86400" + +# Magic link expiry in seconds (15 minutes) +MAGIC_LINK_EXPIRY="900" diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..3b06d64 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,39 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + +env: + REGISTRY: ${{ vars.REGISTRY_URL }} + IMAGE_NAME: mopc-app + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e53f83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Next.js +.next/ +out/ + +# Build +dist/ +build/ + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Docker env (contains secrets on server - never commit) +docker/.env + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# Prisma +prisma/*.db +prisma/*.db-journal + +# Testing +coverage/ + +# Playwright MCP screenshots +.playwright-mcp/ + +# Output files +*-output.txt +build-output.txt + +# Misc +*.log +.vercel diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d5b9698 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,340 @@ +# MOPC Platform - Claude Code Context + +## Project Overview + +**MOPC (Monaco Ocean Protection Challenge)** is a secure jury online voting platform for managing project selection rounds. The platform enables jury members to evaluate submitted ocean conservation projects, with Phase 1 supporting two selection rounds: + +- **Round 1**: ~130 projects → ~60 semi-finalists +- **Round 2**: ~60 projects → 6 finalists + +**Domain**: `monaco-opc.com` + +The platform is designed for future expansion into a comprehensive program management system including learning hub, communication workflows, and partner modules. + +## Key Decisions + +| Decision | Choice | +|----------|--------| +| 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 Data Privacy | All data anonymized before sending to GPT | + +## Brand Identity + +| Name | Hex | Usage | +|------|-----|-------| +| Primary Red | `#de0f1e` | CTAs, alerts | +| Dark Blue | `#053d57` | Headers, sidebar | +| White | `#fefefe` | Backgrounds | +| Teal | `#557f8c` | Links, secondary | + +**Typography**: Montserrat (600/700 for headings, 300/400 for body) + +## 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 | +| **Animation** | Motion (Framer Motion) | 11.x | +| **Notifications** | Sonner | 1.x | +| **Command Palette** | cmdk | 1.x | +| **File Storage** | MinIO (S3-compatible) | External | +| **Email** | Nodemailer + Poste.io | External | +| **Containerization** | Docker Compose | 2.x | +| **Reverse Proxy** | Nginx | External | + +## Architecture Principles + +1. **Type Safety First**: End-to-end TypeScript from database to UI via Prisma → tRPC → React +2. **Mobile-First Responsive**: All components designed for mobile, enhanced for desktop +3. **Full Control**: No black-box services; every component is understood and maintainable +4. **Extensible Data Model**: JSON fields for future attributes without schema migrations +5. **Security by Default**: RBAC, audit logging, secure file access with pre-signed URLs + +## File Structure + +``` +mopc-platform/ +├── CLAUDE.md # This file - project context +├── docs/ +│ └── architecture/ # Architecture documentation +│ ├── README.md # System overview +│ ├── database.md # Database design +│ ├── api.md # API design +│ ├── infrastructure.md # Deployment docs +│ └── ui.md # UI/UX patterns +├── src/ +│ ├── app/ # Next.js App Router pages +│ │ ├── (auth)/ # Public auth routes (login, verify) +│ │ ├── (admin)/ # Admin dashboard (protected) +│ │ ├── (jury)/ # Jury interface (protected) +│ │ ├── api/ # API routes +│ │ │ └── trpc/ # tRPC endpoint +│ │ ├── layout.tsx # Root layout +│ │ └── page.tsx # Home/landing +│ ├── components/ +│ │ ├── ui/ # shadcn/ui components +│ │ ├── forms/ # Form components (evaluation, etc.) +│ │ ├── layouts/ # Layout components (sidebar, nav) +│ │ └── shared/ # Shared components +│ ├── lib/ +│ │ ├── auth.ts # NextAuth configuration +│ │ ├── prisma.ts # Prisma client singleton +│ │ ├── trpc/ # tRPC client & server setup +│ │ ├── minio.ts # MinIO client +│ │ └── email.ts # Email utilities +│ ├── server/ +│ │ ├── routers/ # tRPC routers by domain +│ │ │ ├── program.ts +│ │ │ ├── round.ts +│ │ │ ├── project.ts +│ │ │ ├── user.ts +│ │ │ ├── assignment.ts +│ │ │ ├── evaluation.ts +│ │ │ ├── audit.ts +│ │ │ ├── settings.ts +│ │ │ └── gracePeriod.ts +│ │ ├── services/ # Business logic services +│ │ └── middleware/ # RBAC & auth middleware +│ ├── hooks/ # React hooks +│ ├── types/ # Shared TypeScript types +│ └── utils/ # Utility functions +├── prisma/ +│ ├── schema.prisma # Database schema +│ ├── migrations/ # Migration files +│ └── seed.ts # Seed data +├── public/ # Static assets +├── docker/ +│ ├── Dockerfile # Production build +│ ├── docker-compose.yml # Production stack +│ └── docker-compose.dev.yml # Development stack +├── tests/ +│ ├── unit/ # Unit tests +│ └── e2e/ # End-to-end tests +└── config files... # package.json, tsconfig, etc. +``` + +## Coding Standards + +### TypeScript +- Strict mode enabled +- Explicit return types for functions +- Use `type` over `interface` for consistency (unless extending) +- Prefer `unknown` over `any` + +### React/Next.js +- Use Server Components by default +- `'use client'` only when needed (interactivity, hooks) +- Collocate components with their routes when specific to that route +- Use React Query (via tRPC) for server state + +### Naming Conventions +- **Files**: kebab-case (`user-profile.tsx`) +- **Components**: PascalCase (`UserProfile`) +- **Functions/Variables**: camelCase (`getUserById`) +- **Constants**: SCREAMING_SNAKE_CASE (`MAX_FILE_SIZE`) +- **Database Tables**: PascalCase in Prisma (`User`, `Project`) +- **Database Columns**: camelCase in Prisma (`createdAt`) + +### Styling +- Tailwind CSS utility classes +- Mobile-first: base styles for mobile, `md:` for tablet, `lg:` for desktop +- Use shadcn/ui components as base, customize via CSS variables +- No inline styles; no separate CSS files unless absolutely necessary + +### API Design (tRPC) +- Group by domain: `trpc.program.create()`, `trpc.round.list()` +- Use Zod for input validation +- Return consistent response shapes +- Throw `TRPCError` with appropriate codes + +## Common Commands + +```bash +# Development +npm run dev # Start Next.js dev server +npm run db:studio # Open Prisma Studio +npm run db:push # Push schema changes (dev only) +npm run db:migrate # Run migrations +npm run db:seed # Seed database + +# Testing +npm run test # Run unit tests +npm run test:e2e # Run E2E tests +npm run test:coverage # Test with coverage + +# Build & Deploy +npm run build # Production build +npm run start # Start production server +docker compose up -d # Start Docker stack +docker compose logs -f app # View app logs + +# Code Quality +npm run lint # ESLint +npm run format # Prettier +npm run typecheck # TypeScript check +``` + +## Windows Development Notes + +**IMPORTANT**: On Windows, all Docker commands must be run using PowerShell (`powershell -Command "..."`), not bash/cmd. This is required for proper Docker Desktop integration. + +**IMPORTANT**: When invoking PowerShell from bash, always use `-ExecutionPolicy Bypass` to skip the user profile script which is blocked by execution policy: +```bash +powershell -ExecutionPolicy Bypass -Command "..." +``` + +```powershell +# Docker commands on Windows (use PowerShell) +docker compose -f docker/docker-compose.dev.yml up -d +docker compose -f docker/docker-compose.dev.yml build --no-cache app +docker compose -f docker/docker-compose.dev.yml logs -f app +docker compose -f docker/docker-compose.dev.yml down +``` + +## Environment Variables + +```env +# Database +DATABASE_URL="postgresql://user:pass@localhost:5432/mopc" + +# NextAuth +NEXTAUTH_URL="https://monaco-opc.com" +NEXTAUTH_SECRET="your-secret-key" + +# MinIO (existing separate stack) +MINIO_ENDPOINT="http://localhost:9000" +MINIO_ACCESS_KEY="your-access-key" +MINIO_SECRET_KEY="your-secret-key" +MINIO_BUCKET="mopc-files" + +# Email (Poste.io - existing) +SMTP_HOST="localhost" +SMTP_PORT="587" +SMTP_USER="noreply@monaco-opc.com" +SMTP_PASS="your-smtp-password" +EMAIL_FROM="MOPC Platform " + +# OpenAI (for smart assignment) +OPENAI_API_KEY="your-openai-api-key" +``` + +## Key Architectural Decisions + +### 1. Next.js App Router over Pages Router +**Rationale**: Server Components reduce client bundle, better data fetching patterns, layouts system + +### 2. tRPC over REST +**Rationale**: End-to-end type safety without code generation, excellent DX with autocomplete + +### 3. Prisma over raw SQL +**Rationale**: Type-safe queries, migration system, works seamlessly with TypeScript + +### 4. NextAuth.js over custom auth +**Rationale**: Battle-tested, supports magic links, session management built-in + +### 5. MinIO (external) over local file storage +**Rationale**: S3-compatible, pre-signed URLs for security, scalable, already deployed + +### 6. JSON fields for extensibility +**Rationale**: `metadata_json`, `settings_json` allow adding attributes without migrations + +### 7. Soft deletes with status fields +**Rationale**: Audit trail preservation, recovery capability, referential integrity + +## 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 (optional) | + +## Important Constraints + +1. **Jury can only see assigned projects** - enforced at query level +2. **Voting windows are strict** - submissions blocked outside active window +3. **Evaluations are versioned** - edits create new versions +4. **All admin actions are audited** - immutable audit log +5. **Files accessed via pre-signed URLs** - no public bucket access +6. **Mobile responsiveness is mandatory** - every view must work on phones +7. **File downloads require project authorization** - jury/mentor must be assigned to the project +8. **Mentor endpoints require MENTOR role** - uses `mentorProcedure` middleware + +## Security Notes + +### CSRF Protection +tRPC mutations are protected against CSRF attacks because: +- tRPC uses `application/json` content type, which triggers CORS preflight on cross-origin requests +- Browsers block cross-origin JSON POSTs by default (Same-Origin Policy) +- NextAuth's own routes (`/api/auth/*`) have built-in CSRF token protection +- No custom CORS headers are configured to allow external origins + +**Do NOT add permissive CORS headers** (e.g., `Access-Control-Allow-Origin: *`) without also implementing explicit CSRF token validation on all mutation endpoints. + +### Rate Limiting +- tRPC API: 100 requests/minute per IP +- Auth endpoints: 10 POST requests/minute per IP +- Account lockout: 5 failed password attempts triggers 15-minute lockout + +## External Services (Pre-existing) + +These services are already running on the VPS in separate Docker Compose stacks: + +- **MinIO**: `http://localhost:9000` - S3-compatible storage +- **Poste.io**: `localhost:587` - SMTP server for emails +- **Nginx**: Host-level reverse proxy with SSL (certbot) + +The MOPC platform connects to these via environment variables. + +## Phase 1 Scope + +### In Scope +- Round management (create, configure, activate/close) +- Project import (CSV) and file uploads +- Jury invitation (magic link) +- Manual project assignment (single + bulk) +- Evaluation form (configurable criteria) +- Autosave + final submit +- Voting window enforcement +- Progress dashboards +- CSV export +- Audit logging + +### Out of Scope (Phase 2+) +- Auto-assignment algorithm +- Typeform/Notion integrations +- WhatsApp notifications +- Learning hub +- Partner modules +- Public website + +## Testing Strategy + +- **Unit Tests**: Business logic, utilities, validators +- **Integration Tests**: tRPC routers with test database +- **E2E Tests**: Critical user flows (Playwright) +- **Manual Testing**: Responsive design on real devices + +## Documentation Links + +- [Architecture Overview](./docs/architecture/README.md) +- [Database Design](./docs/architecture/database.md) +- [API Design](./docs/architecture/api.md) +- [Infrastructure](./docs/architecture/infrastructure.md) +- [UI/UX Patterns](./docs/architecture/ui.md) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..9c40024 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,318 @@ +# MOPC Platform - Server Deployment Guide + +Deployment guide for the MOPC platform on a Linux VPS with Docker. + +**Domain**: `portal.monaco-opc.com` +**App Port**: 7600 (behind Nginx reverse proxy) +**CI/CD**: Gitea Actions (Ubuntu runner) builds and pushes Docker images + +## CI/CD Pipeline + +The app is built automatically by a Gitea runner on every push to `main`: + +1. Gitea Actions workflow builds the Docker image on Ubuntu +2. Image is pushed to the Gitea container registry +3. On the server, you pull the latest image and restart + +### Gitea Setup + +Configure the following in your Gitea repository settings: + +**Repository Variables** (Settings > Actions > Variables): + +| Variable | Value | +|----------|-------| +| `REGISTRY_URL` | Your Gitea registry URL (e.g. `gitea.example.com/your-org`) | + +**Repository Secrets** (Settings > Actions > Secrets): + +| Secret | Value | +|--------|-------| +| `REGISTRY_USER` | Gitea username with registry access | +| `REGISTRY_PASSWORD` | Gitea access token or password | + +The workflow file is at `.gitea/workflows/build.yml`. + +## Prerequisites + +- Linux VPS (Ubuntu 22.04+ recommended) +- Docker Engine 24+ with Compose v2 +- Nginx installed on the host +- Certbot for SSL certificates + +### Install Docker (if needed) + +```bash +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER +# Log out and back in +``` + +### Install Nginx & Certbot (if needed) + +```bash +sudo apt update +sudo apt install nginx certbot python3-certbot-nginx +``` + +## First-Time Deployment + +### 1. Clone the repository + +```bash +git clone /opt/mopc +cd /opt/mopc +``` + +### 2. Configure environment variables + +```bash +cp docker/.env.production docker/.env +nano docker/.env +``` + +Fill in all `CHANGE_ME` values. Generate secrets with: + +```bash +openssl rand -base64 32 +``` + +Required variables: + +| Variable | Description | +|----------|-------------| +| `REGISTRY_URL` | Gitea registry URL (e.g. `gitea.example.com/your-org`) | +| `DB_PASSWORD` | PostgreSQL password | +| `NEXTAUTH_SECRET` | Auth session secret (openssl rand) | +| `NEXTAUTH_URL` | `https://portal.monaco-opc.com` | +| `MINIO_ENDPOINT` | MinIO internal URL (e.g. `http://localhost:9000`) | +| `MINIO_ACCESS_KEY` | MinIO access key | +| `MINIO_SECRET_KEY` | MinIO secret key | +| `MINIO_BUCKET` | MinIO bucket name (`mopc-files`) | +| `SMTP_HOST` | SMTP server host | +| `SMTP_PORT` | SMTP port (587) | +| `SMTP_USER` | SMTP username | +| `SMTP_PASS` | SMTP password | +| `EMAIL_FROM` | Sender address | + +### 3. Run the deploy script + +```bash +chmod +x scripts/deploy.sh scripts/seed.sh scripts/update.sh +./scripts/deploy.sh +``` + +This will: +- Log in to the container registry +- Pull the latest app image +- Start PostgreSQL + the app +- Run database migrations automatically on startup +- Wait for the health check + +### 4. Seed the database (one time only) + +```bash +./scripts/seed.sh +``` + +This seeds: +- Super admin user (`matt.ciaccio@gmail.com`) +- System settings +- Program & Round 1 configuration +- Evaluation form +- All candidature data from CSV + +### 5. Set up Nginx + +```bash +sudo ln -s /opt/mopc/docker/nginx/mopc-platform.conf /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +### 6. Set up SSL + +```bash +sudo certbot --nginx -d portal.monaco-opc.com +``` + +Auto-renewal is configured by default. Test with: + +```bash +sudo certbot renew --dry-run +``` + +### 7. Verify + +```bash +curl https://portal.monaco-opc.com/api/health +``` + +Expected response: + +```json +{"status":"healthy","timestamp":"...","services":{"database":"connected"}} +``` + +## Updating the Platform + +After Gitea CI builds a new image (push to `main`): + +```bash +cd /opt/mopc +./scripts/update.sh +``` + +This pulls the latest image from the registry, restarts only the app container (PostgreSQL stays running), runs migrations via the entrypoint, and waits for the health check. + +## Manual Operations + +### View logs + +```bash +cd /opt/mopc/docker +docker compose logs -f app # App logs +docker compose logs -f postgres # Database logs +``` + +### Run migrations manually + +```bash +cd /opt/mopc/docker +docker compose exec app npx prisma migrate deploy +``` + +### Open a shell in the app container + +```bash +cd /opt/mopc/docker +docker compose exec app sh +``` + +### Restart services + +```bash +cd /opt/mopc/docker +docker compose restart app # App only +docker compose restart # All services +``` + +### Stop everything + +```bash +cd /opt/mopc/docker +docker compose down # Stop containers (data preserved) +docker compose down -v # Stop AND delete volumes (data lost!) +``` + +## Database Backups + +### Create a backup + +```bash +docker exec mopc-postgres pg_dump -U mopc mopc | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz +``` + +### Restore a backup + +```bash +gunzip < backup_20260130_020000.sql.gz | docker exec -i mopc-postgres psql -U mopc mopc +``` + +### Set up daily backups (cron) + +```bash +sudo mkdir -p /data/backups/mopc + +cat > /opt/mopc/scripts/backup-db.sh << 'SCRIPT' +#!/bin/bash +BACKUP_DIR=/data/backups/mopc +DATE=$(date +%Y%m%d_%H%M%S) +docker exec mopc-postgres pg_dump -U mopc mopc | gzip > $BACKUP_DIR/mopc_$DATE.sql.gz +find $BACKUP_DIR -name "mopc_*.sql.gz" -mtime +30 -delete +SCRIPT + +chmod +x /opt/mopc/scripts/backup-db.sh +echo "0 2 * * * /opt/mopc/scripts/backup-db.sh >> /var/log/mopc-backup.log 2>&1" | sudo tee /etc/cron.d/mopc-backup +``` + +## Architecture + +``` +Gitea CI (Ubuntu runner) + | + v (docker push) +Container Registry + | + v (docker pull) +Linux VPS + | + v +Nginx (host, port 443) -- SSL termination + | + v +mopc-app (Docker, port 7600) -- Next.js standalone + | + v +mopc-postgres (Docker, port 5432) -- PostgreSQL 16 + +External services (separate Docker stacks): + - MinIO (port 9000) -- S3-compatible file storage + - Poste.io (port 587) -- SMTP email +``` + +## Troubleshooting + +### App won't start + +```bash +cd /opt/mopc/docker +docker compose logs app +docker compose exec postgres pg_isready -U mopc +``` + +### Can't pull image + +```bash +# Re-authenticate with registry +docker login + +# Check image exists +docker pull /mopc-app:latest +``` + +### Migration fails + +```bash +# Check migration status +docker compose exec app npx prisma migrate status + +# Reset (DESTROYS DATA): +docker compose exec app npx prisma migrate reset +``` + +### SSL certificate issues + +```bash +sudo certbot certificates +sudo certbot renew --force-renewal +``` + +### Port conflict + +The app runs on port 7600. If something else uses it: + +```bash +sudo ss -tlnp | grep 7600 +``` + +## Security Checklist + +- [ ] SSL certificate active and auto-renewing +- [ ] `docker/.env` has strong, unique passwords +- [ ] `NEXTAUTH_SECRET` is randomly generated +- [ ] Gitea registry credentials secured +- [ ] Firewall allows only ports 80, 443, 22 +- [ ] Docker daemon not exposed to network +- [ ] Daily backups configured +- [ ] Nginx security headers active diff --git a/Notes.md b/Notes.md new file mode 100644 index 0000000..f4e07e9 --- /dev/null +++ b/Notes.md @@ -0,0 +1,402 @@ +Below is a “technical requirements” rendering (not an architecture diagram), structured so you can hand it to a dev team and derive system architecture + backlog from it. Phase 1 is the only critical deliverable; Phase 2+ are explicitly extendable. + +--- + +## 0) Product scope and phasing + +### Phase 1 (critical, delivery in ~2 weeks) + +**Secure Jury Online Voting Module** to run two selection rounds: + +* Round 1: ~130 projects → ~60 semi-finalists (Feb 18–23 voting window) +* Round 2: ~60 projects → 6 finalists (~April 13 week voting window) +* Voting is asynchronous, online, with assigned project access, scoring + feedback capture, and reporting dashboards. + +### Phase 2+ (mid-term) + +Centralized MOPC platform: + +* Applications/projects database +* Document management (MinIO S3) +* Jury spaces (history, comments, scoring) +* Learning hub / resources +* Communication workflows (email + possibly WhatsApp) +* Partner/sponsor visibility modules +* Potential website integration / shared back office + +--- + +## 1) Users, roles, permissions (RBAC) + +### Core roles + +1. **Platform Super Admin** + + * Full system configuration, security policies, integrations, user/role management, data export, audit access. +2. **Program Admin (MOPC Admin)** + + * Manages cycles/rounds, projects, jury members, assignments, voting windows, criteria forms, dashboards, exports. +3. **Jury Member** + + * Can access only assigned projects for active rounds; submit evaluations; view own submitted evaluations; optionally view aggregated results only if permitted. +4. **Read-only Observer (optional)** + + * Internal meeting viewer: can see dashboards/aggregates but cannot edit votes. + +### Permission model requirements + +* **Least privilege by default** +* **Round-scoped permissions**: access can be constrained per selection round/cycle. +* **Project-scoped access control**: jury sees only assigned projects (unless admin toggles “all projects visible”). +* **Admin override controls**: reassign projects, revoke access, reopen/lock evaluations, extend voting windows, invalidate votes with reason logging. + +--- + +## 2) Core domain objects (data model concepts) + +### Entities + +* **Program** (e.g., “MOPC 2026”) +* **Selection Cycle / Round** + + * Attributes: name, start/end of voting window, status (draft/active/closed/archived), required reviews per project (default ≥3), scoring form version, jury cohort. +* **Project** + + * Attributes: title, team name, description, tags, status (submitted/eligible/assigned/semi-finalist/finalist/etc.), submission metadata, external IDs (Typeform/Notion), files (exec summary, PDF deck, intro video). +* **File Asset** + + * Stored in MinIO (S3-compatible): object key, bucket, version/etag, mime type, size, upload timestamp, retention policy, access policy. +* **Jury Member** + + * Profile: name, email, organization (optional), role, expertise tags, status (invited/active/suspended). +* **Expertise Tag** + + * Managed vocabulary or free-form with admin approval. +* **Assignment** + + * Connects Jury Member ↔ Project ↔ Round + * Attributes: assignment method (manual/auto), created by, created timestamp, required review flag, completion status. +* **Evaluation (Vote)** + + * Per assignment: criterion scores + global score + binary decision + qualitative feedback + * Metadata: submitted_at, last_edited_at, finalization flag, versioning, IP/user-agent logging (optional), conflict handling. +* **Audit Log** + + * Immutable events: login, permission changes, voting window changes, assignments, overrides, exports, vote invalidations. + +--- + +## 3) Phase 1 functional requirements + +### 3.1 Jury authentication & access + +* Invite flow: + + * Admin imports jury list (CSV) or adds manually. + * System sends invitation email with secure link + account activation. +* Authentication options (choose one for Phase 1, keep others pluggable): + + * Email magic link (recommended for speed) + * Password + MFA optional +* Session requirements: + + * Configurable session duration + * Forced logout on role revocation +* Access gating: + + * Jury can only view projects for **active** rounds and only those assigned. + +### 3.2 Project ingestion & management + +Phase 1 can support either: + +* **Option A (fastest): Manual import** + + * Admin uploads CSV with project metadata + file links or uploads. +* **Option B (semi-integrated): Sync from Notion/Typeform** + + * Read projects from existing Notion DB and/or Typeform export. + +Minimum capabilities: + +* Admin CRUD on projects (create/update/archive) +* Project tagging (from “Which issue does your project address?” + additional admin tags) +* Attach required assets: + + * Executive summary (PDF/doc) + * PDF presentation + * 30s intro video (mp4) +* File storage via MinIO (see Section 6) + +### 3.3 Assignment system (≥3 reviews/project) + +Admin can: + +* Manually assign projects to jury members (bulk assign supported) +* Auto-assign (optional but strongly recommended): + + * Input: jury expertise tags + project tags + constraints + * Constraints: + + * Each project assigned to at least N jurors (N configurable; default 3) + * Load balancing across jurors (minimize variance) + * Avoid conflicts (optional): disallow assignment if juror marked conflict with project + * Output: assignment set + summary metrics (coverage, per-juror load, unmatched tags) +* Reassignment rules: + + * Admin can reassign at any time + * If an evaluation exists, admin can: + + * keep existing evaluation tied to original juror + * or invalidate/lock it (requires reason + audit event) + +### 3.4 Evaluation form & scoring logic + +Per project evaluation must capture: + +* **Criterion scores** (scale-based, define exact scale as configurable; e.g., 1–5 or 1–10) + + 1. Need clarity + 2. Solution relevance + 3. Gap analysis (market/competitors) + 4. Target customers clarity + 5. Ocean impact +* **Global score**: 1–10 +* **Binary decision**: “Select as semi-finalist?” (Yes/No) +* **Qualitative feedback**: long text + +Form requirements: + +* Admin-configurable criteria text, ordering, scales, and whether fields are mandatory +* Autosave drafts +* Final submit locks evaluation by default (admin can allow edits until window closes) +* Support multiple rounds with potentially different forms (versioned forms per round) + +### 3.5 Voting windows and enforcement (must-have) + +Admins must be able to configure and enforce: + +* Voting window start/end **per round** (date-time, timezone-aware) +* States: + + * Draft (admins only) + * Active (jury can submit) + * Closed (jury read-only) + * Archived (admin/export only) +* Enforcement rules: + + * Jury cannot submit outside the active window + * Admin “grace period” toggle to accept late submissions for specific jurors/projects + * Admin can extend the window (global or subset) with audit logging +* Dashboard countdown + clear messaging for jurors + +### 3.6 Dashboards & outputs + +Must produce: + +* **Jury member view** + + * Assigned projects list, completion status, quick access to files, evaluation status (not started/draft/submitted) +* **Admin dashboards** + + * Coverage: projects with + +# ============================================================================= +# AI (OpenAI - optional) +# ============================================================================= +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o + +# ============================================================================= +# DOCKER REGISTRY (Gitea container registry) +# ============================================================================= +# The Gitea registry URL where the CI pushes built images +# Example: gitea.example.com/your-org +REGISTRY_URL=code.letsbe.solutions/letsbe + +# ============================================================================= +# APPLICATION +# ============================================================================= +MAX_FILE_SIZE=524288000 diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..bb2ef4f --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,71 @@ +# ============================================================================= +# MOPC Platform - Production Dockerfile +# ============================================================================= +# Multi-stage build for optimized production image + +FROM node:22-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Copy package files +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, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Create non-root user for security +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Install runtime dependencies for migrations and seeding +RUN apk add --no-cache libc6-compat + +# Copy built files +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 +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma +COPY --from=builder /app/node_modules/prisma ./node_modules/prisma + +# Copy CSV data file for manual seeding +COPY --from=builder /app/docs/candidatures_2026.csv ./docs/candidatures_2026.csv + +# Copy entrypoint script +COPY docker/docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh + +# Set correct permissions +RUN chown -R nextjs:nodejs /app + +USER nextjs + +EXPOSE 7600 + +ENV PORT=7600 +ENV HOSTNAME="0.0.0.0" + +# Run via entrypoint (migrate then start) +CMD ["/app/docker-entrypoint.sh"] diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 0000000..547d9df --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,34 @@ +# ============================================================================= +# MOPC Platform - Development Dockerfile (Pre-built) +# ============================================================================= + +FROM node:22-alpine + +WORKDIR /app + +# Install dependencies for Prisma and development +RUN apk add --no-cache libc6-compat openssl + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install dependencies +RUN npm install && npm install tailwindcss-animate + +# Copy prisma schema for generation +COPY prisma ./prisma + +# Generate Prisma client +RUN npx prisma generate + +# Copy the rest of the application +COPY . . + +# Build the application at build time (pre-compile everything) +RUN npm run build + +# Expose port +EXPOSE 3000 + +# Start production server (uses pre-built files) +CMD ["npm", "start"] diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml new file mode 100644 index 0000000..2a6e213 --- /dev/null +++ b/docker/docker-compose.dev.yml @@ -0,0 +1,97 @@ +# ============================================================================= +# MOPC Platform - Development Docker Compose +# ============================================================================= +# Use this for local development. Includes PostgreSQL, MinIO, and the app. + +services: + postgres: + image: postgres:16-alpine + container_name: mopc-postgres-dev + ports: + - "5432:5432" + environment: + - POSTGRES_USER=${POSTGRES_USER:-mopc} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword} + - POSTGRES_DB=${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=${MINIO_ACCESS_KEY:-minioadmin} + - MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY:-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 + environment: + - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin} + - MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin} + - MINIO_BUCKET=${MINIO_BUCKET:-mopc-files} + entrypoint: > + /bin/sh -c " + mc alias set myminio http://minio:9000 $${MINIO_ACCESS_KEY} $${MINIO_SECRET_KEY}; + mc mb --ignore-existing myminio/$${MINIO_BUCKET}; + mc anonymous set download myminio/$${MINIO_BUCKET}; + echo 'Bucket created successfully'; + " + + # Next.js application + app: + build: + context: .. + dockerfile: docker/Dockerfile.dev + container_name: mopc-app-dev + ports: + - "3000:3000" + env_file: + - ../.env + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc} + - NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000} + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only} + - AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only} + - MINIO_ENDPOINT=http://minio:9000 + - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin} + - MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin} + - MINIO_BUCKET=${MINIO_BUCKET:-mopc-files} + - NODE_ENV=development + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER} + - SMTP_PASS=${SMTP_PASS} + - EMAIL_FROM=${EMAIL_FROM} + 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: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..3846df7 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,75 @@ +# ============================================================================= +# MOPC Platform - Production Docker Compose +# ============================================================================= +# This stack contains only the Next.js app and PostgreSQL. +# MinIO and Poste.io are external services connected via environment variables. +# +# The app image is built by Gitea CI and pushed to the container registry. +# To pull the latest image: docker compose pull app +# To deploy: docker compose up -d + +services: + app: + image: ${REGISTRY_URL}/mopc-app:latest + container_name: mopc-app + restart: unless-stopped + ports: + - "127.0.0.1:7600:7600" + env_file: + - .env + environment: + - NODE_ENV=production + - DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc + - NEXTAUTH_URL=${NEXTAUTH_URL} + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - AUTH_SECRET=${NEXTAUTH_SECRET} + - MINIO_ENDPOINT=${MINIO_ENDPOINT} + - MINIO_PUBLIC_ENDPOINT=${MINIO_PUBLIC_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} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o} + - MAX_FILE_SIZE=${MAX_FILE_SIZE:-524288000} + depends_on: + postgres: + condition: service_healthy + networks: + - mopc-network + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:7600/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + 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 + +networks: + mopc-network: + driver: bridge diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100644 index 0000000..c03dbe7 --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "==> Running database migrations..." +npx prisma migrate deploy + +echo "==> Starting application..." +exec node server.js diff --git a/docker/nginx/mopc-platform.conf b/docker/nginx/mopc-platform.conf new file mode 100644 index 0000000..0e6d3b9 --- /dev/null +++ b/docker/nginx/mopc-platform.conf @@ -0,0 +1,78 @@ +# ============================================================================= +# MOPC Platform - Nginx Reverse Proxy Configuration +# ============================================================================= +# Install: sudo ln -s /opt/mopc/docker/nginx/mopc-platform.conf /etc/nginx/sites-enabled/ +# Test: sudo nginx -t +# Reload: sudo systemctl reload nginx + +# Rate limiting zone +limit_req_zone $binary_remote_addr zone=mopc_limit:10m rate=10r/s; + +# MOPC Platform - HTTPS +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name portal.monaco-opc.com; + + # SSL certificates (managed by Certbot) + ssl_certificate /etc/letsencrypt/live/portal.monaco-opc.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/portal.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:7600; + 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:7600; + proxy_cache_valid 200 365d; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # Health check endpoint (no access log noise) + location /api/health { + proxy_pass http://127.0.0.1:7600; + access_log off; + } +} + +# HTTP to HTTPS redirect +server { + listen 80; + listen [::]:80; + server_name portal.monaco-opc.com; + return 301 https://$host$request_uri; +} diff --git a/docs/Notes/notes-prototype-1.md b/docs/Notes/notes-prototype-1.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..0db3359 --- /dev/null +++ b/docs/architecture/README.md @@ -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 diff --git a/docs/architecture/api.md b/docs/architecture/api.md new file mode 100644 index 0000000..6c76507 --- /dev/null +++ b/docs/architecture/api.md @@ -0,0 +1,1138 @@ +# MOPC Platform - API Design + +## Overview + +The MOPC platform uses **tRPC** for all API communication. tRPC provides end-to-end type safety between the server and client without code generation or API schemas. + +## Why tRPC? + +1. **Type Safety**: Changes to the API are immediately reflected in the client +2. **No Code Generation**: Types flow directly from server to client +3. **Great DX**: Full autocomplete and type checking +4. **Performance**: Automatic batching, minimal overhead +5. **React Query Integration**: Built-in caching, refetching, optimistic updates + +## tRPC Setup + +### Server Configuration + +```typescript +// src/server/trpc.ts + +import { initTRPC, TRPCError } from '@trpc/server' +import { type Context } from './context' +import superjson from 'superjson' +import { ZodError } from 'zod' + +const t = initTRPC.context().create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + } + }, +}) + +export const router = t.router +export const publicProcedure = t.procedure +export const middleware = t.middleware +``` + +### Context Definition + +```typescript +// src/server/context.ts + +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import type { inferAsyncReturnType } from '@trpc/server' + +export async function createContext(opts: { req: Request }) { + const session = await getServerSession(authOptions) + + return { + session, + prisma, + ip: opts.req.headers.get('x-forwarded-for') ?? 'unknown', + userAgent: opts.req.headers.get('user-agent') ?? 'unknown', + } +} + +export type Context = inferAsyncReturnType +``` + +### Auth Middleware + +```typescript +// src/server/middleware/auth.ts + +import { TRPCError } from '@trpc/server' +import { middleware } from '../trpc' +import type { UserRole } from '@prisma/client' + +// Require authenticated user +export const isAuthenticated = middleware(async ({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You must be logged in', + }) + } + + return next({ + ctx: { + ...ctx, + user: ctx.session.user, + }, + }) +}) + +// Require specific role(s) +export const hasRole = (...roles: UserRole[]) => + middleware(async ({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: 'UNAUTHORIZED' }) + } + + if (!roles.includes(ctx.session.user.role)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Insufficient permissions', + }) + } + + return next({ + ctx: { + ...ctx, + user: ctx.session.user, + }, + }) + }) + +// Pre-built role procedures +export const protectedProcedure = t.procedure.use(isAuthenticated) +export const adminProcedure = t.procedure.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN')) +export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER')) +``` + +## Router Structure + +``` +src/server/routers/ +├── _app.ts # Root router (combines all routers) +├── program.ts # Program management +├── round.ts # Round management +├── project.ts # Project management +├── user.ts # User management +├── assignment.ts # Assignment management (includes smart assignment) +├── evaluation.ts # Evaluation management +├── file.ts # File operations +├── export.ts # Export operations +├── audit.ts # Audit log access +├── settings.ts # Platform settings (admin) +└── gracePeriod.ts # Grace period management +``` + +### Root Router + +```typescript +// src/server/routers/_app.ts + +import { router } from '../trpc' +import { programRouter } from './program' +import { roundRouter } from './round' +import { projectRouter } from './project' +import { userRouter } from './user' +import { assignmentRouter } from './assignment' +import { evaluationRouter } from './evaluation' +import { fileRouter } from './file' +import { exportRouter } from './export' +import { auditRouter } from './audit' + +export const appRouter = router({ + program: programRouter, + round: roundRouter, + project: projectRouter, + user: userRouter, + assignment: assignmentRouter, + evaluation: evaluationRouter, + file: fileRouter, + export: exportRouter, + audit: auditRouter, + settings: settingsRouter, + gracePeriod: gracePeriodRouter, +}) + +export type AppRouter = typeof appRouter +``` + +## Router Specifications + +### Program Router + +```typescript +// src/server/routers/program.ts + +import { z } from 'zod' +import { router, adminProcedure, protectedProcedure } from '../trpc' + +export const programRouter = router({ + // List all programs + list: protectedProcedure.query(async ({ ctx }) => { + return ctx.prisma.program.findMany({ + orderBy: { year: 'desc' }, + }) + }), + + // Get single program with rounds + get: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.prisma.program.findUniqueOrThrow({ + where: { id: input.id }, + include: { rounds: true }, + }) + }), + + // Create program (admin only) + create: adminProcedure + .input(z.object({ + name: z.string().min(1).max(255), + year: z.number().int().min(2020).max(2100), + description: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + const program = await ctx.prisma.program.create({ + data: input, + }) + + // Audit log + await ctx.prisma.auditLog.create({ + data: { + userId: ctx.user.id, + action: 'CREATE', + entityType: 'Program', + entityId: program.id, + detailsJson: input, + ipAddress: ctx.ip, + }, + }) + + return program + }), + + // Update program (admin only) + update: adminProcedure + .input(z.object({ + id: z.string(), + name: z.string().min(1).max(255).optional(), + status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(), + description: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + const { id, ...data } = input + + const program = await ctx.prisma.program.update({ + where: { id }, + data, + }) + + // Audit log + await ctx.prisma.auditLog.create({ + data: { + userId: ctx.user.id, + action: 'UPDATE', + entityType: 'Program', + entityId: id, + detailsJson: data, + ipAddress: ctx.ip, + }, + }) + + return program + }), +}) +``` + +### Round Router + +```typescript +// src/server/routers/round.ts + +import { z } from 'zod' +import { router, adminProcedure, protectedProcedure } from '../trpc' +import { TRPCError } from '@trpc/server' + +export const roundRouter = router({ + // List rounds for a program + list: protectedProcedure + .input(z.object({ programId: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.prisma.round.findMany({ + where: { programId: input.programId }, + orderBy: { createdAt: 'asc' }, + }) + }), + + // Get round with stats + get: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const round = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: input.id }, + include: { + program: true, + _count: { + select: { + projects: true, + assignments: true, + }, + }, + }, + }) + + return round + }), + + // Create round (admin only) + create: adminProcedure + .input(z.object({ + programId: z.string(), + name: z.string().min(1).max(255), + requiredReviews: z.number().int().min(1).max(10).default(3), + votingStartAt: z.date().optional(), + votingEndAt: z.date().optional(), + })) + .mutation(async ({ ctx, input }) => { + // Validate dates + if (input.votingStartAt && input.votingEndAt) { + if (input.votingEndAt <= input.votingStartAt) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'End date must be after start date', + }) + } + } + + return ctx.prisma.round.create({ data: input }) + }), + + // Update round status (admin only) + updateStatus: adminProcedure + .input(z.object({ + id: z.string(), + status: z.enum(['DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED']), + })) + .mutation(async ({ ctx, input }) => { + return ctx.prisma.round.update({ + where: { id: input.id }, + data: { status: input.status }, + }) + }), + + // Check if voting is open + isVotingOpen: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const round = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: input.id }, + }) + + const now = new Date() + return ( + round.status === 'ACTIVE' && + round.votingStartAt && + round.votingEndAt && + now >= round.votingStartAt && + now <= round.votingEndAt + ) + }), +}) +``` + +### Project Router + +```typescript +// src/server/routers/project.ts + +import { z } from 'zod' +import { router, adminProcedure, protectedProcedure, juryProcedure } from '../trpc' + +export const projectRouter = router({ + // List projects (admin sees all, jury sees assigned) + list: protectedProcedure + .input(z.object({ + roundId: z.string(), + status: z.enum(['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED']).optional(), + search: z.string().optional(), + page: z.number().int().min(1).default(1), + perPage: z.number().int().min(1).max(100).default(20), + })) + .query(async ({ ctx, input }) => { + const { roundId, status, search, page, perPage } = input + const skip = (page - 1) * perPage + + // Build where clause + const where: any = { roundId } + + if (status) where.status = status + if (search) { + where.OR = [ + { title: { contains: search, mode: 'insensitive' } }, + { teamName: { contains: search, mode: 'insensitive' } }, + ] + } + + // Jury members can only see assigned projects + if (ctx.user.role === 'JURY_MEMBER') { + where.assignments = { + some: { userId: ctx.user.id }, + } + } + + const [projects, total] = await Promise.all([ + ctx.prisma.project.findMany({ + where, + skip, + take: perPage, + orderBy: { createdAt: 'desc' }, + include: { + files: true, + _count: { select: { assignments: true } }, + }, + }), + ctx.prisma.project.count({ where }), + ]) + + return { + projects, + total, + page, + perPage, + totalPages: Math.ceil(total / perPage), + } + }), + + // Get project details + get: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const project = await ctx.prisma.project.findUniqueOrThrow({ + where: { id: input.id }, + include: { + files: true, + round: true, + }, + }) + + // Check access for jury members + if (ctx.user.role === 'JURY_MEMBER') { + const assignment = await ctx.prisma.assignment.findFirst({ + where: { + projectId: input.id, + userId: ctx.user.id, + }, + }) + + if (!assignment) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not assigned to this project', + }) + } + } + + return project + }), + + // Import projects from CSV (admin only) + importCSV: adminProcedure + .input(z.object({ + roundId: z.string(), + projects: z.array(z.object({ + title: z.string(), + teamName: z.string().optional(), + description: z.string().optional(), + tags: z.array(z.string()).optional(), + })), + })) + .mutation(async ({ ctx, input }) => { + const created = await ctx.prisma.project.createMany({ + data: input.projects.map((p) => ({ + ...p, + roundId: input.roundId, + })), + }) + + // Audit log + await ctx.prisma.auditLog.create({ + data: { + userId: ctx.user.id, + action: 'IMPORT', + entityType: 'Project', + detailsJson: { roundId: input.roundId, count: created.count }, + ipAddress: ctx.ip, + }, + }) + + return { imported: created.count } + }), +}) +``` + +### Assignment Router + +```typescript +// src/server/routers/assignment.ts + +import { z } from 'zod' +import { router, adminProcedure, protectedProcedure } from '../trpc' + +export const assignmentRouter = router({ + // List assignments for a round + listByRound: adminProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.prisma.assignment.findMany({ + where: { roundId: input.roundId }, + include: { + user: { select: { id: true, name: true, email: true } }, + project: { select: { id: true, title: true } }, + evaluation: { select: { status: true } }, + }, + }) + }), + + // Get my assignments (for jury) + myAssignments: protectedProcedure + .input(z.object({ roundId: z.string().optional() })) + .query(async ({ ctx, input }) => { + return ctx.prisma.assignment.findMany({ + where: { + userId: ctx.user.id, + ...(input.roundId && { roundId: input.roundId }), + round: { status: 'ACTIVE' }, + }, + include: { + project: { + include: { files: true }, + }, + round: true, + evaluation: true, + }, + }) + }), + + // Create single assignment (admin only) + create: adminProcedure + .input(z.object({ + userId: z.string(), + projectId: z.string(), + roundId: z.string(), + })) + .mutation(async ({ ctx, input }) => { + return ctx.prisma.assignment.create({ + data: { + ...input, + method: 'MANUAL', + createdBy: ctx.user.id, + }, + }) + }), + + // Bulk assign (admin only) + bulkCreate: adminProcedure + .input(z.object({ + assignments: z.array(z.object({ + userId: z.string(), + projectId: z.string(), + roundId: z.string(), + })), + })) + .mutation(async ({ ctx, input }) => { + const result = await ctx.prisma.assignment.createMany({ + data: input.assignments.map((a) => ({ + ...a, + method: 'BULK', + createdBy: ctx.user.id, + })), + skipDuplicates: true, + }) + + return { created: result.count } + }), + + // Delete assignment (admin only) + delete: adminProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + return ctx.prisma.assignment.delete({ + where: { id: input.id }, + }) + }), +}) +``` + +### Evaluation Router + +```typescript +// src/server/routers/evaluation.ts + +import { z } from 'zod' +import { router, protectedProcedure, adminProcedure } from '../trpc' +import { TRPCError } from '@trpc/server' + +export const evaluationRouter = router({ + // Get evaluation for assignment + get: protectedProcedure + .input(z.object({ assignmentId: z.string() })) + .query(async ({ ctx, input }) => { + // Verify ownership or admin + const assignment = await ctx.prisma.assignment.findUniqueOrThrow({ + where: { id: input.assignmentId }, + include: { round: true }, + }) + + if (ctx.user.role === 'JURY_MEMBER' && assignment.userId !== ctx.user.id) { + throw new TRPCError({ code: 'FORBIDDEN' }) + } + + return ctx.prisma.evaluation.findUnique({ + where: { assignmentId: input.assignmentId }, + }) + }), + + // Start evaluation (creates draft) + start: protectedProcedure + .input(z.object({ + assignmentId: z.string(), + formId: z.string(), + })) + .mutation(async ({ ctx, input }) => { + // Verify assignment ownership + const assignment = await ctx.prisma.assignment.findUniqueOrThrow({ + where: { id: input.assignmentId }, + include: { round: true }, + }) + + if (assignment.userId !== ctx.user.id) { + throw new TRPCError({ code: 'FORBIDDEN' }) + } + + // Check if evaluation exists + const existing = await ctx.prisma.evaluation.findUnique({ + where: { assignmentId: input.assignmentId }, + }) + + if (existing) return existing + + return ctx.prisma.evaluation.create({ + data: { + assignmentId: input.assignmentId, + formId: input.formId, + status: 'DRAFT', + }, + }) + }), + + // Autosave evaluation (debounced on client) + autosave: protectedProcedure + .input(z.object({ + id: z.string(), + criterionScoresJson: z.record(z.number()).optional(), + globalScore: z.number().int().min(1).max(10).optional(), + binaryDecision: z.boolean().optional(), + feedbackText: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + const { id, ...data } = input + + // Verify ownership and status + const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({ + where: { id }, + include: { assignment: true }, + }) + + if (evaluation.assignment.userId !== ctx.user.id) { + throw new TRPCError({ code: 'FORBIDDEN' }) + } + + if (evaluation.status === 'SUBMITTED' || evaluation.status === 'LOCKED') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Cannot edit submitted evaluation', + }) + } + + return ctx.prisma.evaluation.update({ + where: { id }, + data: { + ...data, + status: 'DRAFT', + }, + }) + }), + + // Submit evaluation (final) + submit: protectedProcedure + .input(z.object({ + id: z.string(), + criterionScoresJson: z.record(z.number()), + globalScore: z.number().int().min(1).max(10), + binaryDecision: z.boolean(), + feedbackText: z.string().min(1), + })) + .mutation(async ({ ctx, input }) => { + const { id, ...data } = input + + // Verify ownership + const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({ + where: { id }, + include: { + assignment: { + include: { round: true }, + }, + }, + }) + + if (evaluation.assignment.userId !== ctx.user.id) { + throw new TRPCError({ code: 'FORBIDDEN' }) + } + + // Check voting window + const round = evaluation.assignment.round + const now = new Date() + + if (round.status !== 'ACTIVE') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Round is not active', + }) + } + + if (round.votingStartAt && now < round.votingStartAt) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Voting has not started yet', + }) + } + + if (round.votingEndAt && now > round.votingEndAt) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Voting window has closed', + }) + } + + // Submit + const updated = await ctx.prisma.evaluation.update({ + where: { id }, + data: { + ...data, + status: 'SUBMITTED', + submittedAt: new Date(), + }, + }) + + // Mark assignment as completed + await ctx.prisma.assignment.update({ + where: { id: evaluation.assignmentId }, + data: { isCompleted: true }, + }) + + // Audit log + await ctx.prisma.auditLog.create({ + data: { + userId: ctx.user.id, + action: 'SUBMIT_EVALUATION', + entityType: 'Evaluation', + entityId: id, + ipAddress: ctx.ip, + }, + }) + + return updated + }), + + // Get aggregated stats for a project (admin only) + getProjectStats: adminProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + const evaluations = await ctx.prisma.evaluation.findMany({ + where: { + status: 'SUBMITTED', + assignment: { projectId: input.projectId }, + }, + }) + + if (evaluations.length === 0) { + return null + } + + const globalScores = evaluations + .map((e) => e.globalScore) + .filter((s): s is number => s !== null) + + const yesVotes = evaluations.filter((e) => e.binaryDecision === true).length + + return { + totalEvaluations: evaluations.length, + averageGlobalScore: globalScores.reduce((a, b) => a + b, 0) / globalScores.length, + minScore: Math.min(...globalScores), + maxScore: Math.max(...globalScores), + yesVotes, + noVotes: evaluations.length - yesVotes, + yesPercentage: (yesVotes / evaluations.length) * 100, + } + }), +}) +``` + +### File Router + +```typescript +// src/server/routers/file.ts + +import { z } from 'zod' +import { router, adminProcedure, protectedProcedure } from '../trpc' +import { getPresignedUrl, uploadFile } from '@/lib/minio' + +export const fileRouter = router({ + // Get pre-signed download URL + getDownloadUrl: protectedProcedure + .input(z.object({ + bucket: z.string(), + objectKey: z.string(), + })) + .query(async ({ ctx, input }) => { + const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min + return { url } + }), + + // Get pre-signed upload URL (admin only) + getUploadUrl: adminProcedure + .input(z.object({ + projectId: z.string(), + fileName: z.string(), + fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']), + mimeType: z.string(), + size: z.number().int().positive(), + })) + .mutation(async ({ ctx, input }) => { + const bucket = 'mopc-files' + const objectKey = `projects/${input.projectId}/${Date.now()}-${input.fileName}` + + const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600) // 1 hour + + // Create file record + const file = await ctx.prisma.projectFile.create({ + data: { + projectId: input.projectId, + fileType: input.fileType, + fileName: input.fileName, + mimeType: input.mimeType, + size: input.size, + bucket, + objectKey, + }, + }) + + return { + uploadUrl, + file, + } + }), + + // Delete file (admin only) + delete: adminProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const file = await ctx.prisma.projectFile.delete({ + where: { id: input.id }, + }) + + // Note: Actual MinIO deletion could be done here or via background job + + return file + }), +}) +``` + +### Settings Router + +```typescript +// src/server/routers/settings.ts + +import { z } from 'zod' +import { router, adminProcedure, superAdminProcedure } from '../trpc' + +export const settingsRouter = router({ + // Get all settings by category + getByCategory: adminProcedure + .input(z.object({ category: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.prisma.systemSettings.findMany({ + where: { category: input.category }, + orderBy: { key: 'asc' }, + }) + }), + + // Update a setting (super admin only for sensitive settings) + update: superAdminProcedure + .input(z.object({ + key: z.string(), + value: z.string(), + })) + .mutation(async ({ ctx, input }) => { + const setting = await ctx.prisma.systemSettings.update({ + where: { key: input.key }, + data: { + value: input.value, + updatedAt: new Date(), + updatedBy: ctx.user.id, + }, + }) + + // Audit log for sensitive settings changes + await ctx.prisma.auditLog.create({ + data: { + userId: ctx.user.id, + action: 'UPDATE_SETTING', + entityType: 'SystemSettings', + entityId: setting.id, + detailsJson: { key: input.key }, + ipAddress: ctx.ip, + }, + }) + + return setting + }), + + // Test AI connection + testAIConnection: superAdminProcedure + .mutation(async ({ ctx }) => { + // Test OpenAI API connectivity + // Returns success/failure + }), + + // Test email connection + testEmailConnection: superAdminProcedure + .mutation(async ({ ctx }) => { + // Send test email + // Returns success/failure + }), +}) +``` + +### Smart Assignment Endpoints + +```typescript +// Added to src/server/routers/assignment.ts + +// Get AI-suggested assignments +suggestAssignments: adminProcedure + .input(z.object({ + roundId: z.string(), + mode: z.enum(['ai', 'algorithm']).default('algorithm'), + })) + .mutation(async ({ ctx, input }) => { + // Returns suggested assignments with reasoning + // AI mode: Uses GPT with anonymized data + // Algorithm mode: Uses rule-based scoring + }), + +// Preview assignment before applying +previewAssignment: adminProcedure + .input(z.object({ + roundId: z.string(), + assignments: z.array(z.object({ + userId: z.string(), + projectId: z.string(), + })), + })) + .query(async ({ ctx, input }) => { + // Returns coverage stats, balance metrics + }), + +// Apply suggested assignments +applyAssignments: adminProcedure + .input(z.object({ + roundId: z.string(), + assignments: z.array(z.object({ + userId: z.string(), + projectId: z.string(), + reasoning: z.string().optional(), + })), + })) + .mutation(async ({ ctx, input }) => { + // Creates assignments in bulk + // Logs AI suggestions that were accepted + }), +``` + +### Grace Period Router + +```typescript +// src/server/routers/gracePeriod.ts + +export const gracePeriodRouter = router({ + // Grant grace period to a juror + grant: adminProcedure + .input(z.object({ + roundId: z.string(), + userId: z.string(), + projectId: z.string().optional(), // Optional: specific project + extendedUntil: z.date(), + reason: z.string(), + })) + .mutation(async ({ ctx, input }) => { + return ctx.prisma.gracePeriod.create({ + data: { + ...input, + grantedBy: ctx.user.id, + }, + }) + }), + + // List grace periods for a round + listByRound: adminProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.prisma.gracePeriod.findMany({ + where: { roundId: input.roundId }, + include: { + user: { select: { id: true, name: true, email: true } }, + project: { select: { id: true, title: true } }, + }, + }) + }), + + // Revoke grace period + revoke: adminProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + return ctx.prisma.gracePeriod.delete({ + where: { id: input.id }, + }) + }), +}) +``` + +## Authentication Flow + +### Magic Link Implementation + +```typescript +// src/lib/auth.ts + +import NextAuth from 'next-auth' +import EmailProvider from 'next-auth/providers/email' +import { PrismaAdapter } from '@auth/prisma-adapter' +import { prisma } from './prisma' +import { sendMagicLinkEmail } from './email' + +export const authOptions = { + adapter: PrismaAdapter(prisma), + providers: [ + EmailProvider({ + server: { + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }, + from: process.env.EMAIL_FROM, + sendVerificationRequest: async ({ identifier, url }) => { + await sendMagicLinkEmail(identifier, url) + }, + }), + ], + callbacks: { + session: async ({ session, user }) => { + if (session.user) { + // Add user id and role to session + const dbUser = await prisma.user.findUnique({ + where: { email: user.email! }, + }) + + session.user.id = dbUser?.id ?? user.id + session.user.role = dbUser?.role ?? 'JURY_MEMBER' + } + return session + }, + }, + pages: { + signIn: '/login', + verifyRequest: '/verify-email', + error: '/auth-error', + }, +} + +export const { handlers, auth, signIn, signOut } = NextAuth(authOptions) +``` + +## Error Handling + +### Standard Error Codes + +| Code | HTTP Status | Usage | +|------|-------------|-------| +| `BAD_REQUEST` | 400 | Invalid input, validation errors | +| `UNAUTHORIZED` | 401 | Not logged in | +| `FORBIDDEN` | 403 | Insufficient permissions | +| `NOT_FOUND` | 404 | Resource doesn't exist | +| `CONFLICT` | 409 | Duplicate entry, state conflict | +| `INTERNAL_SERVER_ERROR` | 500 | Unexpected error | + +### Error Response Format + +```typescript +{ + error: { + message: "Voting window has closed", + code: "BAD_REQUEST", + data: { + zodError: null, // Present if validation error + path: "evaluation.submit", + } + } +} +``` + +## Client Usage + +```typescript +// In React component +import { trpc } from '@/lib/trpc/client' + +function ProjectList() { + const { data, isLoading, error } = trpc.project.list.useQuery({ + roundId: 'round-123', + page: 1, + }) + + const submitMutation = trpc.evaluation.submit.useMutation({ + onSuccess: () => { + // Handle success + }, + onError: (error) => { + // Handle error + }, + }) + + // ... +} +``` + +## Related Documentation + +- [Database Design](./database.md) - Schema that powers the API +- [Infrastructure](./infrastructure.md) - How the API is deployed diff --git a/docs/architecture/database.md b/docs/architecture/database.md new file mode 100644 index 0000000..6f2816c --- /dev/null +++ b/docs/architecture/database.md @@ -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 diff --git a/docs/architecture/infrastructure.md b/docs/architecture/infrastructure.md new file mode 100644 index 0000000..d28a6e4 --- /dev/null +++ b/docs/architecture/infrastructure.md @@ -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 +``` + +### 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 diff --git a/docs/architecture/ui.md b/docs/architecture/ui.md new file mode 100644 index 0000000..291ebf5 --- /dev/null +++ b/docs/architecture/ui.md @@ -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 ( +
+ {/* Desktop Sidebar */} + + + {/* Main Content Area */} +
+
+
{children}
+
+ + {/* Mobile Bottom Navigation */} + +
+ ) +} +``` + +## 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 ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) + } + + return +} +``` + +### Error States + +```typescript +// Consistent error display +function ErrorState({ + title = 'Something went wrong', + message, + onRetry, +}: { + title?: string + message: string + onRetry?: () => void +}) { + return ( +
+ +

{title}

+

{message}

+ {onRetry && ( + + )} +
+ ) +} +``` + +### Empty States + +```typescript +function EmptyState({ + icon: Icon, + title, + description, + action, +}: { + icon: React.ComponentType<{ className?: string }> + title: string + description: string + action?: React.ReactNode +}) { + return ( +
+ +

{title}

+

{description}

+ {action &&
{action}
} +
+ ) +} +``` + +### Responsive Patterns + +```typescript +// Table on desktop, cards on mobile +function ProjectDisplay({ projects }: { projects: Project[] }) { + return ( + <> + {/* Desktop: Table */} +
+ +
+ + {/* Mobile: Cards */} +
+ {projects.map((project) => ( + + ))} +
+ + ) +} +``` + +## Touch Targets + +All interactive elements must have a minimum touch target of 44x44px on mobile: + +```typescript +// Good: Large touch target + + +// Good: Icon button with padding + +``` + +## 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 ( +
{ + debouncedSave(data) + }} + > + {/* Form fields */} +
+ {autosave.isPending ? 'Saving...' : 'Autosaved'} +
+
+ ) +} +``` + +### 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>({ + resolver: zodResolver(evaluationSchema), + }) + + return ( +
+ ( + + Feedback + +