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
473 lines
10 KiB
Markdown
473 lines
10 KiB
Markdown
# 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
|