# InvestPlay — Architecture ## System Overview ```mermaid 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
Multi-schema")] REDIS[("Redis 7
Cache + Queue")] S3[("S3 Storage
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 ```mermaid 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: ```typescript // 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 ```mermaid 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 ```mermaid 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 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 ```json { "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 ```graphql 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 ```mermaid 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 ```mermaid flowchart LR subgraph Producers API[NestJS API] WORKER_SCHED["Scheduled Tasks
@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 ```mermaid flowchart LR CMS[Payload CMS
Author writes lesson] API[NestJS API
Serves content] FRONT[React Frontend
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 ```typescript // 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 ; }); } ``` ## Database Schema Relationships ### Core Entities ```mermaid 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 ```mermaid flowchart LR subgraph Layer1[Network Edge] DDoS[DDoS Protection
Cloudflare] WAF[WAF Rules
Cloudflare] DNS[DNS Security
Cloudflare] end subgraph Layer2[Reverse Proxy] TLS[TLS 1.3
Traefik] RATE[Rate Limiting
Traefik Middleware] HEADERS[Security Headers
Helmet.js] CORS[CORS Policy] end subgraph Layer3[Application] JWT[JWT RS256
Auth Guard] RBAC[Role-Based Access
Roles Guard] TENANT[Tenant Isolation
Tenant Guard] DEPTH[Query Depth
7 Level Max] VALID[Input Validation
Zod Schemas] end subgraph Layer4[Data] ENCRYPT[Encryption at Rest
PostgreSQL TDE] BACKUP[Backup + Point-in-Time
Recovery] AUDIT[Audit Logging
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 ```mermaid graph TB subgraph Internet USER[User Browser] end subgraph Contabo_VPS["Contabo VPS (Single Node)"] subgraph Traefik T1["Traefik v3
Port 443 (TLS)"] T2["Port 80 Redirect
-> 443"] end subgraph Docker_Services API["api:3001
NestJS"] WEB["web:80
nginx SPA"] CMS["cms:3000
Payload CMS"] PG["postgres:5432
PostgreSQL 16"] RD["redis:6379
Redis 7"] MINIO["minio:9000
S3 Storage"] end subgraph External_Access PA["Portainer Agent
: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 ```mermaid 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
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`, `` | | Web | `ghcr.io/investplay/web` | `latest`, `` | | CMS | `ghcr.io/investplay/cms` | `latest`, `` | 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](./CONTEXT.md) for the full project context, tech stack, and development workflow.