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
|
||||
Reference in New Issue
Block a user