feat: complete Phase 1 foundation scaffold
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

- 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
This commit is contained in:
Lefteris Notas
2026-06-12 19:32:57 +03:00
parent fa71c60cfc
commit e75f913f1c
226 changed files with 17547 additions and 0 deletions

472
docs/API.md Normal file
View File

@@ -0,0 +1,472 @@
# InvestPlay — API Documentation
## Base URL Structure
| Environment | Base URL |
|-------------|----------|
| Local development | `http://localhost:3001` |
| Production | `https://api.investplay.app` |
| Staging | `https://api.staging.investplay.app` |
## Authentication
### JWT Bearer Token
All authenticated requests require an `Authorization` header:
```
Authorization: Bearer <accessToken>
```
Access tokens are RS256-signed JWTs issued by the auth service. They are short-lived (15 minutes in production, 7 days in development).
### Token Refresh
When the access token expires, use the refresh token to obtain a new pair:
```
POST /auth/refresh
Content-Type: application/json
Cookie: refreshToken=<httpOnly-cookie>
Response 200:
{
"accessToken": "eyJhbG...",
"expiresIn": 900
}
```
The refresh token is stored in an httpOnly, Secure, SameSite=Strict cookie set at login. If the refresh token is also expired, the user must re-authenticate.
### Token Errors
| Status | Error Code | Description |
|--------|-----------|-------------|
| 401 | `TOKEN_EXPIRED` | Access token has expired; refresh required |
| 401 | `TOKEN_INVALID` | Token signature verification failed |
| 401 | `TOKEN_MISSING` | No Authorization header provided |
| 403 | `INSUFFICIENT_ROLE` | User lacks required role for this operation |
## GraphQL Endpoint
**URL:** `POST /graphql`
**Content-Type:** `application/json`
### Example Query
```graphql
query GetLessonProgress {
me {
id
displayName
xp
level
progress {
completedLessons
totalLessons
currentStreak
}
}
lesson(id: "lesson_abc123") {
title
estimatedMinutes
blocks {
... on HeroBlock {
headline
subtitle
imageUrl
}
... on TextBlock {
body
}
... on QuizBlock {
question
options
correctAnswer
}
}
}
}
```
### Example Mutation
```graphql
mutation SubmitQuiz {
submitQuiz(
lessonId: "lesson_abc123",
answers: [
{ questionId: "q1", selectedOption: 2 },
{ questionId: "q2", selectedOption: 0 }
]
) {
... on QuizResultSuccess {
score
totalQuestions
correctAnswers
xpEarned
passed
}
... on QuizResultError {
code
message
}
}
}
```
### Response Format
```json
{
"data": { ... },
"errors": [
{
"message": "Validation failed",
"extensions": {
"code": "VALIDATION_ERROR",
"field": "answers",
"details": "Each answer must include a valid questionId"
}
}
]
}
```
### Error Codes
| Extension Code | HTTP Status | Description |
|---------------|-------------|-------------|
| `UNAUTHENTICATED` | 401 | Not authenticated |
| `FORBIDDEN` | 403 | Insufficient permissions |
| `VALIDATION_ERROR` | 400 | Input validation failed |
| `NOT_FOUND` | 404 | Requested resource not found |
| `RATE_LIMITED` | 429 | Too many requests |
| `AI_QUOTA_EXCEEDED` | 429 | AI token budget exhausted for user/tenant |
| `INTERNAL_ERROR` | 500 | Unexpected server error |
## REST Endpoints
### Health Check
```
GET /health
Response 200:
{
"status": "ok",
"uptime": 3600,
"timestamp": "2026-06-12T16:00:00Z",
"services": {
"database": "healthy",
"redis": "healthy",
"storage": "healthy",
"cms": "healthy"
}
}
```
Used by Docker health checks and monitoring.
### Auth Callback (Clerk)
```
POST /auth/callback
Content-Type: application/json
Request:
{
"code": "oauth_code_from_clerk",
"redirectUri": "https://app.investplay.app/auth/callback"
}
Response 200:
{
"accessToken": "eyJhbG...",
"refreshToken": "eyJhbG...",
"user": {
"id": "user_abc",
"email": "student@example.com",
"displayName": "Alex",
"locale": "en",
"onboardingComplete": false
}
}
```
### Auth Logout
```
POST /auth/logout
Response 200:
{
"message": "Logged out successfully"
}
```
Clears refresh token cookie and invalidates the session.
### Stripe Webhook
```
POST /webhooks/stripe
Content-Type: application/json
Stripe-Signature: <webhook signature>
Handles: checkout.session.completed, invoice.paid, customer.subscription.updated
Response 200: { "received": true }
```
### Clerk Webhook
```
POST /webhooks/clerk
Content-Type: application/json
Svix-Signature: <webhook signature>
Handles: user.created, user.updated, user.deleted, session.created
Response 200: { "received": true }
```
### Resend Webhook
```
POST /webhooks/resend
Content-Type: application/json
Handles: email.delivered, email.bounced, email.complained
Response 200: { "received": true }
```
## WebSocket Endpoints
### Simulation Tick Stream
```
WebSocket: wss://api.investplay.app/simulations
Auth: { token: "<accessToken>" }
```
**Events:**
| Event | Direction | Payload | Description |
|-------|-----------|---------|-------------|
| `join` | Client -> Server | `{ simulationId: string }` | Join a simulation room |
| `leave` | Client -> Server | `{ simulationId: string }` | Leave a simulation room |
| `tick` | Server -> Client | `{ timestamp, price, volume, indicator }` | Market price tick |
| `status` | Server -> Client | `{ status, speed, elapsed }` | Simulation status change |
| `complete` | Server -> Client | `{ finalPnl, totalTrades, roi }` | Simulation ended |
### AI Coach Stream
```
WebSocket: wss://api.investplay.app/ai-coach
Auth: { token: "<accessToken>" }
```
**Events:**
| Event | Direction | Payload | Description |
|-------|-----------|---------|-------------|
| `message` | Client -> Server | `{ content, contextId?, lessonId? }` | Send chat message |
| `response` | Server -> Client | `{ content, done, tokensUsed }` | Streaming AI response chunk |
| `error` | Server -> Client | `{ code, message }` | Error (quota exceeded, etc.) |
### Classroom Broadcast
```
WebSocket: wss://api.investplay.app/classroom
Auth: { token: "<accessToken>" }
```
**Events:**
| Event | Direction | Payload | Description |
|-------|-----------|---------|-------------|
| `join` | Client -> Server | `{ classroomId: string }` | Join classroom room |
| `broadcast` | Teacher -> Server | `{ type, payload }` | Teacher broadcasts event |
| `event` | Server -> Student | `{ type, payload, timestamp }` | Event delivered to students |
## Rate Limiting
### Limits
| Endpoint | Window | Limit | Scope |
|----------|--------|-------|-------|
| `/graphql` | 60 seconds | 100 requests | Per IP address |
| `/auth/*` | 60 seconds | 20 requests | Per IP address |
| `/webhooks/*` | 60 seconds | 50 requests | Per IP address |
| AI Chat | 1 day | 20 messages (default) | Per user |
| AI Tokens | 1 month | 500,000 tokens (default) | Per tenant |
### Rate Limit Headers
All responses include rate limit headers:
```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1718123456
```
When exceeded:
```
Status: 429 Too Many Requests
Retry-After: 45
X-RateLimit-Reset: 1718123456
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please wait 45 seconds.",
"retryAfter": 45
}
}
```
AI-specific limits return:
```
Status: 429 Too Many Requests
X-AI-Quota-Type: daily | monthly
X-AI-Quota-Reset: 1718123456
{
"error": {
"code": "AI_QUOTA_EXCEEDED",
"message": "Daily AI message limit reached. Resets in 4 hours.",
"quotaType": "daily",
"resetsAt": "2026-06-13T00:00:00Z"
}
}
```
## Pagination
All list queries follow the Relay Connection specification:
```graphql
type LessonConnection {
edges: [LessonEdge!]!
pageInfo: PageInfo!
}
type LessonEdge {
node: Lesson!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
```
### Pagination Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `first` | Int | 20 | Number of items to fetch (max: 100) |
| `after` | String | null | Cursor for forward pagination |
| `last` | Int | null | Number of items backward (use with `before`) |
| `before` | String | null | Cursor for backward pagination |
### Example with Pagination
```graphql
query GetLessons {
lessons(
moduleId: "module_xyz",
first: 10,
after: "cursor_abc"
) {
edges {
cursor
node {
id
title
order
estimatedMinutes
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
```
## Error Codes
### Standard Error Format
```json
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description",
"details": {},
"requestId": "req_abc123"
}
}
```
### Global Error Codes
| Code | HTTP Status | Description |
|------|-------------|-------------|
| `VALIDATION_ERROR` | 400 | Input validation failed |
| `UNAUTHENTICATED` | 401 | Authentication required |
| `TOKEN_EXPIRED` | 401 | Access token expired |
| `TOKEN_INVALID` | 401 | Invalid or malformed token |
| `FORBIDDEN` | 403 | Insufficient permissions |
| `NOT_FOUND` | 404 | Resource not found |
| `CONFLICT` | 409 | Resource conflict (duplicate) |
| `RATE_LIMITED` | 429 | Rate limit exceeded |
| `AI_QUOTA_EXCEEDED` | 429 | AI budget exhausted |
| `TENANT_QUOTA_EXCEEDED` | 429 | Tenant-level quota exceeded |
| `INTERNAL_ERROR` | 500 | Unexpected server error |
| `SERVICE_UNAVAILABLE` | 503 | Dependent service unavailable |
### Validation Error Details
```json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Input validation failed",
"details": {
"fields": {
"email": "Invalid email format",
"password": "Password must be at least 8 characters"
}
}
}
}
```
## Versioning Strategy
### API Versioning
GraphQL does not use URL versioning. Instead:
1. **Schema evolution** — Fields and types are added without removal. Deprecated fields are marked with `@deprecated` directive and maintained for a minimum of 90 days.
2. **Breaking changes** — Announced via Changelog and GitHub Releases. A 30-day migration window is provided.
3. **No v1/v2 URLs** — A single `/graphql` endpoint evolves over time. If a breaking change is unavoidable, a new endpoint `/graphql/v2` may be introduced and maintained alongside the original for 6 months.
### REST Versioning
REST endpoints (webhooks, health) are not versioned at the URL level. Breaking changes to webhook payloads would result in a new webhook event type (e.g., `invoice.paid.v2`) with the old event maintained for 90 days.
## Related Documentation
- [ARCHITECTURE.md](./ARCHITECTURE.md) — System architecture and multi-tenancy deep dive
- [CONTEXT.md](./CONTEXT.md) — Project context, tech stack, and development workflow
- [LOCALIZATION.md](./LOCALIZATION.md) — Locale support and i18n API details

638
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,638 @@
# 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<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
```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 <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
```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<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
```mermaid
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
```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 <Component key={block.id} {...block.props} />;
});
}
```
## 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<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
```mermaid
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
```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<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](./CONTEXT.md) for the full project context, tech stack, and development workflow.

320
docs/CONTEXT.md Normal file
View File

@@ -0,0 +1,320 @@
# InvestPlay — Project Context
## Overview
InvestPlay is a cross-platform financial literacy platform targeting users aged 16-25. It combines interactive educational content, realistic market simulations, gamified progression, and AI-powered coaching to teach investing fundamentals. The platform serves both individual learners (B2C) and educational institutions (B2B) through a multi-tenant architecture with schema-level data isolation.
### Vision
Make financial literacy universally accessible and engaging for the next generation of investors, starting with EU markets (Greece initial launch) and expanding globally.
### Target Users
| Persona | Age Range | Platform | Key Need |
|---------|-----------|----------|----------|
| Self-directed learner | 16-25 | Web, Mobile | Learn investing at own pace, earn XP, compete on leaderboards |
| Student (classroom) | 14-20 | Web | Complete assigned lessons, participate in live simulations |
| Teacher | 25-55 | Web | Create classrooms, assign curriculum, monitor progress |
| Institution admin | 30-60 | Web, Desktop | White-label platform, manage billing, view analytics |
| Parent (future) | 35-55 | Web, Mobile | Monitor child's progress, manage subscription |
## Tech Stack
| Layer | Technology | Purpose |
|-------|-----------|---------|
| **Monorepo** | Turborepo 2.5 + pnpm 9.15 | Orchestrate builds, share code, manage dependencies |
| **Backend** | NestJS 11 + Node.js 20 | Modular API server with DI, guards, interceptors |
| **API Protocol** | GraphQL (Apollo Server 4) | Primary CRUD — type-safe, no over-fetching |
| **Real-time** | Socket.io (NestJS WebSocket gateway) | Live simulations, leaderboards, AI streaming |
| **REST** | Express (NestJS platform) | Webhooks (Stripe, Clerk, Resend), health checks |
| **Frontend** | React 18 + TypeScript + Vite 6 | SPA with fast HMR, tree-shaking, code splitting |
| **CSS** | TailwindCSS 4 + shadcn/ui (Radix primitives) | Design tokens, accessible components, dark mode |
| **CMS** | Payload CMS 3 (self-hosted, headless) | Dynamic block-based content, i18n, media library |
| **Database** | PostgreSQL 16 + Prisma ORM 6 | ACID compliance, JSON support, multi-schema tenancy |
| **Cache/Queue** | Redis 7 + BullMQ (NestJS BullMQ module) | Rate limiting, job queues, session store, leaderboards |
| **Storage** | S3-compatible (MinIO dev / Cloudflare R2 prod) | User avatars, PDF reports, CSV market data |
| **Auth** | Clerk (B2C) / JWT with SAML SSO (B2B) | Social login, passwordless, institutional SSO |
| **Email** | Resend + React Email templates | Welcome emails, progress reports, teacher invites |
| **Billing** | Stripe Connect | B2B seat-based subscriptions, usage metering |
| **AI** | OpenAI / Gemini (via backend proxy) | Socratic AI coach with token budget enforcement |
| **Container** | Docker Compose (dev) + Portainer/Traefik (prod) | Consistent environments, auto-SSL, monitoring |
| **CI/CD** | GitHub Actions -> GHCR -> Portainer webhook | Automated testing, building, and deployment |
| **Testing** | Vitest 3 + Testing Library + Playwright | Unit, integration, and E2E coverage |
| **Desktop** | Tauri v2 (Rust-based, future) | Native Windows/macOS/Linux wrapper |
| **Mobile** | Capacitor (future) | Native iOS/Android wrapper |
## Directory Structure
```
investplay/
├── apps/
│ ├── api/ # NestJS backend
│ │ ├── src/
│ │ │ ├── modules/
│ │ │ │ ├── auth/ # JWT, SSO, role guards
│ │ │ │ ├── tenant/ # Multi-tenancy, feature flags
│ │ │ │ ├── curriculum/ # CMS-backed lesson delivery
│ │ │ │ ├── simulation/ # Historical tick streaming
│ │ │ │ ├── portfolio/ # Virtual trades, P&L calc
│ │ │ │ ├── ai-coach/ # LLM proxy with cost control
│ │ │ │ ├── analytics/ # Behavioral telemetry
│ │ │ │ ├── classroom/ # Teacher dashboard, assignments
│ │ │ │ ├── gamification/ # XP, badges, streaks
│ │ │ │ └── billing/ # Stripe Connect integration
│ │ │ ├── common/
│ │ │ │ ├── guards/ # JWT guard, tenant guard, roles guard
│ │ │ │ ├── decorators/ # @CurrentUser, @TenantId
│ │ │ │ ├── filters/ # Global exception filter
│ │ │ │ └── interceptors/ # Logging, timing, tenant injection
│ │ │ ├── config/ # Env-based config modules
│ │ │ └── main.ts # NestJS bootstrap
│ │ ├── prisma/
│ │ │ ├── schema.prisma # Full data model
│ │ │ └── migrations/ # Prisma migration history
│ │ ├── test/ # E2E test suites
│ │ ├── Dockerfile
│ │ └── package.json
│ ├── web/ # React + Vite frontend
│ │ ├── src/
│ │ │ ├── components/ # UI components (per feature)
│ │ │ ├── pages/ # Route pages
│ │ │ ├── hooks/ # Custom React hooks
│ │ │ ├── stores/ # Zustand stores
│ │ │ ├── lib/ # Utilities, API client
│ │ │ ├── styles/ # TailwindCSS setup
│ │ │ └── i18n/ # i18next configuration
│ │ ├── public/ # Static assets
│ │ ├── index.html
│ │ ├── vite.config.ts
│ │ ├── Dockerfile
│ │ └── package.json
│ ├── cms/ # Payload CMS
│ │ ├── src/
│ │ │ ├── collections/ # Content models
│ │ │ ├── globals/ # Site-wide settings
│ │ │ └── payload.config.ts
│ │ ├── Dockerfile
│ │ └── package.json
│ ├── mobile/ # Capacitor (future)
│ └── desktop/ # Tauri (future)
├── packages/
│ ├── types/ # @investplay/types
│ │ └── src/
│ │ ├── user.ts
│ │ ├── tenant.ts
│ │ ├── curriculum.ts
│ │ ├── simulation.ts
│ │ ├── portfolio.ts
│ │ └── analytics.ts
│ ├── ui/ # @investplay/ui
│ │ └── src/
│ │ ├── components/ # shadcn/ui primitives
│ │ └── lib/ # cn(), formatCurrency(), etc.
│ ├── i18n/ # @investplay/i18n
│ │ └── src/
│ │ ├── locales/
│ │ │ ├── en/ # English translations
│ │ │ │ ├── common.json
│ │ │ │ ├── finance.json
│ │ │ │ ├── gamification.json
│ │ │ │ ├── classroom.json
│ │ │ │ └── ai-coach.json
│ │ │ └── el/ # Greek translations
│ │ │ ├── common.json
│ │ │ └── ...
│ │ └── index.ts # i18next config
│ └── utils/ # @investplay/utils
│ └── src/
│ ├── validation.ts # Zod schemas
│ ├── formatting.ts # Currency, date, number
│ ├── constants.ts # App constants
│ └── env.ts # Env validation
├── docker/
│ ├── traefik/
│ │ ├── traefik.yml # Static config
│ │ └── dynamic.yml # Dynamic config (middleware, TLS)
│ └── nginx/
│ └── nginx.conf # Web SPA nginx config
├── scripts/
│ ├── setup.sh # Initial dev setup
│ ├── healthcheck.sh # Service health verification
│ └── backup.sh # Database backup script
├── docs/ # Project documentation
├── specs/ # Detailed specifications
├── .github/
│ └── workflows/
│ ├── ci.yml # Continuous integration
│ ├── deploy.yml # Deployment pipeline
│ └── pr-checks.yml # PR quality gates
├── .env.example # Environment template
├── turbo.json # Turborepo pipeline config
├── pnpm-workspace.yaml # Workspace definition
├── tsconfig.base.json # Shared TS config
├── .eslintrc.js # ESLint root config
├── .prettierrc # Prettier formatting
└── package.json # Root package.json
```
## Architecture Patterns
### Multi-tenancy
**Approach:** Schema-level isolation via PostgreSQL schemas.
- Each tenant (institution) gets its own PostgreSQL schema
- `TenantContext` stored in `AsyncLocalStorage` — resolved from JWT `tenantId` claim
- Prisma multi-schema extension for dynamic schema switching
- Global/shared tables (lookups, platform config) in `public` schema
- Connection pooling per schema via Prisma's `connection_limit`
- Tenant resolution pipeline: HTTP request -> JWT middleware -> extract tenantId -> set AsyncLocalStorage -> Prisma middleware applies schema filter
### API Design
- **GraphQL** is the primary API for all CRUD operations — single endpoint `/graphql`
- **REST** used only for webhooks (Stripe, Clerk, Resend) and `/health`
- **WebSocket** namespaces for real-time features: `/simulations` (tick streaming), `/ai-coach` (chat streaming), `/classroom` (live teacher broadcasts)
- Input validation via Zod schemas at every API boundary
- All GraphQL queries have depth limiting (max 7) and cost analysis
### Content Management
- Payload CMS serves as the single source of truth for all educational content
- Content model: Track -> Module -> Lesson -> Block (dynamic array)
- Block types: Hero, Text, Image, Video, Quiz, Calculator, BudgetGame, TradeSimulation
- Frontend renders content via a `BlockRenderer` component that maps CMS block types to React components
- CMS localization: each content collection supports locale variants
### AI Proxy Architecture
- All LLM requests route through the NestJS `AICoachModule` — never direct from client
- Context injection: backend appends portfolio state, lesson context, and user history to the system prompt
- Rate limiting via Redis token bucket (per-user daily, per-tenant monthly)
- BYOK (Bring Your Own Key): institutions can provide their own API key for higher quotas
- PII sanitization: names, emails, phone numbers stripped before sending to LLM
- Audit logging: every AI interaction logged with token count, latency, and model used
### Security Architecture
- **Authentication:** JWT RS256 (asymmetric) — access token (15m) + refresh token (7d)
- **Authorization:** Role-based guards (`@Roles(ROLE.TEACHER)`) + tenant scope enforcement
- **GraphQL safety:** Depth limiting (max 7), query cost analysis, persisted operations (future)
- **HTTP hardening:** Helmet.js headers, CORS whitelist per tenant, rate limiting (100 req/min default)
- **WebSocket:** JWT handshake authentication, room-based isolation per tenant
- **Secrets:** Zero secrets in code — all via environment variables, Docker secrets, or secret manager
- **GDPR:** Soft-deletes, PII anonymization pipeline, data export API, retention policies
## Key Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Monorepo tool | Turborepo | Better DX than Nx for this scale; native ESM support; simpler cache config |
| Package manager | pnpm | Disk-efficient (hard links), strict dependency resolution, workspace protocol |
| Backend framework | NestJS | Modular architecture with DI, decorator-based guards, native GraphQL + WebSocket support |
| Frontend build | Vite (not Next.js) | SPA is sufficient (no SSR SEO needed); faster builds; simpler deployment (nginx static) |
| CMS | Payload CMS (self-hosted) | TypeScript-native, code-first config, block-based content, built-in i18n, no vendor lock-in |
| Database ORM | Prisma | Type-safe queries, migration-first workflow, multi-schema preview feature for tenancy |
| Auth (initial) | Clerk | Fast to integrate for B2C; supports social login, passwordless, webhooks |
| Auth (B2B future) | Auth0 / SAML SSO | Required for institutional customers with Active Directory / Google Workspace |
| API protocol | GraphQL primary | Type-safe contracts, no over-fetching, single endpoint, introspection for DX tools |
| Real-time | Socket.io | Mature, fallback transport, room support, NestJS integration |
| Background jobs | BullMQ + Redis | Persistence, delayed jobs, rate limiting, job events for monitoring |
| Styling | TailwindCSS + shadcn/ui | Utility-first, design tokens, accessible Radix primitives, small bundle |
| i18n | i18next + ICU MessageFormat | Industry standard, pluralization, context, rich text in translations |
| AI cost control | Server-side token bucket | Hard enforcement not reliant on OpenAI budget alerts; Redis is fast and atomic |
| Container registry | GHCR | Free with GitHub, tight integration with Actions, no extra credentials |
| Deployment | Docker Compose + Traefik | Simpler than k8s for current scale; Traefik auto-SSL with Let's Encrypt |
| Desktop (future) | Tauri v2 | Rust-based, 10x smaller binary than Electron, memory-efficient |
| Mobile (future) | Capacitor | Minimal wrapper, access to native APIs, shares web codebase |
## State Management
| State Type | Technology | Usage |
|-----------|-----------|-------|
| Server state | @tanstack/react-query | Data fetching, caching, optimistic updates, pagination |
| GraphQL state | Apollo Client (future) | When GraphQL adoption increases; for now REST-like via react-query |
| Client/auth state | Zustand | Auth session, UI preferences (theme, sidebar), gamification (XP, level) |
| Form state | React Hook Form + Zod | All forms with schema validation |
| URL state | React Router v7 | Search params, path params for routing |
| Real-time state | Socket.io client | Live simulation ticks, leaderboard updates, AI chat streaming |
## Naming Conventions
| Category | Convention | Example |
|----------|-----------|---------|
| Files (components) | kebab-case | `user-profile.tsx`, `simulation-chart.tsx` |
| Files (modules/classes) | PascalCase | `AuthModule.ts`, `UserService.ts` |
| Variables | camelCase | `isLoading`, `userData` |
| Functions | camelCase | `getUserById()`, `formatCurrency()` |
| React components | PascalCase | `UserProfile`, `SimulationChart` |
| Types | PascalCase | `User`, `TenantConfig` |
| Interfaces | PascalCase (no I prefix) | `UserRepository`, `AuthPayload` |
| Enums | PascalCase (singular) | `Role`, `LessonStatus`, `TenantTier` |
| Database tables | snake_case | `users`, `user_progress`, `classroom_students` |
| Database columns | snake_case | `tenant_id`, `first_name`, `created_at` |
| GraphQL types | PascalCase | `User`, `LessonProgress`, `TradeInput` |
| GraphQL fields | camelCase | `userId`, `portfolioValue`, `totalXp` |
| Environment variables | UPPER_SNAKE_CASE | `DATABASE_URL`, `JWT_SECRET` |
## Environment Variables
See [.env.example](../.env.example) for the complete template with all variables and descriptions.
Key groups:
- **APP** — `NODE_ENV`, `PORT`, `APP_URL`, `API_URL`, `CORS_ORIGINS`
- **DATABASE** — `DATABASE_URL`, `DATABASE_POOL_MIN`, `DATABASE_POOL_MAX`
- **REDIS** — `REDIS_URL`, `REDIS_PASSWORD`
- **JWT** — `JWT_SECRET`, `JWT_EXPIRES_IN`, `JWT_REFRESH_EXPIRES_IN`
- **AUTH** — `CLERK_SECRET_KEY`, `CLERK_WEBHOOK_SECRET`, `AUTH_SSO_ENABLED`
- **AI** — `OPENAI_API_KEY`, `GEMINI_API_KEY`, `AI_DEFAULT_DAILY_LIMIT`, `AI_DEFAULT_MONTHLY_TOKEN_BUDGET`
- **STORAGE** — `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET`
- **EMAIL** — `RESEND_API_KEY`, `EMAIL_FROM`
- **BILLING** — `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`
- **TENANT** — `TENANT_DEFAULT_AI_MODE`, `TENANT_MAX_TOKENS_MONTHLY`
- **i18n** — `DEFAULT_LOCALE`, `SUPPORTED_LOCALES`
- **CMS** — `CMS_API_URL`, `CMS_API_KEY`
- **SECURITY** — `RATE_LIMIT_TTL`, `RATE_LIMIT_MAX`, `GRAPHQL_DEPTH_LIMIT`
- **LOGGING** — `LOG_LEVEL`, `LOG_FORMAT`
## Testing Strategy
| Layer | Tool | Scope | Frequency |
|-------|------|-------|-----------|
| Unit | Vitest | Backend services, utils, hooks, formatting | Every commit |
| Integration | Supertest + Vitest | API endpoints, GraphQL resolvers | Every commit |
| Component | Vitest + Testing Library | React component rendering, user interactions | Every commit |
| E2E | Playwright | Critical user flows (login, lesson, simulation) | CI / pre-release |
| Contract | GraphQL schema tests | Schema validation, resolver shape | On schema change |
| Coverage target | 80%+ | Core business logic (services, resolvers, hooks) | CI gate |
## Development Workflow
1. Create a feature branch from `main`:
- `git checkout -b feat/my-feature` (new feature)
- `git checkout -b fix/my-bug` (bug fix)
2. Start the development environment: `docker compose -f docker-compose.dev.yml up`
3. Develop iteratively with hot-reload enabled
4. Write tests following TDD cycle: Red -> Green -> Refactor
5. Run quality gates locally:
```bash
pnpm lint
pnpm typecheck
pnpm test
```
6. Commit using conventional commits:
```
feat(auth): add SAML SSO login flow
fix(simulation): correct tick timestamp offset
docs(api): update websocket event reference
```
7. Push branch and open a Pull Request
8. CI runs automatically: lint -> typecheck -> test -> build
9. PR must pass all checks and receive code review approval
10. Squash merge to `main` (keeps history clean)
11. CI runs again on main, then auto-deploys to production
## Related Documents
- [ARCHITECTURE.md](./ARCHITECTURE.md) — System architecture, request flow, multi-tenancy deep dive
- [API.md](./API.md) — API reference, authentication, endpoints, error codes
- [LOCALIZATION.md](./LOCALIZATION.md) — i18n setup, adding locales, ICU formatting
- [DEVELOPMENT-ROADMAP.md](./DEVELOPMENT-ROADMAP.md) — Phase plan and progress tracking

389
docs/DEVELOPMENT-ROADMAP.md Normal file
View File

@@ -0,0 +1,389 @@
# InvestPlay — Development Roadmap
> **Status:** Phase 1 — Foundation
> **Last Updated:** 2026-06-12
> **Target:** Production-grade, multi-tenant, multi-platform financial literacy platform
---
## Architecture Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Monorepo Tool | **Turborepo** | Better DX than Nx for this scale; native ESM support |
| Package Manager | **pnpm** | Disk-efficient, strict dependency resolution |
| Backend Framework | **NestJS** | Modular, DI, GraphQL native, WebSocket integrated |
| Frontend Framework | **React + Vite** | Fast HMR, modern tooling, Capacitor/Tauri compatible |
| CMS | **Payload CMS** (self-hosted) | Code-first, TypeScript-native, block-based content, i18n built-in |
| Database ORM | **Prisma** | Type-safe, migration-first, multi-schema for tenant isolation |
| Cache | **Redis** | BullMQ queues + session store + rate limiting |
| Auth | **Clerk** (initial) → **Auth0** (B2B SSO) | Fast B2C start, SAML SSO for institutions |
| i18n Framework | **next-intl / react-i18next** | ICU message format, locale-aware routing |
| CSS Framework | **TailwindCSS** + **shadcn/ui** | Design tokens, accessible components |
| Containerization | **Docker Compose** (dev) + **Portainer** (prod) | Consistent local/remote, easy management |
| CI/CD | **GitHub Actions** | Free for public repos, Docker layer caching |
---
## Development Phases
### PHASE 1 — Foundation ✅ COMPLETED (2026-06-12)
**Goal:** Running monorepo, Docker dev environment, auth, basic API, empty UI shell
- [x] Project documentation and roadmap
- [x] Monorepo structure (Turborepo + pnpm workspaces)
- [x] Docker Compose for local dev (PostgreSQL, Redis, MinIO, Payload CMS)
- [x] Docker Compose for Contabo/Portainer production deployment
- [x] Environment variable system (.env per service, validation via Zod)
- [x] NestJS backend scaffold with core modules (75 files, 9 modules)
- [x] Prisma schema — 19 models, 13 enums, users, tenants, roles, classrooms
- [x] Auth module (JWT + SSO ready)
- [x] Tenant module (multi-tenancy, feature flags)
- [x] React + Vite frontend scaffold (55+ files, 10 pages, 14 UI primitives)
- [x] TailwindCSS + shadcn/ui design system
- [x] i18n infrastructure (EN + EL, 10 locale files, ICU MessageFormat)
- [x] Shared packages: `@investplay/types`, `@investplay/ui`, `@investplay/i18n`, `@investplay/utils`
- [x] Payload CMS scaffold with Docker support
- [x] ESLint + Prettier + Husky pre-commit hooks
- [x] CI pipeline (lint, typecheck, test, build) + deploy + PR checks
- [x] Comprehensive documentation (CONTEXT.md, ARCHITECTURE.md, API.md, LOCALIZATION.md)
### PHASE 2 — Core Learning (MVP B2C)
**Goal:** Self-guided learner can complete lessons and earn XP
- [ ] CMS block renderer in frontend (Hero, Text, Quiz, Calculator blocks)
- [ ] Lesson progression engine (track → module → lesson → block)
- [ ] XP and leveling system
- [ ] Badges and achievements
- [ ] Quiz/assessment engine
- [ ] User dashboard with progress visualization
- [ ] Streak tracking
- [ ] B2C auth flow (email + social)
### PHASE 3 — Simulations & Gamification
**Goal:** Interactive simulations and mini-games work end-to-end
- [ ] Blind Scenario engine (historical CSV → WebSocket tick stream)
- [ ] Synthetic market generator
- [ ] Portfolio management (virtual trades, ROI calculation)
- [ ] Budget Game (50/30/20 drag-and-drop)
- [ ] Debt Snowball mini-game
- [ ] Hype Trap narrative challenge
- [ ] Anti-cheat mechanisms
- [ ] Simulation leaderboards
### PHASE 4 — AI Coach
**Goal:** Context-aware educational AI with cost controls
- [ ] AI proxy service (NestJS → OpenAI/Gemini)
- [ ] Context injection pipeline
- [ ] Token bucket rate limiting (per user, per tenant)
- [ ] BYOK (Bring Your Own Key) mode
- [ ] Socratic prompt engineering
- [ ] Audit logging
- [ ] PII sanitization before LLM calls
- [ ] Graceful fallback when quota exhausted
### PHASE 5 — Classroom & Institutions (B2B MVP)
**Goal:** Teachers can manage classrooms and institutions can deploy
- [ ] Teacher dashboard
- [ ] Classroom creation with invite codes
- [ ] Assignment system
- [ ] Live "Time Machine" classroom simulation
- [ ] Cohort analytics and heatmaps
- [ ] Intervention alerts
- [ ] PDF/CSV report generation
- [ ] Institution admin panel
- [ ] White-label configuration
- [ ] Stripe Connect B2B billing
- [ ] SSO integration (SAML/OIDC)
### PHASE 6 — Mobile & Desktop
**Goal:** Native app wrappers for iOS, Android, Windows, macOS, Linux
- [ ] Capacitor wrapper (iOS + Android)
- [ ] Push notifications (FCM)
- [ ] Offline lesson caching
- [ ] Tauri v2 wrapper (Windows, macOS, Linux)
- [ ] Desktop-specific features (offline mode, system tray)
### PHASE 7 — Analytics & Reporting
**Goal:** Data-driven insights for institutions and platform operators
- [ ] Behavioral telemetry pipeline
- [ ] Aggregated materialized views (nightly rollups)
- [ ] Teacher analytics dashboard
- [ ] PDF/CSV automated report generation
- [ ] GDPR anonymization pipeline
- [ ] Opt-out mechanism for research aggregation
- [ ] "Gen Z Financial Behavior Index" report templates
### PHASE 8 — Localization Expansion
**Goal:** Full EU language support
- [ ] Add DE, FR, ES, IT, PT, NL locales
- [ ] CMS content localization workflow
- [ ] Locale-specific financial examples (taxes, regulations)
- [ ] RTL support readiness
- [ ] Locale-aware currency and number formatting
### PHASE 9 — Production Hardening
**Goal:** Enterprise-grade security, performance, and reliability
- [ ] Load testing (k6 / Artillery)
- [ ] Penetration testing
- [ ] Database backup/restore automation
- [ ] Disaster recovery plan
- [ ] Multi-region deployment (EU data residency)
- [ ] SOC 2 / ISO 27001 readiness
- [ ] SLA monitoring and alerting
---
## i18n Strategy
### Implementation
- **UI Strings:** `react-i18next` with ICU MessageFormat in JSON files per locale
- **Educational Content:** Payload CMS localized collections (each lesson has locale variants)
- **Currency/Number Formatting:** `Intl.NumberFormat` (browser-native, no extra library)
- **Date Formatting:** `Intl.DateTimeFormat` with locale-aware relative time
### Locale File Structure
```
packages/i18n/src/locales/
en/
common.json # Buttons, nav, errors
finance.json # Financial terms, instrument names
gamification.json # XP, badges, achievement names
classroom.json # Teacher dashboard, assignments
ai-coach.json # AI chat UI strings
gr/
common.json
finance.json
...
```
### Initial Languages (Phase 1)
- English (en) — Primary development language
- Greek (el) — Market entry requirement
### Expansion Languages (Phase 8)
- German (de), French (fr), Spanish (es), Italian (it), Portuguese (pt), Dutch (nl)
- All EU official languages eventually
---
## Deployment Architecture
### Local Development (Windows)
```
docker compose -f docker-compose.dev.yml up
```
Services:
- `api` — NestJS backend (hot-reload via tsx watch)
- `web` — Vite React frontend (HMR on port 5173)
- `cms` — Payload CMS (port 3000)
- `postgres` — PostgreSQL 16 (port 5432)
- `redis` — Redis 7 (port 6379)
- `minio` — S3-compatible storage (ports 9000/9001)
### Contabo Production (via Portainer)
```
docker compose -f docker-compose.prod.yml up -d
```
Portainer stack deployment with:
- Traefik reverse proxy (auto-SSL via Let's Encrypt)
- Multi-stage Docker builds (CI → GHCR → Portainer pull)
- Health checks on all containers
- Log rotation via Docker logging drivers
- Database backup sidecar container
---
## Environment Variables
All configuration through environment variables. No hardcoded secrets.
### Required ENV Files
```
.env.example # Template for all vars (committed)
.env # Local overrides (gitignored)
.env.production.example # Production template (committed)
```
### Key Variable Categories
| Category | Variables |
|----------|-----------|
| Database | `DATABASE_URL`, `DATABASE_POOL_MIN`, `DATABASE_POOL_MAX` |
| Redis | `REDIS_URL`, `REDIS_PASSWORD` |
| Auth | `JWT_SECRET`, `JWT_EXPIRES_IN`, `CLERK_SECRET_KEY` |
| AI | `OPENAI_API_KEY`, `GEMINI_API_KEY`, `AI_DEFAULT_DAILY_LIMIT` |
| Storage | `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET` |
| Email | `RESEND_API_KEY`, `EMAIL_FROM` |
| Billing | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` |
| Tenant | `TENANT_DEFAULT_AI_MODE`, `TENANT_MAX_TOKENS_MONTHLY` |
| i18n | `DEFAULT_LOCALE`, `SUPPORTED_LOCALES` |
---
## Repository Structure
```
investplay/
├── apps/
│ ├── api/ # NestJS backend
│ │ ├── src/
│ │ │ ├── modules/
│ │ │ │ ├── auth/
│ │ │ │ ├── tenant/
│ │ │ │ ├── curriculum/
│ │ │ │ ├── simulation/
│ │ │ │ ├── portfolio/
│ │ │ │ ├── ai-coach/
│ │ │ │ ├── analytics/
│ │ │ │ ├── classroom/
│ │ │ │ └── gamification/
│ │ │ ├── common/
│ │ │ │ ├── guards/
│ │ │ │ ├── decorators/
│ │ │ │ ├── filters/
│ │ │ │ └── interceptors/
│ │ │ ├── config/
│ │ │ └── main.ts
│ │ ├── prisma/
│ │ │ ├── schema.prisma
│ │ │ └── migrations/
│ │ ├── test/
│ │ ├── Dockerfile
│ │ └── package.json
│ ├── web/ # React + Vite frontend
│ │ ├── src/
│ │ │ ├── components/
│ │ │ ├── pages/
│ │ │ ├── hooks/
│ │ │ ├── stores/
│ │ │ ├── lib/
│ │ │ ├── styles/
│ │ │ └── i18n/
│ │ ├── public/
│ │ ├── index.html
│ │ ├── vite.config.ts
│ │ ├── Dockerfile
│ │ └── package.json
│ ├── cms/ # Payload CMS
│ │ ├── src/
│ │ │ └── collections/
│ │ ├── package.json
│ │ └── Dockerfile
│ ├── mobile/ # Capacitor wrapper
│ └── desktop/ # Tauri v2 wrapper
├── packages/
│ ├── types/ # @investplay/types
│ │ ├── src/
│ │ │ ├── user.ts
│ │ │ ├── tenant.ts
│ │ │ ├── curriculum.ts
│ │ │ ├── simulation.ts
│ │ │ ├── portfolio.ts
│ │ │ └── analytics.ts
│ │ └── package.json
│ ├── ui/ # @investplay/ui
│ │ ├── src/
│ │ │ ├── components/
│ │ │ └── lib/
│ │ └── package.json
│ ├── i18n/ # @investplay/i18n
│ │ ├── src/
│ │ │ └── locales/
│ │ └── package.json
│ └── utils/ # @investplay/utils
│ ├── src/
│ └── package.json
├── docker/
│ ├── docker-compose.dev.yml
│ ├── docker-compose.prod.yml
│ ├── traefik/
│ │ ├── traefik.yml
│ │ └── dynamic.yml
│ └── nginx/
│ └── nginx.conf
├── scripts/
│ ├── setup.sh
│ ├── seed.ts
│ └── backup.sh
├── docs/
│ ├── DEVELOPMENT-ROADMAP.md
│ ├── CONTEXT.md
│ ├── API.md
│ └── ARCHITECTURE.md
├── .github/
│ └── workflows/
│ ├── ci.yml
│ └── deploy.yml
├── .env.example
├── .env.production.example
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
├── tsconfig.base.json
├── .eslintrc.js
├── .prettierrc
└── README.md
```
---
## Database Schema (Core Entities)
```
User — id, email, role, locale, tenantId, xp, level
Tenant — id, name, domain, branding, featureFlags, aiMode
Classroom — id, name, tenantId, teacherId, inviteCode
ClassroomStudent — id, classroomId, userId, joinedAt
Module — id, cmsId, track, order, tenantId
Lesson — id, cmsId, moduleId, order, estimatedMins
UserProgress — id, userId, lessonId, status, score, completedAt
QuizAttempt — id, userId, quizId, answers, score, timestamp
Trade — id, userId, simulationId, asset, amount, timestamp
Portfolio — id, userId, simulationId, cashBalance, holdings
Badge — id, name, description, icon, criteria
UserBadge — id, userId, badgeId, earnedAt
AILog — id, tenantId, userId, tokensUsed, timestamp
AnalyticsEvent — id, userId, eventType, metadata, timestamp
```
---
## Key Technical Decisions
### Multi-Tenancy Strategy
**Schema-level isolation** via PostgreSQL schemas (one per tenant) for maximum data isolation. Tenant-specific connection pooling via Prisma's multi-schema preview feature.
### API Design
- **Primary:** GraphQL for all CRUD operations (type-safe, no over-fetching)
- **Real-time:** WebSockets (Socket.io) for live simulations and leaderboards
- **Webhooks:** REST endpoints for Stripe, Resend, third-party integrations
### AI Cost Control
Backend-side hard enforcement (not reliant on OpenAI budget alerts):
- Redis token bucket per user (daily) and per tenant (monthly)
- Hard cutoff at quota exhaustion (no grace period)
- Admin alert when approaching 80% of monthly quota
### Security
- JWT with RS256 (asymmetric) for service-to-service auth
- GraphQL query depth limiting (max depth 7)
- Rate limiting on all public endpoints
- CORS whitelist per tenant domain
- Helmet.js for HTTP security headers
- Input validation via Zod on every endpoint
- Prompt injection defense in AI proxy
---
## Tracking
Progress tracked via this file. Each completed task gets `[x]`.
Git commits follow Conventional Commits format.
CI badges added once pipelines are running.

377
docs/LOCALIZATION.md Normal file
View File

@@ -0,0 +1,377 @@
# InvestPlay — Localization Guide
## Overview
InvestPlay supports multiple languages through a layered i18n architecture:
1. **UI strings**`i18next` with namespaced JSON files (runtime-loaded, no build step)
2. **Educational content** — Payload CMS localized collections (per-locale content variants)
3. **Financial formatting**`Intl.NumberFormat` and `Intl.DateTimeFormat` (browser-native)
4. **Currency** — Locale-aware currency symbols and formatting
### Currently Supported Locales
| Locale | Code | Status | Notes |
|--------|------|--------|-------|
| English | `en` | Primary | Default fallback locale |
| Greek | `el` | Active | Market entry requirement |
### Target Locales (Phase 8)
German (`de`), French (`fr`), Spanish (`es`), Italian (`it`), Portuguese (`pt`), Dutch (`nl`)
---
## How to Add a New Locale
### Step 1: Define the locale in configuration
Add the locale to the `SUPPORTED_LOCALES` environment variable:
```env
SUPPORTED_LOCALES=en,el,de
```
### Step 2: Create translation JSON files
Create a new directory under `packages/i18n/src/locales/<locale_code>/` with the same namespace structure as existing locales:
```
packages/i18n/src/locales/
├── en/
│ ├── common.json
│ ├── finance.json
│ ├── gamification.json
│ ├── classroom.json
│ └── ai-coach.json
├── el/
│ ├── common.json
│ ├── finance.json
│ ├── gamification.json
│ ├── classroom.json
│ └── ai-coach.json
└── de/ # New locale
├── common.json
├── finance.json
├── gamification.json
├── classroom.json
└── ai-coach.json
```
### Step 3: Register in the i18n package
Edit `packages/i18n/src/index.ts` to import and register the new locale:
```typescript
import deCommon from "./locales/de/common.json" with { type: "json" };
import deFinance from "./locales/de/finance.json" with { type: "json" };
import deGamification from "./locales/de/gamification.json" with { type: "json" };
import deClassroom from "./locales/de/classroom.json" with { type: "json" };
import deAiCoach from "./locales/de/ai-coach.json" with { type: "json" };
const resources = {
en: { /* existing */ },
el: { /* existing */ },
de: {
common: deCommon,
finance: deGamification,
gamification: deGamification,
classroom: deClassroom,
aiCoach: deAiCoach,
},
};
```
### Step 4: Add locale in the CMS
1. Navigate to Payload Admin -> Settings -> Localization
2. Add the new locale code
3. Existing content collections will now show locale switcher tabs
4. Content editors can create locale-specific variants for each lesson, article, or block
### Step 5: Add locale-specific financial examples
If the new locale has different financial regulations, tax rules, or common investment products, create locale-specific example data in the CMS. This ensures learners see locally relevant content.
### Step 6: Test the locale
```bash
# TypeScript checking ensures all namespaces are covered
pnpm typecheck
# Run tests with German locale
NODE_ENV=test SUPPORTED_LOCALES=en,el,de pnpm test
```
---
## CMS Localization Workflow
### Architecture
Payload CMS supports built-in localization at the collection level. When a collection has `localization: true`, each document stores locale-specific variants:
```typescript
// Conceptual Payload collection config
const Lessons = {
slug: "lessons",
localization: {
locales: ["en", "el", "de"],
defaultLocale: "en",
fallback: true, // Falls back to default locale if variant missing
},
fields: [
{ name: "title", type: "text", localized: true },
{ name: "body", type: "richText", localized: true },
{ name: "blocks", type: "blocks", localized: true },
{ name: "duration", type: "number", localized: false }, // Shared across locales
],
};
```
### Content Editor Flow
1. Author creates a lesson in the default locale (English)
2. CMS marks the lesson as "Translation Needed" for other locales
3. Translator opens the lesson and sees all localized fields
4. Translator fills in the locale-specific content
5. CMS automatically uses the locale variant when the API is queried with the matching `Accept-Language` header
### Frontend Rendering
The frontend requests content with the user's preferred locale. The CMS API automatically resolves the correct variant:
```typescript
// The CMS API respects Accept-Language header
const response = await fetch(`${CMS_API_URL}/api/lessons/${id}`, {
headers: {
"Accept-Language": userLocale, // "de", "el", etc.
"Authorization": `Bearer ${cmsApiKey}`,
},
});
```
If a locale variant is missing, Payload falls back to the default locale content.
---
## ICU MessageFormat Examples
InvestPlay uses ICU MessageFormat for all translated strings via `i18next`. This provides robust support for plurals, gender, context, and rich text.
### Basic Interpolation
```json
{
"welcome": "Welcome, {name}!",
"login": "Sign in to continue"
}
```
```typescript
t("welcome", { name: "Alex" });
// -> "Welcome, Alex!"
```
### Pluralization
```json
{
"xpEarned": "You earned {count} XP",
"xpEarned_plural": "You earned {count} XP",
"lessonsCompleted": "{count} lesson completed",
"lessonsCompleted_plural": "{count} lessons completed"
}
```
```typescript
t("xpEarned", { count: 1 }); // -> "You earned 1 XP"
t("xpEarned", { count: 5 }); // -> "You earned 5 XP"
t("lessonsCompleted", { count: 1 }); // -> "1 lesson completed"
t("lessonsCompleted", { count: 3 }); // -> "3 lessons completed"
```
ICU native plural syntax (also supported):
```json
{
"streak": "{count, plural, one {# day streak} other {# day streak}}",
"students": "{count, plural, one {# student} other {# students}}"
}
```
### Context and Gender
```json
{
"notification": "{name} {context, select, trade {bought} quiz {scored} badge {unlocked} other {completed}} {target}"
}
```
```typescript
t("notification", { name: "Alex", context: "trade", target: "AAPL" });
// -> "Alex bought AAPL"
t("notification", { name: "Maria", context: "badge", target: "Early Investor" });
// -> "Maria unlocked Early Investor"
```
### Rich Text Formatting
```json
{
"terms": "By continuing, you agree to our <terms>Terms of Service</terms> and <privacy>Privacy Policy</privacy>"
}
```
```typescript
t("terms", {
terms: (text: string) => `<a href="/terms">${text}</a>`,
privacy: (text: string) => `<a href="/privacy">${text}</a>`,
});
```
### Currency Formatting
Currency symbols are dynamically selected based on locale. The formatting happens in `@investplay/utils`:
```typescript
import { formatCurrency } from "@investplay/utils";
formatCurrency(1234.56, "EUR", "en"); // -> "€1,234.56"
formatCurrency(1234.56, "EUR", "el"); // -> "1.234,56 €"
formatCurrency(1234.56, "USD", "en"); // -> "$1,234.56"
```
### Date and Time Formatting
```typescript
import { formatDate, formatRelativeTime } from "@investplay/utils";
formatDate("2026-06-12", "en"); // -> "Jun 12, 2026"
formatDate("2026-06-12", "el"); // -> "12 Ιουν 2026"
formatRelativeTime("2026-06-10", "en"); // -> "2 days ago"
formatRelativeTime("2026-06-10", "el"); // -> "πριν από 2 ημέρες"
```
### Number Formatting
```typescript
import { formatNumber, formatPercentage } from "@investplay/utils";
formatNumber(1234567.89, "en"); // -> "1,234,567.89"
formatNumber(1234567.89, "el"); // -> "1.234.567,89"
formatPercentage(0.1234, "en"); // -> "12.34%"
formatPercentage(0.1234, "el"); // -> "12,34%"
```
### Namespace Structure
| Namespace | Contents | Example Keys |
|-----------|----------|-------------|
| `common` | Navigation, buttons, errors, meta | `nav.home`, `button.save`, `error.notFound` |
| `finance` | Financial terms, currencies, instruments | `term.dividend`, `currency.eur`, `instrument.stock` |
| `gamification` | XP, badges, streaks, levels | `xp.earned`, `badge.investor`, `streak.days` |
| `classroom` | Teacher dashboard, assignments, roster | `classroom.create`, `assignment.due`, `roster.student` |
| `aiCoach` | AI chat UI strings, hints, feedback | `ai.greeting`, `ai.hint`, `ai.quotaExhausted` |
---
## Currency/Date/Number Formatting
All formatting is handled browser-side using the `Intl` API via `@investplay/utils`. No server-side formatting is needed for display values.
### Currency Formatting Configuration
```typescript
import { CURRENCY_SYMBOLS } from "@investplay/utils";
CURRENCY_SYMBOLS = {
EUR: { symbol: "€", code: "EUR" },
USD: { symbol: "$", code: "USD" },
GBP: { symbol: "£", code: "GBP" },
} as const;
```
The `formatCurrency` function uses `Intl.NumberFormat` internally:
```typescript
new Intl.NumberFormat(locale, {
style: "currency",
currency: currencyCode,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
```
### Locale-Format Mapping
| Locale | Decimal | Grouping | Currency Position | Date Format |
|--------|---------|----------|-------------------|-------------|
| `en` | `.` | `,` | Prefix (`$1,234.56`) | `MMM dd, yyyy` |
| `el` | `,` | `.` | Suffix (`1.234,56 €`) | `dd MMM yyyy` |
| `de` | `,` | `.` | Suffix (`1.234,56 €`) | `dd.MM.yyyy` |
| `fr` | `,` | ` ` | Suffix (`1 234,56 €`) | `dd/MM/yyyy` |
| `es` | `,` | `.` | Suffix (`1.234,56 €`) | `dd/MM/yyyy` |
| `it` | `,` | `.` | Suffix (`1.234,56 €`) | `dd/MM/yyyy` |
| `pt` | `,` | `.` | Suffix (`1.234,56 €`) | `dd/MM/yyyy` |
| `nl` | `,` | `.` | Suffix (`1.234,56 €`) | `dd-MM-yyyy` |
### Implementation
All formatting functions in `@investplay/utils` accept a locale parameter that defaults to the user's current locale from `i18next.language`:
```typescript
import { useTranslation } from "@investplay/i18n";
function TradeRow({ trade }: { trade: Trade }) {
const { i18n } = useTranslation();
return (
<tr>
<td>{formatDate(trade.executedAt, i18n.language)}</td>
<td>{formatCurrency(trade.amount, "EUR", i18n.language)}</td>
<td>{formatPercentage(trade.roi, i18n.language)}</td>
</tr>
);
}
```
---
## RTL Readiness Notes
RTL (right-to-left) support for languages like Arabic (`ar`) is planned for Phase 8. The following steps have been taken to ensure RTL readiness:
### CSS
- TailwindCSS has built-in RTL support via the `rtl:` variant
- shadcn/ui components use logical CSS properties (`margin-inline-start` instead of `margin-left`)
- Flexbox and Grid layouts use `gap` rather than left/right margins
### HTML
- The `<html>` element will have `dir="rtl"` set via the locale configuration
- React components should avoid hardcoded `left`/`right` style values
### Icons
- Lucide icons support `RTL` via the `mirrorInRtl` property on directional icons (arrows, chevrons)
- Example: `<ChevronRight className="lucide-rtl-mirror" />`
### Future Implementation Checklist
When adding RTL support:
1. Update `packages/i18n/src/index.ts` to expose a `isRTL(locale: string): boolean` function
2. Create a `RTLProvider` component that sets `dir` on the document element
3. Audit all third-party libraries for RTL compatibility
4. Test with long RTL strings for layout overflow
5. Verify input fields, date pickers, and number inputs handle RTL correctly
---
## Related Documentation
- [CONTEXT.md](./CONTEXT.md) — Project context and tech stack
- [API.md](./API.md) — API endpoints and authentication
- [ARCHITECTURE.md](./ARCHITECTURE.md) — System architecture