Files
InvestPlay/docs/ARCHITECTURE.md
Lefteris Notas e75f913f1c
Some checks failed
CI / Lint (push) Has been cancelled
CI / TypeCheck (push) Has been cancelled
CI / Test (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/api/Dockerfile name:api]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/cms/Dockerfile name:cms]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/web/Dockerfile name:web]) (push) Has been cancelled
CI / Build (push) Has been cancelled
Deploy / Deploy (push) Has been cancelled
feat: complete Phase 1 foundation scaffold
- Monorepo: Turborepo + pnpm workspaces with 7 apps/packages
- Backend: NestJS scaffold with 9 modules (auth, tenant, curriculum, simulation, portfolio, ai-coach, analytics, classroom, gamification)
- Frontend: React 18 + Vite + TailwindCSS + shadcn/ui with 10 pages, 14 UI primitives, 3 Zustand stores
- Database: Prisma schema with 19 models, 13 enums, seed script, multi-tenant ready
- Docker: Dev, production, and Portainer Compose files with Traefik reverse proxy
- Configuration: .env.example (47 vars), Zod validation, frontend-safe env exposure
- i18n: English + Greek locale files (10 files), ICU MessageFormat, react-i18next
- Shared packages: @investplay/types, @investplay/ui, @investplay/i18n, @investplay/utils
- CI/CD: GitHub Actions (lint, typecheck, test, build, deploy, PR checks)
- Documentation: CONTEXT.md, ARCHITECTURE.md (10 Mermaid diagrams), API.md, LOCALIZATION.md, DEVELOPMENT-ROADMAP.md
- Infrastructure: Dockerfiles (dev + prod), nginx configs, backup/healthcheck scripts
- Security: JWT auth guards, role-based access, rate limiting, Helmet, CORS, PII sanitization
2026-06-12 19:32:57 +03:00

19 KiB

InvestPlay — Architecture

System Overview

graph TB
    subgraph Clients
        WEB["Web SPA (React + Vite)"]
        MOBILE["Mobile (Capacitor)"]
        DESKTOP["Desktop (Tauri)"]
    end

    subgraph Edge
        CF["Cloudflare (DNS + CDN)"]
        TRAEFIK["Traefik (Reverse Proxy + SSL)"]
    end

    subgraph Backend_Services
        API["NestJS API Server"]
        WS["Socket.io Gateway"]
        WORKER["BullMQ Workers"]
        GRAPHQL["Apollo GraphQL"]
    end

    subgraph Data_Layer
        PG[("PostgreSQL 16<br/>Multi-schema")]
        REDIS[("Redis 7<br/>Cache + Queue")]
        S3[("S3 Storage<br/>MinIO / R2")]
    end

    subgraph External
        CLERK["Clerk Auth"]
        STRIPE["Stripe Connect"]
        RESEND["Resend Email"]
        LLM["OpenAI / Gemini"]
        CMS_EXTERNAL["Payload CMS"]
    end

    WEB --> CF
    MOBILE --> CF
    DESKTOP --> CF
    CF --> TRAEFIK
    TRAEFIK --> API
    TRAEFIK --> WS

    API --> GRAPHQL
    API --> WS
    API --> WORKER

    GRAPHQL --> PG
    WS --> REDIS
    WORKER --> REDIS
    WORKER --> PG

    API --> CLERK
    API --> STRIPE
    API --> RESEND
    API --> LLM
    API --> CMS_EXTERNAL

    MOBILE -.-> CLERK

Request Flow

Typical API Request

sequenceDiagram
    participant Client as Browser / Mobile
    participant CF as Cloudflare
    participant Traefik as Traefik Proxy
    participant Nest as NestJS API
    participant Guard as Auth Guard
    participant Tenant as Tenant Interceptor
    participant Resolver as GraphQL Resolver
    participant Prisma as Prisma ORM
    participant DB as PostgreSQL

    Client->>CF: HTTPS Request
    CF->>Traefik: Proxy Request
    Traefik->>Nest: Reverse Proxy

    Note over Nest: Middleware Pipeline

    Nest->>Guard: JWT Validation
    Guard->>Guard: Verify RS256 Signature
    Guard->>Guard: Extract tenantId from JWT
    Guard-->>Nest: User + Tenant Context

    Nest->>Tenant: Set TenantContext
    Tenant->>Tenant: AsyncLocalStorage.set(tenantId)
    Tenant-->>Nest: Context Ready

    Nest->>Resolver: Execute Query/Mutation
    Resolver->>Prisma: Query with tenant scope
    Prisma->>DB: SELECT * FROM "tenant_schema"."table"
    DB-->>Prisma: Results
    Prisma-->>Resolver: Data

    Resolver-->>Nest: Response
    Nest-->>Traefik: JSON Response
    Traefik-->>CF: Proxy Response
    CF-->>Client: Response

Multi-tenancy Deep Dive

Approach: Schema-level Isolation

Each tenant (institution) receives an isolated PostgreSQL schema. This provides stronger data separation than row-level security (RLS) while sharing the same database server for operational simplicity.

Tenant Resolution Pipeline

  1. JWT issuance: Auth service embeds tenantId claim in the JWT access token at login
  2. HTTP request arrives: Traefik proxies to NestJS
  3. JwtAuthGuard: Extracts tenantId from validated token payload
  4. TenantInterceptor: Sets TenantContext in Node.js AsyncLocalStorage
  5. Prisma middleware: Intercepts all queries and rewrites them to the correct schema:
    // Conceptual — Prisma middleware applies schema filter
    prisma.$extends({
      query: {
        $allModels: {
          async $allOperations({ model, operation, args, query }) {
            const tenantId = TenantContext.get();
            if (tenantId && !args.where) args.where = {};
            args.where.tenantId = tenantId;
            return query(args);
          },
        },
      },
    });
    
  6. Post-Schema routing: Prisma connects to the tenant-specific schema via schema parameter in DATABASE_URL

Tenant Provisioning

flowchart LR
    A[New Institution Signs Up] --> B[Create Tenant Record]
    B --> C[Provision PostgreSQL Schema]
    C --> D[Run Migrations on Schema]
    D --> E[Create Admin User]
    E --> F[Configure Feature Flags]
    F --> G[Tenant Ready]

Shared vs Isolated Data

Data Location Isolation
User profiles, roles Per-tenant schema Full
Lesson progress, grades Per-tenant schema Full
Virtual portfolios, trades Per-tenant schema Full
AI interaction logs Per-tenant schema Full
Curriculum content Shared (public schema) Read-only
Platform configuration Shared (public schema) Read-only
Email templates Shared (public schema) Read-only
Badges, achievements Shared (public schema) Read-only

Authentication Flow

sequenceDiagram
    participant User
    participant Client
    participant API
    participant Clerk
    participant DB

    User->>Client: Click "Sign In"
    Client->>Clerk: Redirect to Clerk Hosted UI
    User->>Clerk: Enter credentials / Social Login
    Clerk-->>Client: Authorization code
    Client->>API: POST /auth/callback (code)
    API->>Clerk: Verify code
    Clerk-->>API: User info + external ID
    API->>DB: Find or create user
    API->>API: Generate JWT (RS256 signed)
    Note over API: Payload: userId, tenantId, roles, schoolId
    API-->>Client: { accessToken, refreshToken, user }
    Client->>Client: Store tokens (memory + httpOnly cookie)

    Note over Client,API: Subsequent requests

    Client->>API: Authorization: Bearer <accessToken>
    API->>API: Verify JWT signature
    API->>API: Check expiry
    API-->>Client: 200 OK | 401 Unauthorized

    Note over Client,API: Token Refresh

    Client->>API: POST /auth/refresh (refreshToken)
    API->>API: Verify refresh token
    API-->>Client: { accessToken, refreshToken }

JWT Token Structure

{
  "sub": "user_2abc123",
  "tenantId": "tenant_45def",
  "roles": ["student", "premium"],
  "schoolId": "school_78ghi",
  "iat": 1718000000,
  "exp": 1718000900
}
  • Algorithm: RS256 (asymmetric) — private key signs, public key verifies
  • Access token TTL: 15 minutes (production) / 7 days (development)
  • Refresh token TTL: 7 days (production) / 30 days (development)
  • Storage: Access token in memory (Zustand store), refresh token in httpOnly cookie

GraphQL Schema Design Principles

Structure

  • Single endpoint: POST /graphql for all queries and mutations
  • Namespace by module: auth { ... }, curriculum { ... }, simulation { ... }
  • Pagination: Relay-style Connection pattern with first/after cursors

Design Rules

  1. Every query must be nullable-safe — return null or [] never throw
  2. Mutations return a union type: SuccessPayload | ErrorPayload
  3. Depth limited to 7 levels (enforced via graphql-depth-limit)
  4. Query complexity analysis for cost-based rate limiting

Example Schema Excerpt

type Query {
  me: User
  lesson(id: ID!): Lesson
  lessons(moduleId: ID!, first: Int, after: String): LessonConnection
  portfolio(simulationId: ID!): Portfolio
  leaderboard(classroomId: ID!, timeRange: TimeRange): [LeaderboardEntry!]
}

type Mutation {
  startLesson(lessonId: ID!): LessonSessionPayload
  submitQuiz(lessonId: ID!, answers: [AnswerInput!]!): QuizResultPayload
  executeTrade(simulationId: ID!, input: TradeInput!): TradePayload
  askAICoach(message: String!, contextId: ID): AICoachPayload
}

type User {
  id: ID!
  email: String!
  displayName: String!
  locale: String!
  xp: Int!
  level: Int!
  streak: Int!
  roles: [Role!]!
  progress: UserProgress
}

WebSocket Architecture

Namespaces

Namespace Authentication Purpose
/simulations JWT handshake Stream historical price ticks
/ai-coach JWT handshake Stream AI chat responses
/classroom JWT handshake + teacher role Live classroom broadcasts

Connection Lifecycle

sequenceDiagram
    participant Client
    participant WS as Socket.io Gateway
    participant Auth as AuthGuard
    participant Redis as Redis Adapter
    participant Room as Room Manager

    Client->>WS: Connect (auth: { token })
    WS->>Auth: Verify JWT
    Auth-->>WS: { userId, tenantId, roles }
    WS->>Redis: Register connection
    WS-->>Client: Connected (socket.id)

    Client->>WS: join(simulation:sim_123)
    WS->>Room: Add to room
    WS-->>Client: Joined room

    Note over Client,Room: Tick streaming begins

    Client->>WS: leave(simulation:sim_123)
    WS->>Room: Remove from room
    WS-->>Client: Left room

    Client->>WS: Disconnect
    WS->>Redis: Unregister connection

Simulation Tick Streaming

  1. Teacher or timer starts a simulation scenario
  2. BullMQ worker loads historical CSV data for the scenario
  3. Worker emits ticks to Redis pub/sub channel at configured speed (1x, 2x, 5x)
  4. Socket.io gateway subscribes to Redis channel and broadcasts to room members
  5. Each tick: { timestamp, price, volume, indicator }
  6. Frontend updates charts in real-time via Recharts

Background Job Architecture

BullMQ Queues

Queue Concurrency Description
simulation-tick 5 Generate and distribute simulation price ticks
portfolio-valuation 3 End-of-day portfolio P&L calculation
email 10 Send transactional emails via Resend
analytics-rollup 2 Nightly analytics materialized view refresh
leaderboard 1 Recalculate leaderboard rankings
ai-audit-log 20 Persist AI interaction logs asynchronously
cleanup 1 Soft-delete expired sessions, rotate logs

Queue Architecture

flowchart LR
    subgraph Producers
        API[NestJS API]
        WORKER_SCHED["Scheduled Tasks<br/>@nestjs/schedule"]
    end

    subgraph Redis
        QUEUES[(BullMQ Queues)]
    end

    subgraph Consumers
        QW1[Worker: simulation-tick]
        QW2[Worker: portfolio-valuation]
        QW3[Worker: email]
        QW4[Worker: analytics-rollup]
    end

    API --> QUEUES
    WORKER_SCHED --> QUEUES
    QUEUES --> QW1
    QUEUES --> QW2
    QUEUES --> QW3
    QUEUES --> QW4

Each worker runs in the same NestJS process via @nestjs/bullmq or can be spun as a separate process for scale.

Content Rendering Pipeline

flowchart LR
    CMS[Payload CMS<br/>Author writes lesson]
    API[NestJS API<br/>Serves content]
    FRONT[React Frontend<br/>Renders blocks]
    USER[User sees content]

    CMS -->|REST API| API
    API -->|GraphQL query| FRONT
    FRONT -->|BlockRenderer maps types| USER

    subgraph Block Types
        HERO[Hero]
        TEXT[Text]
        QUIZ[Quiz]
        CALC[Calculator]
        BUDGET[BudgetGame]
        TRADE[TradeSimulation]
    end

    FRONT --> HERO
    FRONT --> TEXT
    FRONT --> QUIZ
    FRONT --> CALC
    FRONT --> BUDGET
    FRONT --> TRADE

Block Renderer Component

// Conceptual component structure
const BLOCK_COMPONENTS = {
  hero: HeroBlock,
  text: TextBlock,
  quiz: QuizBlock,
  calculator: CalculatorBlock,
  budgetGame: BudgetGameBlock,
  tradeSimulation: TradeSimulationBlock,
} as const;

function BlockRenderer({ blocks }: { blocks: ContentBlock[] }) {
  return blocks.map((block) => {
    const Component = BLOCK_COMPONENTS[block.type];
    return <Component key={block.id} {...block.props} />;
  });
}

Database Schema Relationships

Core Entities

erDiagram
    Tenant ||--o{ User : "belongs to"
    Tenant ||--o{ Classroom : "contains"
    Tenant ||--o{ Module : "owns"
    User ||--o{ UserProgress : "tracks"
    User ||--o{ QuizAttempt : "takes"
    User ||--o{ Trade : "executes"
    User ||--o{ UserBadge : "earns"
    User ||--o{ AILog : "generates"
    User ||--o{ ClassroomStudent : "enrolled in"
    Classroom ||--o{ ClassroomStudent : "has"
    Classroom ||--o{ Assignment : "assigns"
    Module ||--o{ Lesson : "contains"
    Lesson ||--o{ UserProgress : "tracked by"
    Lesson ||--o{ QuizAttempt : "tested by"
    Simulation ||--o{ Trade : "contains"
    Simulation ||--o{ Portfolio : "tracks"
    Portfolio ||--o{ Trade : "records"
    Badge ||--o{ UserBadge : "awarded"

Entity Descriptions

Entity Key Fields Relationships
tenants id, name, domain, branding, ai_mode, feature_flags Parent to users, classrooms, modules
users id, email, role, locale, tenant_id, xp, level, streak Child of tenant; parent to progress, trades, badges
classrooms id, name, tenant_id, teacher_id, invite_code Child of tenant; parent to classroom_students
classroom_students id, classroom_id, user_id, joined_at Join table classroom <-> user
modules id, cms_id, track, order, tenant_id Child of tenant; parent to lessons
lessons id, cms_id, module_id, order, estimated_minutes Child of module; parent to progress
user_progress id, user_id, lesson_id, status, score, completed_at Tracks lesson completion
quiz_attempts id, user_id, quiz_id, answers, score, timestamp Stores quiz submissions
simulations id, scenario, config, status, started_at Manages simulation sessions
portfolios id, user_id, simulation_id, cash_balance, holdings Virtual portfolio state
trades id, user_id, simulation_id, asset, amount, price, timestamp Trade execution records
badges id, name, description, icon, criteria Badge definitions
user_badges id, user_id, badge_id, earned_at Awarded badges
ai_logs id, tenant_id, user_id, tokens_used, model, timestamp AI usage audit trail
analytics_events id, user_id, event_type, metadata, timestamp Behavioral telemetry

Security Architecture

Defense in Depth

flowchart LR
    subgraph Layer1[Network Edge]
        DDoS[DDoS Protection<br/>Cloudflare]
        WAF[WAF Rules<br/>Cloudflare]
        DNS[DNS Security<br/>Cloudflare]
    end

    subgraph Layer2[Reverse Proxy]
        TLS[TLS 1.3<br/>Traefik]
        RATE[Rate Limiting<br/>Traefik Middleware]
        HEADERS[Security Headers<br/>Helmet.js]
        CORS[CORS Policy]
    end

    subgraph Layer3[Application]
        JWT[JWT RS256<br/>Auth Guard]
        RBAC[Role-Based Access<br/>Roles Guard]
        TENANT[Tenant Isolation<br/>Tenant Guard]
        DEPTH[Query Depth<br/>7 Level Max]
        VALID[Input Validation<br/>Zod Schemas]
    end

    subgraph Layer4[Data]
        ENCRYPT[Encryption at Rest<br/>PostgreSQL TDE]
        BACKUP[Backup + Point-in-Time<br/>Recovery]
        AUDIT[Audit Logging<br/>All Data Access]
    end

    DDoS --> WAF
    WAF --> DNS
    DNS --> TLS
    TLS --> RATE
    RATE --> HEADERS
    HEADERS --> CORS
    CORS --> JWT
    JWT --> RBAC
    RBAC --> TENANT
    TENANT --> DEPTH
    DEPTH --> VALID
    VALID --> ENCRYPT
    ENCRYPT --> BACKUP
    BACKUP --> AUDIT

Security Measures by Layer

Layer Measure Implementation
Edge DDoS protection Cloudflare Always-on
Edge WAF Cloudflare WAF with OWASP ruleset
Edge DNS Cloudflare DNS with DNSSEC
Proxy TLS 1.3 Traefik auto-cert via Let's Encrypt
Proxy HSTS Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Proxy Rate limiting 100 req/s average, 50 burst, per-IP via Traefik middleware
Proxy Security headers Helmet.js: X-Frame-Options, X-Content-Type-Options, CSP, Permissions-Policy
Proxy CORS Whitelist per tenant domain, credentials allowed
App Auth JWT RS256, short-lived access tokens (15m), httpOnly refresh cookies
App RBAC @Roles() decorator guards on every protected resolver
App Tenant isolation @TenantScoped() guard enforces cross-tenant access prevention
App GraphQL safety Depth limit (7), query complexity analysis, alias limiting
App Input validation Zod schemas validated on every API boundary
App Prompt injection Regex/ML-based sanitization before LLM calls
Data Encryption at rest PostgreSQL TDE or EBS encryption
Data Backup Daily automated backups, 30-day retention, Point-in-Time Recovery
Data Audit All data mutations logged with actor, timestamp, diff

Deployment Architecture

Production Topology

graph TB
    subgraph Internet
        USER[User Browser]
    end

    subgraph Contabo_VPS["Contabo VPS (Single Node)"]
        subgraph Traefik
            T1["Traefik v3<br/>Port 443 (TLS)"]
            T2["Port 80 Redirect<br/>-> 443"]
        end

        subgraph Docker_Services
            API["api:3001<br/>NestJS"]
            WEB["web:80<br/>nginx SPA"]
            CMS["cms:3000<br/>Payload CMS"]
            PG["postgres:5432<br/>PostgreSQL 16"]
            RD["redis:6379<br/>Redis 7"]
            MINIO["minio:9000<br/>S3 Storage"]
        end

        subgraph External_Access
            PA["Portainer Agent<br/>:9001"]
        end

        subgraph Volumes
            V_PG["pgdata"]
            V_RD["redis-data"]
            V_MI["minio-data"]
            V_LE["letsencrypt"]
        end
    end

    USER -->|HTTPS| T1
    USER -->|HTTP| T2

    T1 -->|api.investplay.app| API
    T1 -->|investplay.app| WEB
    T1 -->|cms.investplay.app| CMS

    API --> PG
    API --> RD
    API --> MINIO
    CMS --> PG
    CMS --> MINIO

    PA -.->|Docker socket| T1

    PG --> V_PG
    RD --> V_RD
    MINIO --> V_MI
    T1 --> V_LE

CI/CD Pipeline

flowchart LR
    PUSH["Push to main"] --> CI
    PR["Pull Request"] --> CI

    subgraph CI[GitHub Actions CI]
        LINT[lint]
        TYPECHECK[typecheck]
        TEST[test]
        BUILD[build]
        LINT --> BUILD
        TYPECHECK --> BUILD
        TEST --> BUILD
    end

    CI -->|on main| DEPLOY_TRIGGER

    subgraph CD[GitHub Actions Deploy]
        DOCKER["Docker Build & Push<br/>api, web, cms"]
        TAG["Tag: git-sha, latest"]
        PUSH_GHCR["Push to GHCR"]
    end

    CD -->|Portainer webhook| PORTAINER

    subgraph PROD[Contabo Server]
        PORTAINER["Portainer Agent"]
        PULL["docker compose pull"]
        UP["docker compose up -d"]
        HEALTH["Health Check"]
    end

    DEPLOY_TRIGGER --> DOCKER
    DOCKER --> TAG
    TAG --> PUSH_GHCR

Docker Image Tags

Service Image Tags
API ghcr.io/investplay/api latest, <git-sha>
Web ghcr.io/investplay/web latest, <git-sha>
CMS ghcr.io/investplay/cms latest, <git-sha>

Layer caching via GitHub Actions cache-from/cache-to with type=gha for each service separately.

Scaling Considerations

  • Current: Single VPS (Contabo) with Docker Compose — sufficient for MVP to thousands of users
  • Short-term (Phase 3-4): Separate API workers per CPU core, Redis cluster for pub/sub
  • Medium-term (Phase 6-7): Horizontal API scaling behind Traefik load balancer, read replicas for PostgreSQL, separate BullMQ worker processes
  • Long-term: Kubernetes migration, multi-region deployment for EU data residency, database sharding

See CONTEXT.md for the full project context, tech stack, and development workflow.