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
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:
472
docs/API.md
Normal file
472
docs/API.md
Normal 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
638
docs/ARCHITECTURE.md
Normal 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
320
docs/CONTEXT.md
Normal 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
389
docs/DEVELOPMENT-ROADMAP.md
Normal 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
377
docs/LOCALIZATION.md
Normal 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
|
||||
Reference in New Issue
Block a user