feat: complete Phase 1 foundation scaffold
Some checks failed
CI / Lint (push) Has been cancelled
CI / TypeCheck (push) Has been cancelled
CI / Test (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/api/Dockerfile name:api]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/cms/Dockerfile name:cms]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/web/Dockerfile name:web]) (push) Has been cancelled
CI / Build (push) Has been cancelled
Deploy / Deploy (push) Has been cancelled

- Monorepo: Turborepo + pnpm workspaces with 7 apps/packages
- Backend: NestJS scaffold with 9 modules (auth, tenant, curriculum, simulation, portfolio, ai-coach, analytics, classroom, gamification)
- Frontend: React 18 + Vite + TailwindCSS + shadcn/ui with 10 pages, 14 UI primitives, 3 Zustand stores
- Database: Prisma schema with 19 models, 13 enums, seed script, multi-tenant ready
- Docker: Dev, production, and Portainer Compose files with Traefik reverse proxy
- Configuration: .env.example (47 vars), Zod validation, frontend-safe env exposure
- i18n: English + Greek locale files (10 files), ICU MessageFormat, react-i18next
- Shared packages: @investplay/types, @investplay/ui, @investplay/i18n, @investplay/utils
- CI/CD: GitHub Actions (lint, typecheck, test, build, deploy, PR checks)
- Documentation: CONTEXT.md, ARCHITECTURE.md (10 Mermaid diagrams), API.md, LOCALIZATION.md, DEVELOPMENT-ROADMAP.md
- Infrastructure: Dockerfiles (dev + prod), nginx configs, backup/healthcheck scripts
- Security: JWT auth guards, role-based access, rate limiting, Helmet, CORS, PII sanitization
This commit is contained in:
Lefteris Notas
2026-06-12 19:32:57 +03:00
parent fa71c60cfc
commit e75f913f1c
226 changed files with 17547 additions and 0 deletions

45
.dockerignore Normal file
View File

@@ -0,0 +1,45 @@
node_modules/
.pnpm-store/
.npm/
.yarn/
.yarn/cache/
.yarn/unplugged/
.yarn/build-state.yml
.yarn/install-state.gz
.git/
.gitattributes
.gitignore
.husky/
.vscode/
.idea/
dist/
build/
out/
.next/
.turbo/
coverage/
storybook-static/
*.tsbuildinfo
*.log
.env
.env.*
!.env.example
!.env.production.example
docker-data/
postgres-data/
redis-data/
minio-data/
tmp/
temp/
logs/
*.md
!README.md
.DS_Store
Thumbs.db
*.local
.vercel
.cache/
.eslintcache
jest-cache/
playwright-report/
test-results/

123
.env.example Normal file
View File

@@ -0,0 +1,123 @@
# =============================================================================
# InvestPlay — Environment Configuration (.env.example)
# =============================================================================
# Copy this file to `.env` and adjust values for your local development setup.
# cp .env.example .env
#
# LEGEND:
# [REQUIRED] — Must be set for the application to function.
# [OPTIONAL] — Has a safe default; override only if needed.
# [DEV] — Value shown is for local development only.
# =============================================================================
# =============================================================================
# APP — Core application settings
# =============================================================================
NODE_ENV=development # [REQUIRED] runtime: development|production|test|staging
PORT=3001 # [OPTIONAL] API server port (default: 3001)
APP_URL=http://localhost:5173 # [REQUIRED] Frontend application URL
API_URL=http://localhost:3001 # [REQUIRED] API base URL (used by backend itself)
CORS_ORIGINS=http://localhost:5173,http://localhost:3000 # [OPTIONAL] Comma-separated allowed origins
# =============================================================================
# DATABASE — PostgreSQL via Prisma
# =============================================================================
# Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=SCHEMA
# [REQUIRED] Connection string for the primary database.
DATABASE_URL=postgresql://investplay:investplay@localhost:5432/investplay?schema=public
DATABASE_POOL_MIN=2 # [OPTIONAL] Minimum connection pool size (default: 2)
DATABASE_POOL_MAX=10 # [OPTIONAL] Maximum connection pool size (default: 10)
# =============================================================================
# REDIS — Caching, queues, and session store
# =============================================================================
REDIS_URL=redis://localhost:6379 # [REQUIRED] Redis connection string
REDIS_PASSWORD= # [OPTIONAL] Leave empty for local dev; set in production
# =============================================================================
# JWT — Authentication tokens
# =============================================================================
# WARNING: Change JWT_SECRET to a strong random string in production!
# You can generate one with: openssl rand -hex 64
JWT_SECRET=dev-secret-change-in-production # [REQUIRED] HMAC secret for signing tokens
JWT_EXPIRES_IN=7d # [OPTIONAL] Access token lifetime (default: 7d)
JWT_REFRESH_EXPIRES_IN=30d # [OPTIONAL] Refresh token lifetime (default: 30d)
# =============================================================================
# AUTH — Authentication provider (Clerk) & SSO
# =============================================================================
CLERK_SECRET_KEY= # [OPTIONAL] Clerk API secret key (leave empty to disable Clerk)
CLERK_WEBHOOK_SECRET= # [OPTIONAL] Clerk webhook signing secret
AUTH_SSO_ENABLED=false # [OPTIONAL] Enable SSO login (default: false)
# =============================================================================
# AI — LLM providers (OpenAI / Gemini)
# =============================================================================
OPENAI_API_KEY= # [OPTIONAL] OpenAI API key (leave empty if not using OpenAI)
OPENAI_MODEL=gpt-4o-mini # [OPTIONAL] OpenAI model name (default: gpt-4o-mini)
GEMINI_API_KEY= # [OPTIONAL] Google Gemini API key
GEMINI_MODEL=gemini-1.5-flash # [OPTIONAL] Gemini model name (default: gemini-1.5-flash)
AI_DEFAULT_DAILY_LIMIT=20 # [OPTIONAL] Max AI requests per user per day (default: 20)
AI_DEFAULT_MONTHLY_TOKEN_BUDGET=500000 # [OPTIONAL] Monthly token budget per user (default: 500k)
AI_MAX_MESSAGE_LENGTH=250 # [OPTIONAL] Max characters per AI message (default: 250)
# =============================================================================
# STORAGE — S3-compatible object storage (MinIO for dev)
# =============================================================================
S3_ENDPOINT=localhost # [REQUIRED] S3 endpoint hostname
S3_PORT=9000 # [REQUIRED] S3 endpoint port
S3_USE_SSL=false # [OPTIONAL] Use HTTPS for S3 (default: false for dev)
S3_ACCESS_KEY=minioadmin # [REQUIRED] S3 access key
S3_SECRET_KEY=minioadmin # [REQUIRED] S3 secret key
S3_BUCKET=investplay-dev # [REQUIRED] Default bucket name
S3_REGION=us-east-1 # [OPTIONAL] AWS region (default: us-east-1)
# =============================================================================
# EMAIL — Transactional emails (Resend)
# =============================================================================
RESEND_API_KEY= # [OPTIONAL] Resend API key (leave empty to disable email)
EMAIL_FROM=noreply@investplay.dev # [OPTIONAL] Sender email address
EMAIL_REPLY_TO=support@investplay.dev # [OPTIONAL] Reply-to address
# =============================================================================
# BILLING — Stripe integration
# =============================================================================
STRIPE_SECRET_KEY= # [OPTIONAL] Stripe secret key (leave empty to disable billing)
STRIPE_WEBHOOK_SECRET= # [OPTIONAL] Stripe webhook signing secret
STRIPE_PRICE_BASIC= # [OPTIONAL] Stripe price ID for Basic plan
STRIPE_PRICE_PREMIUM= # [OPTIONAL] Stripe price ID for Premium plan
# =============================================================================
# TENANT — Multi-tenant platform defaults
# =============================================================================
TENANT_DEFAULT_AI_MODE=MANAGED # [OPTIONAL] Default AI mode: MANAGED|SELF_SERVICE|DISABLED
TENANT_MAX_TOKENS_MONTHLY=1000000 # [OPTIONAL] Default monthly token cap per tenant (default: 1M)
# =============================================================================
# i18n — Internationalisation
# =============================================================================
DEFAULT_LOCALE=en # [OPTIONAL] Fallback locale (default: en)
SUPPORTED_LOCALES=en,el # [OPTIONAL] Comma-separated supported locales
# =============================================================================
# CMS — Content management system (Strapi / custom)
# =============================================================================
CMS_API_URL=http://localhost:3000/api # [REQUIRED] CMS API base URL
CMS_API_KEY= # [OPTIONAL] CMS API authentication key
# =============================================================================
# SECURITY — Rate limiting & GraphQL depth
# =============================================================================
RATE_LIMIT_TTL=60 # [OPTIONAL] Rate-limit window in seconds (default: 60)
RATE_LIMIT_MAX=100 # [OPTIONAL] Max requests per window (default: 100)
GRAPHQL_DEPTH_LIMIT=7 # [OPTIONAL] Max GraphQL query depth (default: 7)
CORS_ENABLED=true # [OPTIONAL] Enable CORS middleware (default: true)
# =============================================================================
# LOGGING — Observability
# =============================================================================
LOG_LEVEL=debug # [OPTIONAL] Log level: debug|info|warn|error|fatal
LOG_FORMAT=pretty # [OPTIONAL] Log format: pretty|json

171
.env.production.example Normal file
View File

@@ -0,0 +1,171 @@
# =============================================================================
# InvestPlay — Production Environment Configuration
# =============================================================================
# WARNING: Do NOT commit this file or any `.env` file to version control.
# Use a secure secret manager (AWS Secrets Manager, Doppler, HashiCorp Vault)
# or your CI/CD platform's encrypted secrets for production values.
#
# Copy this to your production environment and fill in every [REQUIRED] value.
#
# LEGEND:
# [REQUIRED] — Must be set before the application starts.
# [STRONGLY RECOMMENDED] — Highly advised for security/stability.
# [OPTIONAL] — Has a safe default; override only if needed.
# =============================================================================
# =============================================================================
# APP — Core application settings
# =============================================================================
# [REQUIRED] Must be "production" in production environment.
NODE_ENV=production
# [OPTIONAL] API server port (default: 3001)
PORT=3001
# [REQUIRED] Frontend application URL — must match your deployed frontend domain.
APP_URL=https://app.investplay.dev
# [REQUIRED] API base URL — must match your deployed API domain.
API_URL=https://api.investplay.dev
# [REQUIRED] Comma-separated allowed CORS origins — no trailing slashes.
# Include your frontend domain and any CMS/admin domains.
CORS_ORIGINS=https://app.investplay.dev,https://admin.investplay.dev
# =============================================================================
# DATABASE — PostgreSQL via Prisma
# =============================================================================
# [REQUIRED] Connection string for the primary database.
# Use a read-write user with minimal required permissions.
# Consider using a connection pooler (PgBouncer) for serverless environments.
# Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=SCHEMA&pool_timeout=10&connection_limit=20
DATABASE_URL=postgresql://investplay_prod:CHANGE_ME@prod-cluster.aws.com:5432/investplay_prod?schema=public&pool_timeout=10&connection_limit=20
# [OPTIONAL] Connection pool settings
DATABASE_POOL_MIN=2
DATABASE_POOL_MAX=20
# =============================================================================
# REDIS — Caching, queues, and session store
# =============================================================================
# [REQUIRED] Redis connection string — prefer TLS in production.
# For AWS ElastiCache: rediss://clustercfg.xxx.cache.amazonaws.com:6379
REDIS_URL=rediss://investplay-prod.xxxxx.ng.0001.use1.cache.amazonaws.com:6379
# [OPTIONAL] Redis password / ACL token if using authentication.
REDIS_PASSWORD=
# =============================================================================
# JWT — Authentication tokens
# =============================================================================
# [REQUIRED] ⚠️ CHANGE THIS TO A STRONG RANDOM VALUE ⚠️
# Generate with: openssl rand -hex 64
# Rotate periodically — do NOT reuse across environments.
JWT_SECRET=
# [STRONGLY RECOMMENDED] Token lifetimes — keep access tokens short.
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# =============================================================================
# AUTH — Authentication provider (Clerk) & SSO
# =============================================================================
# [OPTIONAL] Clerk API secret key — only if using Clerk for auth.
CLERK_SECRET_KEY=
# [OPTIONAL] Clerk webhook signing secret — for verifying Clerk webhooks.
CLERK_WEBHOOK_SECRET=
# [OPTIONAL] Enable SSO login (default: false).
AUTH_SSO_ENABLED=false
# =============================================================================
# AI — LLM providers (OpenAI / Gemini)
# =============================================================================
# [REQUIRED if using AI features] At least one provider API key must be set.
OPENAI_API_KEY=
# [OPTIONAL] OpenAI model name (default: gpt-4o-mini)
OPENAI_MODEL=gpt-4o-mini
GEMINI_API_KEY=
# [OPTIONAL] Gemini model name (default: gemini-1.5-flash)
GEMINI_MODEL=gemini-1.5-flash
AI_DEFAULT_DAILY_LIMIT=50
AI_DEFAULT_MONTHLY_TOKEN_BUDGET=2000000
AI_MAX_MESSAGE_LENGTH=500
# =============================================================================
# STORAGE — S3-compatible object storage
# =============================================================================
# [REQUIRED] S3 endpoint — use AWS S3, Cloudflare R2, or Backblaze B2.
S3_ENDPOINT=s3.us-east-1.amazonaws.com
# [OPTIONAL] S3 endpoint port (default: 443 for SSL)
S3_PORT=443
# [REQUIRED] Must be true in production.
S3_USE_SSL=true
# [REQUIRED] IAM access key with s3:PutObject, s3:GetObject, s3:DeleteObject permissions.
S3_ACCESS_KEY=
# [REQUIRED] IAM secret key.
S3_SECRET_KEY=
# [REQUIRED] Bucket name — must already exist.
S3_BUCKET=investplay-prod
# [OPTIONAL] AWS region (default: us-east-1)
S3_REGION=us-east-1
# =============================================================================
# EMAIL — Transactional emails (Resend)
# =============================================================================
# [OPTIONAL] Resend API key — required for sending emails.
RESEND_API_KEY=
# [STRONGLY RECOMMENDED] Sender — use a verified domain.
EMAIL_FROM=noreply@investplay.dev
EMAIL_REPLY_TO=support@investplay.dev
# =============================================================================
# BILLING — Stripe integration
# =============================================================================
# [OPTIONAL] Stripe secret key — required for billing features.
STRIPE_SECRET_KEY=
# [OPTIONAL] Stripe webhook signing secret — verify webhook authenticity.
STRIPE_WEBHOOK_SECRET=
# [OPTIONAL] Stripe price IDs — set after creating products in Stripe dashboard.
STRIPE_PRICE_BASIC=
STRIPE_PRICE_PREMIUM=
# =============================================================================
# TENANT — Multi-tenant platform defaults
# =============================================================================
# [OPTIONAL] Default AI mode for new tenants.
TENANT_DEFAULT_AI_MODE=MANAGED
# [OPTIONAL] Default monthly token cap per tenant.
TENANT_MAX_TOKENS_MONTHLY=1000000
# =============================================================================
# i18n — Internationalisation
# =============================================================================
# [OPTIONAL] Default/fallback locale.
DEFAULT_LOCALE=en
# [OPTIONAL] Comma-separated list of supported locales.
SUPPORTED_LOCALES=en,el
# =============================================================================
# CMS — Content management system
# =============================================================================
# [REQUIRED if using CMS] CMS API base URL.
CMS_API_URL=https://cms.investplay.dev/api
# [OPTIONAL] CMS API authentication key.
CMS_API_KEY=
# =============================================================================
# SECURITY — Rate limiting & GraphQL depth — production-tuned values
# =============================================================================
RATE_LIMIT_TTL=60
# [STRONGLY RECOMMENDED] Stricter rate limit for production.
RATE_LIMIT_MAX=50
GRAPHQL_DEPTH_LIMIT=7
CORS_ENABLED=true
# =============================================================================
# LOGGING — Observability — production prefers structured JSON
# =============================================================================
LOG_LEVEL=info
LOG_FORMAT=json

68
.eslintrc.js Normal file
View File

@@ -0,0 +1,68 @@
module.exports = {
root: true,
env: {
node: true,
es2022: true,
},
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
project: ["./tsconfig.base.json", "./apps/*/tsconfig.json", "./packages/*/tsconfig.json"],
},
plugins: ["@typescript-eslint"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/strict",
"prettier",
],
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports" },
],
"@typescript-eslint/no-import-type-side-effects": "error",
"no-console": ["warn", { allow: ["warn", "error"] }],
"prefer-const": "error",
"no-var": "error",
},
overrides: [
{
files: ["apps/api/**/*.ts"],
extends: [
"plugin:@typescript-eslint/recommended-requiring-type-checking",
],
rules: {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/return-await": "error",
},
},
{
files: ["apps/web/**/*.{ts,tsx}"],
plugins: ["react", "react-hooks"],
extends: [
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
],
settings: {
react: {
version: "detect",
},
},
rules: {
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react-hooks/exhaustive-deps": "warn",
},
},
],
};

53
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,53 @@
# InvestPlay — Code Ownership
# Each line defines a file pattern and the teams/users responsible.
# Order matters: the last matching pattern takes precedence.
# Default owners — everyone
* @investplay/core
# Application — Backend
apps/api/ @investplay/backend
apps/api/** @investplay/backend
# Application — Frontend
apps/web/ @investplay/frontend
apps/web/** @investplay/frontend
# Application — CMS
apps/cms/ @investplay/backend
apps/cms/** @investplay/backend
# Shared Packages
packages/types/ @investplay/types
packages/types/** @investplay/types
packages/ui/ @investplay/frontend
packages/ui/** @investplay/frontend
packages/i18n/ @investplay/frontend @investplay/backend
packages/i18n/** @investplay/frontend @investplay/backend
packages/utils/ @investplay/backend
packages/utils/** @investplay/backend
# Infrastructure & DevOps
docker/ @investplay/devops
docker/** @investplay/devops
docker-compose.* @investplay/devops
Dockerfile* @investplay/devops
# CI/CD
.github/ @investplay/devops
.github/** @investplay/devops
# Documentation
*.md @investplay/docs
docs/ @investplay/docs
docs/** @investplay/docs
# Configuration files
turbo.json @investplay/core
pnpm-workspace.yaml @investplay/core
tsconfig.base.json @investplay/core
.eslintrc.js @investplay/core
.prettierrc @investplay/core

89
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,89 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
day: monday
time: "09:00"
timezone: Europe/Athens
versioning-strategy: increase
open-pull-requests-limit: 15
labels:
- dependencies
- npm
groups:
production-dependencies:
dependency-type: production
update-types:
- minor
- patch
dev-dependencies:
dependency-type: development
update-types:
- minor
- patch
reviewers:
- "investplay/backend"
- "investplay/frontend"
ignore:
- dependency-name: "react"
versions: [">=19.0.0"]
- dependency-name: "react-dom"
versions: [">=19.0.0"]
- dependency-name: "@types/react"
versions: [">=19.0.0"]
- dependency-name: "@types/react-dom"
versions: [">=19.0.0"]
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: monday
time: "09:00"
timezone: Europe/Athens
labels:
- dependencies
- github-actions
reviewers:
- "investplay/devops"
- package-ecosystem: docker
directory: "/apps/api"
schedule:
interval: weekly
day: monday
time: "09:00"
timezone: Europe/Athens
labels:
- dependencies
- docker
reviewers:
- "investplay/devops"
- package-ecosystem: docker
directory: "/apps/web"
schedule:
interval: weekly
day: monday
time: "09:00"
timezone: Europe/Athens
labels:
- dependencies
- docker
reviewers:
- "investplay/devops"
- package-ecosystem: docker
directory: "/apps/cms"
schedule:
interval: weekly
day: monday
time: "09:00"
timezone: Europe/Athens
labels:
- dependencies
- docker
reviewers:
- "investplay/devops"

143
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,143 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: "20"
PNPM_VERSION: "9.15.9"
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Restore Turborepo cache
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run lint
run: pnpm lint
typecheck:
name: TypeCheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Restore Turborepo cache
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run typecheck
run: pnpm typecheck
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Restore Turborepo cache
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
directory: ./coverage
fail_ci_if_error: false
build:
name: Build
needs: [lint, typecheck, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Restore Turborepo cache
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build all packages
run: pnpm build

149
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,149 @@
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
inputs:
environment:
description: Target environment
type: choice
options:
- production
- staging
default: production
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
IMAGE_OWNER: investplay
NODE_VERSION: "20"
PNPM_VERSION: "9.15.9"
jobs:
docker-build:
name: Docker Build & Push
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
tags: ${{ steps.meta.outputs.tags }}
strategy:
matrix:
service:
- name: api
context: .
dockerfile: apps/api/Dockerfile
- name: web
context: .
dockerfile: apps/web/Dockerfile
- name: cms
context: .
dockerfile: apps/cms/Dockerfile
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build ${{ matrix.service.name }}
run: pnpm --filter @investplay/${{ matrix.service.name }} build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ matrix.service.name }}
tags: |
type=sha,format=long,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: ${{ matrix.service.context }}
file: ${{ matrix.service.dockerfile }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.service.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.service.name }}
provenance: false
deploy:
name: Deploy
needs: [docker-build]
runs-on: ubuntu-latest
environment:
name: ${{ github.event.inputs.environment || 'production' }}
url: https://investplay.app
steps:
- name: Deploy via Portainer webhook
if: vars.PORTAINER_WEBHOOK_URL != ''
run: |
curl -X POST "${{ vars.PORTAINER_WEBHOOK_URL }}" \
--fail-with-body
- name: Deploy via SSH to Contabo server
if: vars.PORTAINER_WEBHOOK_URL == ''
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT || '22' }}
script: |
set -e
cd /opt/investplay
echo "Pulling latest images..."
docker compose -f docker-compose.prod.yml pull
echo "Starting services..."
docker compose -f docker-compose.prod.yml up -d --remove-orphans
echo "Pruning old images..."
docker image prune -f
echo "Verifying health..."
sleep 10
docker compose -f docker-compose.prod.yml ps
- name: Notify deployment status
if: always()
uses: actions/github-script@v7
with:
script: |
const success = '${{ job.status }}' === 'success';
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: context.sha,
state: success ? 'success' : 'failure',
context: 'Deploy / production',
description: success ? 'Deployment completed successfully' : 'Deployment failed',
target_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
});

135
.github/workflows/pr-checks.yml vendored Normal file
View File

@@ -0,0 +1,135 @@
name: PR Checks
on:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: pr-checks-${{ github.head_ref }}
cancel-in-progress: true
jobs:
conventional-commit:
name: Conventional Commit Title
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check PR title
uses: actions/github-script@v7
with:
script: |
const title = context.payload.pull_request.title;
const pattern = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?!?:\s.+/;
if (!pattern.test(title)) {
core.setFailed(
`PR title "${title}" does not follow Conventional Commits format.\n\n` +
`Expected: type(scope): description\n` +
`Valid types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert\n\n` +
`Example: feat(auth): add SAML SSO login flow`
);
}
pr-size-label:
name: PR Size Labeler
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/github-script@v7
id: size
with:
result-encoding: string
script: |
const { data: diff } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
mediaType: { format: 'diff' },
});
const lines = diff.split('\n').filter(l => l.startsWith('+') || l.startsWith('-')).length;
let label;
if (lines <= 10) label = 'size/XS';
else if (lines <= 50) label = 'size/S';
else if (lines <= 200) label = 'size/M';
else if (lines <= 800) label = 'size/L';
else label = 'size/XL';
core.info(`PR has ${lines} changed lines → ${label}`);
return label;
- name: Remove existing size labels
uses: actions/github-script@v7
with:
script: |
const existing = context.payload.pull_request.labels
.map(l => l.name)
.filter(n => n.startsWith('size/'));
for (const label of existing) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: label,
});
}
- name: Add size label
uses: actions/github-script@v7
with:
script: |
const label = '${{ steps.size.outputs.result }}';
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: [label],
});
auto-assign-reviewer:
name: Auto-assign Reviewer
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Assign reviewer based on CODEOWNERS
uses: actions/github-script@v7
with:
script: |
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
const changedPaths = files.map(f => f.filename);
const rules = [
{ pattern: 'apps/api/', reviewers: ['investplay/backend'] },
{ pattern: 'apps/web/', reviewers: ['investplay/frontend'] },
{ pattern: 'apps/cms/', reviewers: ['investplay/backend'] },
{ pattern: 'packages/types/', reviewers: ['investplay/types'] },
{ pattern: 'packages/ui/', reviewers: ['investplay/frontend'] },
{ pattern: 'packages/i18n/', reviewers: ['investplay/frontend', 'investplay/backend'] },
{ pattern: 'packages/utils/', reviewers: ['investplay/backend'] },
{ pattern: 'docker/', reviewers: ['investplay/devops'] },
{ pattern: 'docker-compose.', reviewers: ['investplay/devops'] },
{ pattern: '.github/', reviewers: ['investplay/devops'] },
{ pattern: '*.md', reviewers: ['investplay/docs'] },
];
const reviewersToRequest = new Set();
for (const file of changedPaths) {
for (const rule of rules) {
if (file.startsWith(rule.pattern) || file.match(rule.pattern)) {
for (const r of rule.reviewers) reviewersToRequest.add(r);
}
}
}
if (reviewersToRequest.size > 0) {
await github.rest.pulls.requestReviewers({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
team_reviewers: [...reviewersToRequest],
});
}

4
.husky/pre-commit Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

4
.lintstagedrc.js Normal file
View File

@@ -0,0 +1,4 @@
module.exports = {
"*.{ts,tsx}": ["prettier --write", "eslint --fix"],
"*.{js,jsx,json,md}": ["prettier --write"],
};

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "lf"
}

127
README.md Normal file
View File

@@ -0,0 +1,127 @@
# InvestPlay
**Financial literacy education platform** — Teaching students to invest wisely through interactive simulations, gamified learning, and AI-powered coaching.
## Architecture
InvestPlay is a **Turborepo** monorepo managed with **pnpm** workspaces.
```
investplay/
├── apps/
│ ├── api/ # NestJS backend (REST, GraphQL, WebSocket)
│ ├── web/ # React + Vite + TailwindCSS frontend
│ └── cms/ # Payload CMS (content management)
├── packages/
│ ├── types/ # @investplay/types — shared TypeScript types
│ ├── ui/ # @investplay/ui — shared UI components (shadcn/ui)
│ ├── i18n/ # @investplay/i18n — translations (en, el)
│ └── utils/ # @investplay/utils — shared utilities
└── docs/ # Architecture and design documentation
```
### Tech Stack
| Layer | Technology |
| ----------- | ----------------------------------------------- |
| Monorepo | Turborepo + pnpm workspaces |
| Backend | NestJS, Prisma, GraphQL, Socket.io, BullMQ |
| Frontend | React 18, Vite, TailwindCSS, shadcn/ui |
| CMS | Payload CMS |
| Database | PostgreSQL (via Prisma) |
| Cache/Queue | Redis, BullMQ |
| AI | Azure OpenAI (Managed / BYOK) |
| i18n | i18next, react-i18next |
| Testing | Vitest, Playwright, Testing Library |
## Getting Started
### Prerequisites
- Node.js >= 20
- pnpm >= 9
### Install
```bash
pnpm install
```
### Development
Start all services in development mode:
```bash
pnpm dev
```
Or start individual apps:
```bash
pnpm --filter @investplay/api dev # API server (port 3001)
pnpm --filter @investplay/web dev # Web app (port 3000)
pnpm --filter @investplay/cms dev # CMS (port 3002)
```
### Code Quality
```bash
pnpm lint # Lint all packages
pnpm typecheck # TypeScript type checking
pnpm test # Run all tests
pnpm format # Format code with Prettier
pnpm build # Build all packages
```
## Packages
### `@investplay/types`
Shared TypeScript interfaces and enums for users, tenants, curriculum, simulations, gamification, classrooms, analytics, and AI coaching.
### `@investplay/ui`
Shared UI component library built on shadcn/ui primitives with TailwindCSS design tokens. Includes utility functions (`cn`, `formatCurrency`, `formatXP`, `formatPercentage`).
### `@investplay/i18n`
Internationalization layer using i18next. Supports English (`en`) and Greek (`el`) with namespaced translations for UI, finance terms, gamification, classroom management, and AI coach.
### `@investplay/utils`
Shared utilities: Zod validation schemas, formatting functions (currency, dates, numbers, percentages), and application constants.
## Apps
### API (`apps/api`)
NestJS backend providing:
- GraphQL API (Apollo Server)
- REST endpoints
- Real-time WebSocket events (Socket.io)
- Queue-based job processing (BullMQ)
- Prisma ORM for database access
### Web (`apps/web`)
React 18 SPA with:
- Vite for fast development and builds
- TailwindCSS with InvestPlay design tokens
- React Router for client-side routing
- TanStack Query for server state management
- Zustand for client state
- Recharts for data visualization
- Socket.io client for real-time updates
### CMS (`apps/cms`)
Payload CMS for managing:
- Curriculum content and lesson blocks
- Simulation configurations
- Badges and achievements
- User and tenant management
## Contributing
1. Create a feature branch: `git checkout -b feat/your-feature`
2. Make your changes
3. Run `pnpm lint` and `pnpm typecheck` and `pnpm test`
4. Commit using conventional commits: `feat:`, `fix:`, `docs:`, etc.
5. Open a pull request
## License
See [licence.md](./licence.md).

37
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY apps/api/package.json apps/api/tsconfig.json apps/api/tsconfig.build.json ./apps/api/
COPY packages/ ./packages/
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat python3 make g++
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY apps/api/package.json apps/api/tsconfig.json apps/api/tsconfig.build.json ./apps/api/
COPY packages/ ./packages/
RUN pnpm install --frozen-lockfile
COPY apps/api ./apps/api
COPY packages/ ./packages/
RUN pnpm --filter @investplay/api exec prisma generate
RUN pnpm --filter @investplay/api build
FROM node:20-alpine AS runner
RUN apk add --no-cache libc6-compat wget
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nodeuser
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/apps/api/dist ./dist
COPY --from=builder /app/apps/api/prisma ./prisma
COPY --from=builder /app/apps/api/package.json ./package.json
RUN chown -R nodeuser:nodejs /app
USER nodeuser
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1
CMD ["node", "dist/main.js"]

12
apps/api/Dockerfile.dev Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY apps/api/package.json apps/api/tsconfig.json ./apps/api/
COPY packages/ ./packages/
RUN pnpm install --frozen-lockfile
COPY apps/api/prisma ./apps/api/prisma
RUN pnpm --filter @investplay/api exec prisma generate
EXPOSE 3001
CMD ["pnpm", "--filter", "@investplay/api", "dev"]

8
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

74
apps/api/package.json Normal file
View File

@@ -0,0 +1,74 @@
{
"name": "@investplay/api",
"version": "0.1.0",
"private": true,
"description": "InvestPlay NestJS API backend",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start:prod": "node dist/main",
"lint": "eslint \"src/**/*.ts\" --fix",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "vitest run --config ./vitest.config.e2e.ts",
"clean": "rimraf dist .turbo",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:prod": "prisma migrate deploy",
"prisma:seed": "tsx prisma/seed.ts",
"prisma:studio": "prisma studio",
"prisma:reset": "prisma migrate reset --force"
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/graphql": "^13.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/platform-socket.io": "^11.0.0",
"@nestjs/bullmq": "^11.0.0",
"@nestjs/apollo": "^12.0.0",
"@nestjs/event-emitter": "^2.0.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/schedule": "^11.0.0",
"@nestjs/throttler": "^6.0.0",
"@prisma/client": "^6.5.0",
"graphql": "^16.10.0",
"graphql-tools": "^9.0.0",
"@apollo/server": "^4.10.0",
"apollo-server-express": "^3.13.0",
"ioredis": "^5.4.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"socket.io": "^4.8.0",
"zod": "^3.24.0",
"class-validator": "^0.14.1",
"class-transformer": "^0.5.1",
"helmet": "^8.0.0",
"cors": "^2.8.5",
"compression": "^1.7.4",
"cookie-parser": "^1.4.7",
"i18next": "^24.2.0",
"i18next-fs-backend": "^2.6.0",
"bcryptjs": "^2.4.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/passport-jwt": "^4.0.1",
"prisma": "^6.5.0",
"tsx": "^4.19.0",
"vitest": "^3.1.0",
"@types/node": "^22.14.0",
"@types/express": "^5.0.0",
"@types/cors": "^2.8.17",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.7",
"typescript": "^5.8.0",
"rimraf": "^6.0.0"
}
}

View File

View File

@@ -0,0 +1,572 @@
// ─────────────────────────────────────────────────────────────────────────────
// InvestPlay Prisma Schema (PostgreSQL)
// Multi-tenant financial education platform
// ─────────────────────────────────────────────────────────────────────────────
generator client {
provider = "prisma-client-js"
previewFeatures = ["multiSchema", "fullTextSearch"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Enums ───────────────────────────────────────────────────────────────────
enum UserRole {
STUDENT
TEACHER
TENANT_ADMIN
PLATFORM_ADMIN
CONTENT_MANAGER
RESEARCHER
}
enum AIMode {
MANAGED
BYOK
DISABLED
}
enum SubscriptionStatus {
TRIALING
ACTIVE
PAST_DUE
CANCELED
EXPIRED
}
enum CurriculumTrack {
SURVIVAL_BASICS
CREDIT_DEBT
WEALTH_BUILDER
ANTI_HYPE
MACROECONOMICS
}
enum ProgressStatus {
NOT_STARTED
IN_PROGRESS
COMPLETED
FAILED
}
enum SubmissionStatus {
PENDING
SUBMITTED
GRADED
RETURNED
}
enum SimulationType {
BUDGET_CHALLENGE
EMERGENCY_FUND
COST_OF_LIVING
BLIND_ASSET
MARKET_CRASH
RENT_VS_TRANSPORT
SAVINGS_HABIT
ESG_PORTFOLIO
INTEREST_APY
DEBT_REPAYMENT
}
enum SessionStatus {
WAITING
IN_PROGRESS
ENDED
CANCELLED
}
enum AssetType {
STOCK
ETF
BOND
CRYPTO
CASH
REAL_ESTATE
COMMODITY
}
enum TradeType {
BUY
SELL
}
enum XPReason {
LESSON_COMPLETED
QUIZ_PASSED
STREAK_MAINTAINED
CHALLENGE_COMPLETED
SIMULATION_COMPLETED
DIVERSIFICATION
RISK_MANAGEMENT
HYPE_AVOIDED
DEBT_PAID_OFF
}
enum BadgeCategory {
MILESTONE
SKILL
CHALLENGE
STREAK
SPECIAL
}
enum AnalyticsEventType {
LESSON_STARTED
LESSON_COMPLETED
QUIZ_ATTEMPTED
SIMULATION_JOINED
TRADE_EXECUTED
RISK_WARNING_SHOWN
RISK_WARNING_IGNORED
PANIC_SELL_DETECTED
HYPE_TRAP_TRIGGERED
AI_COACH_CONSULTED
}
// ─── Core Identity ───────────────────────────────────────────────────────────
model User {
id String @id @default(cuid())
email String @unique
passwordHash String?
firstName String
lastName String
role UserRole
locale String @default("en")
avatarUrl String?
tenantId String
xp Int @default(0)
level Int @default(1)
streakDays Int @default(0)
lastActiveAt DateTime?
emailVerifiedAt DateTime?
ssoProvider String?
ssoSubject String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Restrict, onUpdate: Cascade)
progress UserProgress[]
portfolios Portfolio[] @relation("UserPortfolio")
trades Trade[]
badges UserBadge[]
classrooms ClassroomStudent[]
aiLogs AILog[]
refreshTokens RefreshToken[]
quizAttempts QuizAttempt[]
xpEvents XPEvent[]
dailyStreaks DailyStreak[]
analyticsEvents AnalyticsEvent[]
createdClassrooms Classroom[] @relation("ClassroomTeacher")
createdSimulations SimulationSession[] @relation("SimulationCreator")
submittedAssignments AssignmentSubmission[]
@@index([tenantId])
@@index([email])
@@index([role])
@@unique([ssoProvider, ssoSubject])
@@map("users")
}
model Tenant {
id String @id @default(cuid())
name String
slug String @unique
domain String?
domains Json?
branding Json?
featureFlags Json?
aiMode AIMode @default(MANAGED)
aiApiKey String?
aiMonthlyTokenBudget Int @default(1000000)
aiTokensUsedThisMonth Int @default(0)
defaultLocale String @default("en")
excludeFromResearch Boolean @default(false)
subscriptionStatus SubscriptionStatus @default(TRIALING)
stripeCustomerId String?
stripeSubscriptionId String?
maxStudents Int?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
classrooms Classroom[]
modules Module[]
aiLogs AILog[]
@@index([slug])
@@index([domain])
@@index([subscriptionStatus])
@@map("tenants")
}
model RefreshToken {
id String @id @default(cuid())
token String @unique
userId String
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([userId])
@@index([token])
@@map("refresh_tokens")
}
// ─── Curriculum ──────────────────────────────────────────────────────────────
model Module {
id String @id @default(cuid())
cmsId String
track CurriculumTrack
title String
description String?
order Int
estimatedHours Float?
xpReward Int @default(100)
tenantId String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lessons Lesson[]
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([track])
@@index([tenantId])
@@index([cmsId])
@@map("modules")
}
model Lesson {
id String @id @default(cuid())
cmsId String
moduleId String
title String
description String?
order Int
estimatedMins Int @default(5)
xpReward Int @default(25)
requiredLessonId String?
blocks Json?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade, onUpdate: Cascade)
requiredLesson Lesson? @relation("LessonPrerequisite", fields: [requiredLessonId], references: [id], onDelete: SetNull, onUpdate: Cascade)
dependentLessons Lesson[] @relation("LessonPrerequisite")
progress UserProgress[]
assignments Assignment[]
quizAttempts QuizAttempt[]
@@index([moduleId])
@@index([cmsId])
@@map("lessons")
}
model UserProgress {
id String @id @default(cuid())
userId String
lessonId String
status ProgressStatus
quizScore Float?
quizAttempts Int @default(0)
timeSpentSeconds Int @default(0)
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Restrict, onUpdate: Cascade)
@@unique([userId, lessonId])
@@index([userId])
@@index([lessonId])
@@index([status])
@@map("user_progress")
}
model QuizAttempt {
id String @id @default(cuid())
userId String
lessonId String
answers Json
score Float
passed Boolean
attemptedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Restrict, onUpdate: Cascade)
@@index([userId])
@@index([lessonId])
@@map("quiz_attempts")
}
// ─── Classroom ───────────────────────────────────────────────────────────────
model Classroom {
id String @id @default(cuid())
name String
description String?
tenantId String
teacherId String
inviteCode String @unique
inviteExpiresAt DateTime?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade, onUpdate: Cascade)
teacher User @relation("ClassroomTeacher", fields: [teacherId], references: [id], onDelete: Restrict, onUpdate: Cascade)
students ClassroomStudent[]
assignments Assignment[]
simulationSessions SimulationSession[]
@@map("classrooms")
}
model ClassroomStudent {
id String @id @default(cuid())
classroomId String
userId String
joinedAt DateTime @default(now())
leftAt DateTime?
classroom Classroom @relation(fields: [classroomId], references: [id], onDelete: Cascade, onUpdate: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@unique([classroomId, userId])
@@index([userId])
@@map("classroom_students")
}
model Assignment {
id String @id @default(cuid())
classroomId String
lessonId String
title String
description String?
dueDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
classroom Classroom @relation(fields: [classroomId], references: [id], onDelete: Cascade, onUpdate: Cascade)
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Restrict, onUpdate: Cascade)
submissions AssignmentSubmission[]
@@map("assignments")
}
model AssignmentSubmission {
id String @id @default(cuid())
assignmentId String
userId String
status SubmissionStatus
score Float?
feedback String?
submittedAt DateTime?
gradedAt DateTime?
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@unique([assignmentId, userId])
@@map("assignment_submissions")
}
// ─── Simulation & Portfolio ──────────────────────────────────────────────────
model SimulationSession {
id String @id @default(cuid())
type SimulationType
title String
description String?
classroomId String?
createdById String
scenarioData Json
status SessionStatus
startedAt DateTime?
endedAt DateTime?
createdAt DateTime @default(now())
classroom Classroom? @relation(fields: [classroomId], references: [id], onDelete: SetNull, onUpdate: Cascade)
creator User @relation("SimulationCreator", fields: [createdById], references: [id], onDelete: Restrict, onUpdate: Cascade)
portfolios Portfolio[]
@@map("simulation_sessions")
}
model Portfolio {
id String @id @default(cuid())
userId String
simulationSessionId String?
name String @default("My Portfolio")
cashBalance Decimal @default(0)
initialBalance Decimal @default(10000)
currentValue Decimal?
roi Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation("UserPortfolio", fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
simulationSession SimulationSession? @relation(fields: [simulationSessionId], references: [id], onDelete: SetNull, onUpdate: Cascade)
holdings Holding[]
trades Trade[]
@@map("portfolios")
}
model Holding {
id String @id @default(cuid())
portfolioId String
assetSymbol String
assetName String?
assetType AssetType
quantity Decimal
averagePrice Decimal
currentPrice Decimal?
allocationPercentage Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([portfolioId])
@@map("holdings")
}
model Trade {
id String @id @default(cuid())
portfolioId String
userId String
assetSymbol String
type TradeType
quantity Decimal
price Decimal
totalValue Decimal
simulationTick Int?
executedAt DateTime @default(now())
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade, onUpdate: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([portfolioId])
@@index([userId])
@@index([executedAt])
@@map("trades")
}
// ─── Gamification ────────────────────────────────────────────────────────────
model Badge {
id String @id @default(cuid())
key String @unique
name String
description String
icon String
category BadgeCategory
criteria Json
xpReward Int @default(50)
createdAt DateTime @default(now())
users UserBadge[]
@@map("badges")
}
model UserBadge {
id String @id @default(cuid())
userId String
badgeId String
earnedAt DateTime @default(now())
context Json?
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
badge Badge @relation(fields: [badgeId], references: [id], onDelete: Restrict, onUpdate: Cascade)
@@unique([userId, badgeId])
@@index([userId])
@@map("user_badges")
}
model XPEvent {
id String @id @default(cuid())
userId String
reason XPReason
amount Int
context Json?
awardedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([userId])
@@index([awardedAt])
@@map("xp_events")
}
model DailyStreak {
id String @id @default(cuid())
userId String
date DateTime @db.Date
activities Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@unique([userId, date])
@@index([userId])
@@map("daily_streaks")
}
// ─── AI Coach ────────────────────────────────────────────────────────────────
model AILog {
id String @id @default(cuid())
tenantId String
userId String
sessionId String?
message String
responsePreview String?
tokensUsed Int
modelUsed String
contextInjected Json?
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade, onUpdate: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([tenantId])
@@index([userId])
@@index([createdAt])
@@index([tenantId, createdAt])
@@map("ai_logs")
}
// ─── Analytics ───────────────────────────────────────────────────────────────
model AnalyticsEvent {
id String @id @default(cuid())
userId String
eventType AnalyticsEventType
metadata Json
sessionId String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([userId])
@@index([eventType])
@@index([createdAt])
@@index([eventType, createdAt])
@@map("analytics_events")
}

833
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,833 @@
import { PrismaClient } from "@prisma/client";
import * as bcrypt from "bcryptjs";
const prisma = new PrismaClient();
// ─── Helpers ─────────────────────────────────────────────────────────────────
function cuid(seed: string): string {
// Deterministic CUID-like string for idempotent seeding
return seed.padStart(25, "c");
}
function mockCmsId(prefix: string, index: number): string {
return `${prefix}_${String(index).padStart(4, "0")}`;
}
async function ensureTenant(
slug: string,
data: Parameters<typeof prisma.tenant.create>[0]["data"],
) {
const existing = await prisma.tenant.findUnique({ where: { slug } });
if (existing) {
console.log(` Tenant "${slug}" already exists, skipping`);
return existing;
}
return prisma.tenant.create({ data });
}
async function ensureUser(
email: string,
data: Parameters<typeof prisma.user.create>[0]["data"],
) {
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
console.log(` User "${email}" already exists, skipping`);
return existing;
}
return prisma.user.create({ data });
}
async function ensureModule(
cmsId: string,
data: Parameters<typeof prisma.module.create>[0]["data"],
) {
const existing = await prisma.module.findFirst({ where: { cmsId } });
if (existing) {
return existing;
}
return prisma.module.create({ data });
}
async function ensureLesson(
cmsId: string,
data: Parameters<typeof prisma.lesson.create>[0]["data"],
) {
const existing = await prisma.lesson.findFirst({ where: { cmsId } });
if (existing) {
return existing;
}
return prisma.lesson.create({ data });
}
async function ensureBadge(
key: string,
data: Parameters<typeof prisma.badge.create>[0]["data"],
) {
const existing = await prisma.badge.findUnique({ where: { key } });
if (existing) {
console.log(` Badge "${key}" already exists, skipping`);
return existing;
}
return prisma.badge.create({ data });
}
// ─── Data Definitions ────────────────────────────────────────────────────────
const CURRICULUM_DATA: Array<{
track: string;
modules: Array<{
title: string;
description: string;
order: number;
estimatedHours: number;
lessons: Array<{
title: string;
description: string;
order: number;
estimatedMins: number;
}>;
}>;
}> = [
{
track: "SURVIVAL_BASICS",
modules: [
{
title: "Budgeting 101",
description: "Learn how to create and maintain a personal budget",
order: 1,
estimatedHours: 1.5,
lessons: [
{
title: "What is a Budget?",
description: "Understanding the fundamentals of budgeting",
order: 1,
estimatedMins: 10,
},
{
title: "Tracking Your Expenses",
description: "How to monitor where your money goes",
order: 2,
estimatedMins: 15,
},
{
title: "The 50/30/20 Rule",
description: "A simple framework for allocating your income",
order: 3,
estimatedMins: 12,
},
],
},
{
title: "Emergency Fund",
description: "Build your financial safety net",
order: 2,
estimatedHours: 1.0,
lessons: [
{
title: "Why You Need an Emergency Fund",
description: "The importance of having cash reserves",
order: 1,
estimatedMins: 8,
},
{
title: "How Much to Save",
description: "Calculating your target emergency fund amount",
order: 2,
estimatedMins: 10,
},
{
title: "Where to Keep Your Fund",
description: "High-yield savings accounts and other options",
order: 3,
estimatedMins: 7,
},
],
},
],
},
{
track: "CREDIT_DEBT",
modules: [
{
title: "Understanding Credit Scores",
description: "Demystifying credit reporting and scoring",
order: 1,
estimatedHours: 1.5,
lessons: [
{
title: "What Is a Credit Score?",
description: "The basics of credit scoring models",
order: 1,
estimatedMins: 10,
},
{
title: "Factors That Affect Your Score",
description: "Payment history, utilization, and more",
order: 2,
estimatedMins: 15,
},
{
title: "How to Improve Your Credit",
description: "Actionable steps to build better credit",
order: 3,
estimatedMins: 12,
},
],
},
{
title: "Debt Repayment Strategies",
description: "Methods to eliminate debt efficiently",
order: 2,
estimatedHours: 1.0,
lessons: [
{
title: "Snowball vs Avalanche",
description: "Comparing two popular debt payoff methods",
order: 1,
estimatedMins: 10,
},
{
title: "Debt Consolidation",
description: "When and how to consolidate your debts",
order: 2,
estimatedMins: 8,
},
{
title: "Avoiding Debt Traps",
description: "Predatory lending and high-interest pitfalls",
order: 3,
estimatedMins: 10,
},
],
},
],
},
{
track: "WEALTH_BUILDER",
modules: [
{
title: "Investing Basics",
description: "Getting started with investing",
order: 1,
estimatedHours: 2.0,
lessons: [
{
title: "Stocks, Bonds & ETFs",
description: "Understanding different asset classes",
order: 1,
estimatedMins: 15,
},
{
title: "Risk & Return",
description: "The fundamental trade-off in investing",
order: 2,
estimatedMins: 12,
},
{
title: "Dollar-Cost Averaging",
description: "A disciplined approach to investing regularly",
order: 3,
estimatedMins: 10,
},
],
},
{
title: "Compound Interest",
description: "The eighth wonder of the world",
order: 2,
estimatedHours: 1.0,
lessons: [
{
title: "How Compounding Works",
description: "The math behind exponential growth",
order: 1,
estimatedMins: 10,
},
{
title: "Time Is Your Superpower",
description: "Why starting early matters more than timing",
order: 2,
estimatedMins: 8,
},
{
title: "The Rule of 72",
description: "A quick way to estimate doubling time",
order: 3,
estimatedMins: 6,
},
],
},
],
},
{
track: "ANTI_HYPE",
modules: [
{
title: "Critical Thinking in Finance",
description: "Developing a skeptic's mindset",
order: 1,
estimatedHours: 1.5,
lessons: [
{
title: "Identifying Hype Cycles",
description: "Recognizing when euphoria drives markets",
order: 1,
estimatedMins: 12,
},
{
title: "Social Media & Investing",
description: "How online echo chambers affect decisions",
order: 2,
estimatedMins: 10,
},
{
title: "Due Diligence 101",
description: "Researching before you invest",
order: 3,
estimatedMins: 15,
},
],
},
{
title: "Recognizing Bubbles",
description: "Historical and modern market bubbles",
order: 2,
estimatedHours: 1.5,
lessons: [
{
title: "Tulip Mania to Dot-Com",
description: "Classic bubble case studies",
order: 1,
estimatedMins: 15,
},
{
title: "Crypto Mania",
description: "Evaluating digital asset hype",
order: 2,
estimatedMins: 12,
},
{
title: "Bubble Indicators",
description: "Signs that a market is overheating",
order: 3,
estimatedMins: 10,
},
],
},
],
},
{
track: "MACROECONOMICS",
modules: [
{
title: "Supply and Demand",
description: "The foundation of economic thinking",
order: 1,
estimatedHours: 1.5,
lessons: [
{
title: "Market Equilibrium",
description: "How prices are determined",
order: 1,
estimatedMins: 12,
},
{
title: "Elasticity",
description: "How quantity responds to price changes",
order: 2,
estimatedMins: 10,
},
{
title: "Market Failures",
description: "Externalities, public goods, and inefficiencies",
order: 3,
estimatedMins: 14,
},
],
},
{
title: "Monetary Policy",
description: "How central banks shape the economy",
order: 2,
estimatedHours: 1.5,
lessons: [
{
title: "Interest Rates & Inflation",
description: "The tools central banks use",
order: 1,
estimatedMins: 12,
},
{
title: "Quantitative Easing",
description: "Unconventional monetary policy explained",
order: 2,
estimatedMins: 10,
},
{
title: "Global Economic Indicators",
description: "GDP, unemployment, and CPI",
order: 3,
estimatedMins: 15,
},
],
},
],
},
];
const BADGE_DATA: Array<{
key: string;
name: string;
description: string;
icon: string;
category: string;
criteria: Record<string, unknown>;
xpReward: number;
}> = [
{
key: "first-trade",
name: "First Trade",
description: "Execute your very first trade in any simulation",
icon: "trophy",
category: "MILESTONE",
criteria: { type: "trade_count", threshold: 1 },
xpReward: 25,
},
{
key: "diversifier",
name: "Diversifier",
description: "Hold 5 or more different assets in a single portfolio",
icon: "pie-chart",
category: "SKILL",
criteria: { type: "asset_diversity", threshold: 5 },
xpReward: 75,
},
{
key: "crash-survivor",
name: "Crash Survivor",
description: "Survive a market crash simulation without going bankrupt",
icon: "shield",
category: "CHALLENGE",
criteria: { type: "simulation_survive", simulationType: "MARKET_CRASH" },
xpReward: 100,
},
{
key: "debt-destroyer",
name: "Debt Destroyer",
description: "Successfully complete the debt repayment simulation",
icon: "broken-chain",
category: "CHALLENGE",
criteria: { type: "simulation_complete", simulationType: "DEBT_REPAYMENT" },
xpReward: 100,
},
{
key: "the-skeptic",
name: "The Skeptic",
description: "Correctly identify and avoid 3 hype traps",
icon: "magnifying-glass",
category: "SKILL",
criteria: { type: "hype_avoided", threshold: 3 },
xpReward: 150,
},
{
key: "streak-master",
name: "Streak Master",
description: "Maintain a 30-day login streak",
icon: "fire",
category: "STREAK",
criteria: { type: "streak_days", threshold: 30 },
xpReward: 200,
},
{
key: "portfolio-millionaire",
name: "Portfolio Millionaire",
description: "Reach a portfolio value of $1,000,000",
icon: "diamond",
category: "MILESTONE",
criteria: { type: "portfolio_value", threshold: 1_000_000 },
xpReward: 500,
},
{
key: "quick-learner",
name: "Quick Learner",
description: "Complete 10 lessons in a single day",
icon: "rocket",
category: "CHALLENGE",
criteria: { type: "lessons_in_day", threshold: 10 },
xpReward: 100,
},
{
key: "teacher-pet",
name: "Teacher's Pet",
description: "Score 100% on 5 different quizzes",
icon: "apple",
category: "MILESTONE",
criteria: { type: "perfect_quizzes", threshold: 5 },
xpReward: 150,
},
{
key: "risk-avoider",
name: "Risk Avoider",
description: "Heed 5 risk warnings instead of ignoring them",
icon: "umbrella",
category: "SKILL",
criteria: { type: "risk_warnings_heeded", threshold: 5 },
xpReward: 75,
},
{
key: "simulation-champion",
name: "Simulation Champion",
description: "Win or top the leaderboard in 3 different simulations",
icon: "crown",
category: "CHALLENGE",
criteria: { type: "simulation_wins", threshold: 3 },
xpReward: 250,
},
{
key: "hype-avoider",
name: "Hype Avoider",
description: "Resist the urge to buy into a trending hype asset",
icon: "eye",
category: "SPECIAL",
criteria: { type: "hype_trap_resisted", threshold: 1 },
xpReward: 50,
},
];
// ─── Main Seeding Logic ──────────────────────────────────────────────────────
async function main() {
console.log("🌱 Seeding InvestPlay database...\n");
// ── 1. Tenants ──────────────────────────────────────────────────────────
console.log("📋 Creating tenants...");
const platformTenant = await ensureTenant("investplay", {
id: cuid("tenant-platform"),
name: "InvestPlay",
slug: "investplay",
domain: "app.investplay.dev",
domains: ["app.investplay.dev"],
branding: {
logoUrl: "https://investplay.dev/logo.png",
primaryColor: "#6366f1",
accentColor: "#06b6d4",
},
featureFlags: {
aiCoach: true,
simulations: true,
classrooms: true,
newsFeed: true,
},
aiMode: "MANAGED",
aiMonthlyTokenBudget: 10_000_000,
defaultLocale: "en",
subscriptionStatus: "ACTIVE",
maxStudents: 1000,
});
console.log(` ✅ Platform tenant: ${platformTenant.slug}`);
const demoTenant = await ensureTenant("demo-university", {
id: cuid("tenant-demo"),
name: "Demo University",
slug: "demo-university",
domain: "demo.university.edu",
domains: ["demo.university.edu", "student.demo.edu"],
branding: {
logoUrl: "https://demo.university.edu/logo.png",
primaryColor: "#059669",
accentColor: "#f59e0b",
},
featureFlags: {
aiCoach: true,
simulations: true,
classrooms: true,
newsFeed: false,
},
aiMode: "MANAGED",
aiMonthlyTokenBudget: 5_000_000,
defaultLocale: "en",
subscriptionStatus: "ACTIVE",
maxStudents: 200,
});
console.log(` ✅ Demo tenant: ${demoTenant.slug}\n`);
// ── 2. Users ────────────────────────────────────────────────────────────
console.log("👤 Creating users...");
const passwordHash = await bcrypt.hash("Admin123!", 10);
const adminUser = await ensureUser("admin@investplay.dev", {
id: cuid("user-admin"),
email: "admin@investplay.dev",
passwordHash,
firstName: "Platform",
lastName: "Admin",
role: "PLATFORM_ADMIN",
locale: "en",
tenantId: platformTenant.id,
xp: 9999,
level: 50,
streakDays: 100,
emailVerifiedAt: new Date(),
});
console.log(` ✅ Platform admin: ${adminUser.email}`);
const teacherUser = await ensureUser("teacher@demouni.edu", {
id: cuid("user-teacher"),
email: "teacher@demouni.edu",
passwordHash,
firstName: "Jane",
lastName: "Educator",
role: "TEACHER",
locale: "en",
tenantId: demoTenant.id,
xp: 2500,
level: 15,
streakDays: 30,
emailVerifiedAt: new Date(),
});
console.log(` ✅ Demo teacher: ${teacherUser.email}`);
const studentUser = await ensureUser("student@demo.edu", {
id: cuid("user-student"),
email: "student@demo.edu",
passwordHash,
firstName: "Alex",
lastName: "Learner",
role: "STUDENT",
locale: "en",
tenantId: demoTenant.id,
xp: 450,
level: 5,
streakDays: 7,
emailVerifiedAt: new Date(),
});
console.log(` ✅ Demo student: ${studentUser.email}\n`);
// ── 3. Curriculum ───────────────────────────────────────────────────────
console.log("📚 Creating curriculum...");
let lessonIdCounter = 0;
const createdLessons: Array<{ cmsId: string; lessonId: string; track: string }> = [];
for (const trackData of CURRICULUM_DATA) {
let moduleIndex = 0;
for (const moduleData of trackData.modules) {
moduleIndex++;
const moduleCmsId = mockCmsId(
trackData.track.substring(0, 3),
moduleIndex,
);
const module = await ensureModule(moduleCmsId, {
id: cuid(`module-${trackData.track}-${moduleIndex}`),
cmsId: moduleCmsId,
track: trackData.track as any,
title: moduleData.title,
description: moduleData.description,
order: moduleData.order,
estimatedHours: moduleData.estimatedHours,
xpReward: 100,
tenantId: null,
isActive: true,
});
let lessonIndex = 0;
for (const lessonData of moduleData.lessons) {
lessonIndex++;
lessonIdCounter++;
const lessonCmsId = mockCmsId(
`${trackData.track.substring(0, 3)}${moduleIndex}L`,
lessonIndex,
);
// Determine gating: first lesson of each module has no prerequisite
// Subsequent lessons require the previous lesson in the module
const requiredLessonCmsId =
lessonIndex > 1
? mockCmsId(
`${trackData.track.substring(0, 3)}${moduleIndex}L`,
lessonIndex - 1,
)
: null;
const requiredLesson =
requiredLessonCmsId !== null
? createdLessons.find(
(l) => l.cmsId === requiredLessonCmsId && l.track === trackData.track,
)
: null;
const lesson = await ensureLesson(lessonCmsId, {
id: cuid(`lesson-${trackData.track}-${moduleIndex}-${lessonIndex}`),
cmsId: lessonCmsId,
moduleId: module.id,
title: lessonData.title,
description: lessonData.description,
order: lessonData.order,
estimatedMins: lessonData.estimatedMins,
xpReward: 25,
requiredLessonId: requiredLesson?.lessonId ?? null,
blocks: {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
content: [{ type: "text", text: lessonData.title }],
},
{
type: "paragraph",
content: [
{
type: "text",
text: lessonData.description,
},
],
},
],
},
isActive: true,
});
createdLessons.push({
cmsId: lessonCmsId,
lessonId: lesson.id,
track: trackData.track,
});
}
}
}
console.log(` ✅ Created ${createdLessons.length} lessons across 10 modules\n`);
// ── 4. Badges ───────────────────────────────────────────────────────────
console.log("🏅 Creating badges...");
for (const badgeData of BADGE_DATA) {
await ensureBadge(badgeData.key, {
id: cuid(`badge-${badgeData.key}`),
key: badgeData.key,
name: badgeData.name,
description: badgeData.description,
icon: badgeData.icon,
category: badgeData.category as any,
criteria: badgeData.criteria,
xpReward: badgeData.xpReward,
});
}
console.log(` ✅ Created ${BADGE_DATA.length} badges\n`);
// ── 5. Classroom ────────────────────────────────────────────────────────
console.log("🏫 Creating demo classroom...");
const classroomId = cuid("classroom-demo");
const existingClassroom = await prisma.classroom.findFirst({
where: { inviteCode: "DEMO123" },
});
let classroom;
if (existingClassroom) {
classroom = existingClassroom;
console.log(` Classroom "Demo 101" already exists, skipping`);
} else {
classroom = await prisma.classroom.create({
data: {
id: classroomId,
name: "Demo 101 Personal Finance",
description:
"Introduction to personal finance for first-year students. This classroom covers budgeting, credit, and investing fundamentals.",
tenantId: demoTenant.id,
teacherId: teacherUser.id,
inviteCode: "DEMO123",
isActive: true,
},
});
console.log(` ✅ Created classroom: ${classroom.name}`);
// Enroll the student
const existingEnrollment = await prisma.classroomStudent.findUnique({
where: {
classroomId_userId: {
classroomId: classroom.id,
userId: studentUser.id,
},
},
});
if (!existingEnrollment) {
await prisma.classroomStudent.create({
data: {
id: cuid("enrollment-demo"),
classroomId: classroom.id,
userId: studentUser.id,
},
});
console.log(` ✅ Enrolled ${studentUser.email} in ${classroom.name}`);
} else {
console.log(` ${studentUser.email} already enrolled, skipping`);
}
}
// ── 6. Assign first-module lesson progress for the demo student ────────
const firstLesson = createdLessons.find((l) => l.track === "SURVIVAL_BASICS");
if (firstLesson) {
const existingProgress = await prisma.userProgress.findUnique({
where: {
userId_lessonId: {
userId: studentUser.id,
lessonId: firstLesson.lessonId,
},
},
});
if (!existingProgress) {
await prisma.userProgress.create({
data: {
id: cuid("progress-demo-1"),
userId: studentUser.id,
lessonId: firstLesson.lessonId,
status: "COMPLETED",
quizScore: 90,
quizAttempts: 1,
timeSpentSeconds: 420,
completedAt: new Date(),
},
});
// Also grant XP for the completed lesson
await prisma.xPEvent.create({
data: {
id: cuid("xpevent-demo-1"),
userId: studentUser.id,
reason: "LESSON_COMPLETED",
amount: 25,
context: { lessonId: firstLesson.lessonId },
},
});
console.log(` ✅ Demo student progress created for first lesson\n`);
} else {
console.log(` Demo student progress exists, skipping\n`);
}
}
console.log("────────────────────────────────────────────");
console.log("✅ Seeding complete!");
console.log("────────────────────────────────────────────");
console.log(" Admin: admin@investplay.dev / Admin123!");
console.log(" Teacher: teacher@demouni.edu / Admin123!");
console.log(" Student: student@demo.edu / Admin123!");
console.log("────────────────────────────────────────────");
}
main()
.catch((e) => {
console.error("❌ Seeding failed:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,63 @@
import { Controller, Get, Logger, ServiceUnavailableException } from "@nestjs/common";
import { SkipThrottle } from "@nestjs/throttler";
import { Public } from "./common/decorators/public.decorator.js";
import { PrismaService } from "./common/prisma/prisma.service.js";
import { RedisService } from "./common/redis/redis.service.js";
@SkipThrottle()
@Controller("health")
export class AppController {
private readonly logger = new Logger(AppController.name);
private readonly startTime: number;
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {
this.startTime = Date.now();
}
@Public()
@Get()
check() {
return {
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.npm_package_version ?? "0.1.0",
uptime: Math.floor((Date.now() - this.startTime) / 1000),
environment: process.env.NODE_ENV ?? "development",
};
}
@Public()
@Get("db")
async checkDatabase() {
try {
await this.prisma.$queryRaw`SELECT 1`;
return { status: "ok", service: "database" };
} catch (error) {
this.logger.error("Database health check failed", error);
throw new ServiceUnavailableException({
status: "error",
service: "database",
message: "Database connection failed",
});
}
}
@Public()
@Get("redis")
async checkRedis() {
try {
await this.redis.ping();
return { status: "ok", service: "redis" };
} catch (error) {
this.logger.error("Redis health check failed", error);
throw new ServiceUnavailableException({
status: "error",
service: "redis",
message: "Redis connection failed",
});
}
}
}

116
apps/api/src/app.module.ts Normal file
View File

@@ -0,0 +1,116 @@
import { Module } from "@nestjs/common";
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from "@nestjs/core";
import { GraphQLModule } from "@nestjs/graphql";
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { BullModule } from "@nestjs/bullmq";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
import { join } from "node:path";
import { redis } from "./config/index.js";
import { AppController } from "./app.controller.js";
import { PrismaModule } from "./common/prisma/prisma.module.js";
import { RedisModule } from "./common/redis/redis.module.js";
import { QueueModule } from "./common/queue/queue.module.js";
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard.js";
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor.js";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter.js";
import { AuthModule } from "./modules/auth/auth.module.js";
import { TenantModule } from "./modules/tenant/tenant.module.js";
import { CurriculumModule } from "./modules/curriculum/curriculum.module.js";
import { SimulationModule } from "./modules/simulation/simulation.module.js";
import { PortfolioModule } from "./modules/portfolio/portfolio.module.js";
import { AICoachModule } from "./modules/ai-coach/ai-coach.module.js";
import { AnalyticsModule } from "./modules/analytics/analytics.module.js";
import { ClassroomModule } from "./modules/classroom/classroom.module.js";
import { GamificationModule } from "./modules/gamification/gamification.module.js";
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), "src/schema.gql"),
sortSchema: true,
playground: process.env.NODE_ENV !== "production",
introspection: process.env.NODE_ENV !== "production",
context: ({ req, res }) => ({ req, res }),
formatError: (formattedError) => ({
message: formattedError.message,
code:
(formattedError.extensions?.code as string) ?? "INTERNAL_SERVER_ERROR",
locations: formattedError.locations,
path: formattedError.path,
}),
}),
BullModule.forRootAsync({
useFactory: () => ({
connection: {
url: redis().url,
password: redis().password,
maxRetriesPerRequest: null,
enableReadyCheck: false,
},
defaultJobOptions: {
attempts: 3,
backoff: {
type: "exponential",
delay: 1000,
},
removeOnComplete: {
age: 3600 * 24 * 7,
},
removeOnFail: {
age: 3600 * 24 * 30,
},
},
}),
}),
EventEmitterModule.forRoot({
wildcard: false,
delimiter: ".",
verboseMemoryLeak: true,
maxListeners: 20,
}),
ThrottlerModule.forRoot([
{
ttl: 60000,
limit: 100,
},
]),
PrismaModule,
RedisModule,
QueueModule,
AuthModule,
TenantModule,
CurriculumModule,
SimulationModule,
PortfolioModule,
AICoachModule,
AnalyticsModule,
ClassroomModule,
GamificationModule,
],
controllers: [AppController],
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
],
})
export class AppModule {}

View File

@@ -0,0 +1,14 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
export const CurrentTenant = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
const gqlReq = ctx.getContext().req;
if (gqlReq) {
return gqlReq.tenantId;
}
const httpReq = context.switchToHttp().getRequest();
return httpReq.tenantId;
},
);

View File

@@ -0,0 +1,14 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
export const CurrentUser = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
const gqlReq = ctx.getContext().req;
if (gqlReq) {
return gqlReq.user;
}
const httpReq = context.switchToHttp().getRequest();
return httpReq.user;
},
);

View File

@@ -0,0 +1,4 @@
export { Roles, ROLES_KEY } from "./roles.decorator.js";
export { CurrentUser } from "./current-user.decorator.js";
export { CurrentTenant } from "./current-tenant.decorator.js";
export { Public, IS_PUBLIC_KEY } from "./public.decorator.js";

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from "@nestjs/common";
import type { UserRole } from "@investplay/types";
export const ROLES_KEY = "roles";
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,115 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from "@nestjs/common";
import { GqlArgumentsHost, GqlExceptionFilter } from "@nestjs/graphql";
import { GraphQLError } from "graphql";
import type { Request, Response } from "express";
@Catch()
export class HttpExceptionFilter implements ExceptionFilter, GqlExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const gqlHost = GqlArgumentsHost.create(host);
if (gqlHost.getType() === "graphql") {
return this.handleGraphQLError(exception);
}
return this.handleHttpError(exception, host);
}
private handleGraphQLError(exception: unknown) {
if (exception instanceof GraphQLError) {
return exception;
}
if (exception instanceof HttpException) {
const response = exception.getResponse();
const status = exception.getStatus();
const message =
typeof response === "string"
? response
: (response as Record<string, unknown>).message ?? exception.message;
this.logger.warn(`GraphQL exception: ${status} - ${JSON.stringify(message)}`);
return new GraphQLError(
typeof message === "string" ? message : JSON.stringify(message),
{
extensions: {
code: this.mapHttpStatusToCode(status),
statusCode: status,
},
},
);
}
const error = exception as Error;
this.logger.error("Unhandled GraphQL exception", error.stack);
return new GraphQLError("Internal server error", {
extensions: {
code: "INTERNAL_SERVER_ERROR",
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
},
});
}
private handleHttpError(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string | object = "Internal server error";
let errorCode = "INTERNAL_SERVER_ERROR";
if (exception instanceof HttpException) {
status = exception.getStatus();
const exResponse = exception.getResponse();
errorCode = this.mapHttpStatusToCode(status);
if (typeof exResponse === "string") {
message = exResponse;
} else {
const resp = exResponse as Record<string, unknown>;
message = (resp.message as string | object) ?? exception.message;
errorCode = (resp.error as string) ?? errorCode;
}
} else if (exception instanceof Error) {
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
}
this.logger.warn(
`${request.method} ${request.url} -> ${status} ${JSON.stringify(message)}`,
);
response.status(status).json({
statusCode: status,
errorCode,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
private mapHttpStatusToCode(status: number): string {
const map: Record<number, string> = {
[HttpStatus.BAD_REQUEST]: "BAD_REQUEST",
[HttpStatus.UNAUTHORIZED]: "UNAUTHORIZED",
[HttpStatus.FORBIDDEN]: "FORBIDDEN",
[HttpStatus.NOT_FOUND]: "NOT_FOUND",
[HttpStatus.CONFLICT]: "CONFLICT",
[HttpStatus.TOO_MANY_REQUESTS]: "RATE_LIMIT_EXCEEDED",
[HttpStatus.UNPROCESSABLE_ENTITY]: "VALIDATION_ERROR",
[HttpStatus.INTERNAL_SERVER_ERROR]: "INTERNAL_SERVER_ERROR",
[HttpStatus.SERVICE_UNAVAILABLE]: "SERVICE_UNAVAILABLE",
};
return map[status] ?? "INTERNAL_SERVER_ERROR";
}
}

View File

@@ -0,0 +1,37 @@
import { Injectable, ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { GqlExecutionContext } from "@nestjs/graphql";
import { AuthGuard } from "@nestjs/passport";
import { IS_PUBLIC_KEY } from "../decorators/public.decorator.js";
@Injectable()
export class GqlJwtGuard extends AuthGuard("jwt") {
constructor(private readonly reflector: Reflector) {
super();
}
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req;
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
handleRequest(err: Error | null, user: unknown) {
if (err || !user) {
throw err ?? new UnauthorizedException("Invalid or expired token");
}
return user;
}
}

View File

@@ -0,0 +1,37 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { GqlExecutionContext } from "@nestjs/graphql";
import { ROLES_KEY } from "../decorators/roles.decorator.js";
import type { UserRole } from "@investplay/types";
@Injectable()
export class GqlRolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const ctx = GqlExecutionContext.create(context);
const user = ctx.getContext().req?.user;
if (!user) {
throw new ForbiddenException("Authentication required");
}
const hasRole = requiredRoles.includes(user.role);
if (!hasRole) {
throw new ForbiddenException(
`Required role: ${requiredRoles.join(", ")}. Current role: ${user.role}`,
);
}
return true;
}
}

View File

@@ -0,0 +1,5 @@
export { JwtAuthGuard } from "./jwt-auth.guard.js";
export { RolesGuard } from "./roles.guard.js";
export { TenantGuard } from "./tenant.guard.js";
export { GqlJwtGuard } from "./gql-jwt.guard.js";
export { GqlRolesGuard } from "./gql-roles.guard.js";

View File

@@ -0,0 +1,31 @@
import { Injectable, ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import { IS_PUBLIC_KEY } from "../decorators/public.decorator.js";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
handleRequest(err: Error | null, user: unknown) {
if (err || !user) {
throw err ?? new UnauthorizedException("Invalid or expired token");
}
return user;
}
}

View File

@@ -0,0 +1,36 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "../decorators/roles.decorator.js";
import type { UserRole } from "@investplay/types";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException("Authentication required");
}
const hasRole = requiredRoles.includes(user.role);
if (!hasRole) {
throw new ForbiddenException(
`Required role: ${requiredRoles.join(", ")}. Current role: ${user.role}`,
);
}
return true;
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { IS_PUBLIC_KEY } from "../decorators/public.decorator.js";
@Injectable()
export class TenantGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const tenantId = request.headers["x-tenant-id"] ?? request.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException("Tenant context required");
}
request.tenantId = tenantId;
return true;
}
}

View File

@@ -0,0 +1,60 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
import { Observable } from "rxjs";
import { tap } from "rxjs/operators";
import type { Request, Response } from "express";
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger("HTTP");
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const gqlContext = GqlExecutionContext.create(context);
if (gqlContext.getType() === "graphql") {
const info = gqlContext.getInfo();
const operation = info?.operation?.operation ?? "query";
const fieldName = info?.fieldName ?? "unknown";
const startTime = Date.now();
return next.handle().pipe(
tap({
next: () => {
const duration = Date.now() - startTime;
this.logger.debug(`[GraphQL] ${operation} ${fieldName} (${duration}ms)`);
},
error: (err: Error) => {
const duration = Date.now() - startTime;
this.logger.warn(
`[GraphQL] ${operation} ${fieldName} FAILED (${duration}ms): ${err.message}`,
);
},
}),
);
}
const httpRequest = context.switchToHttp().getRequest<Request>();
const { method, url } = httpRequest;
const startTime = Date.now();
return next.handle().pipe(
tap({
next: () => {
const httpResponse = context.switchToHttp().getResponse<Response>();
const duration = Date.now() - startTime;
this.logger.log(`${method} ${url} -> ${httpResponse.statusCode} (${duration}ms)`);
},
error: (err: Error) => {
const duration = Date.now() - startTime;
this.logger.warn(`${method} ${url} FAILED (${duration}ms): ${err.message}`);
},
}),
);
}
}

View File

@@ -0,0 +1,32 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from "@nestjs/common";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
export interface WrappedResponse<T> {
data: T;
meta: {
timestamp: string;
};
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, WrappedResponse<T>> {
intercept(
_context: ExecutionContext,
next: CallHandler<T>,
): Observable<WrappedResponse<T>> {
return next.handle().pipe(
map((data) => ({
data,
meta: {
timestamp: new Date().toISOString(),
},
})),
);
}
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service.js";
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,54 @@
import { Injectable, OnModuleInit, Logger } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
log:
process.env.NODE_ENV === "production"
? ["warn", "error"]
: ["query", "warn", "error"],
errorFormat: "pretty",
});
}
async onModuleInit() {
try {
await this.$connect();
this.logger.log("Connected to database");
} catch (error) {
this.logger.error("Failed to connect to database", error);
throw error;
}
}
async enableShutdownHooks() {
process.on("beforeExit", async () => {
await this.$disconnect();
});
}
async healthCheck(): Promise<boolean> {
try {
await this.$queryRaw`SELECT 1`;
return true;
} catch {
return false;
}
}
getTenantSchema(tenantId: string): string {
return `tenant_${tenantId.replace(/-/g, "_")}`;
}
async withTenant<T>(tenantId: string, fn: (prisma: PrismaClient) => Promise<T>): Promise<T> {
const schema = this.getTenantSchema(tenantId);
return this.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET search_path TO "${schema}", public`);
return fn(tx as unknown as PrismaClient);
});
}
}

View File

@@ -0,0 +1,17 @@
import { Global, Module } from "@nestjs/common";
import { BullModule } from "@nestjs/bullmq";
import { QueueService } from "./queue.service.js";
@Global()
@Module({
imports: [
BullModule.registerQueue(
{ name: "default" },
{ name: "email" },
{ name: "analytics" },
),
],
providers: [QueueService],
exports: [QueueService],
})
export class QueueModule {}

View File

@@ -0,0 +1,106 @@
import { Injectable } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bullmq";
import { Queue } from "bullmq";
export enum JobType {
SEND_EMAIL = "send-email",
PROCESS_ANALYTICS = "process-analytics",
GENERATE_REPORT = "generate-report",
SYNC_CMS = "sync-cms",
PROCESS_AI_COACH = "process-ai-coach",
AWARD_XP = "award-xp",
UPDATE_LEADERBOARD = "update-leaderboard",
TENANT_PROVISION = "tenant-provision",
CLEANUP_SESSIONS = "cleanup-sessions",
}
@Injectable()
export class QueueService {
constructor(
@InjectQueue("default") private readonly defaultQueue: Queue,
@InjectQueue("email") private readonly emailQueue: Queue,
@InjectQueue("analytics") private readonly analyticsQueue: Queue,
) {}
async addJob<T>(type: JobType, data: T, delayMs?: number): Promise<string> {
const queue = this.getQueueForJob(type);
const job = await queue.add(type, data, {
delay: delayMs,
attempts: 3,
backoff: { type: "exponential", delay: 1000 },
});
return job.id ?? "";
}
async addBulkJobs<T>(jobs: { type: JobType; data: T; delayMs?: number }[]): Promise<void> {
const grouped = new Map<Queue, { name: string; data: unknown; opts?: object }[]>();
for (const job of jobs) {
const queue = this.getQueueForJob(job.type);
const existing = grouped.get(queue) ?? [];
existing.push({
name: job.type,
data: job.data,
opts: job.delayMs ? { delay: job.delayMs } : undefined,
});
grouped.set(queue, existing);
}
for (const [queue, entries] of grouped) {
await queue.addBulk(entries);
}
}
async getJobCounts(): Promise<Record<string, number>> {
const [defaultCounts, emailCounts, analyticsCounts] = await Promise.all([
this.defaultQueue.getJobCounts(),
this.emailQueue.getJobCounts(),
this.analyticsQueue.getJobCounts(),
]);
return {
default: Object.values(defaultCounts).reduce((a, b) => a + b, 0),
email: Object.values(emailCounts).reduce((a, b) => a + b, 0),
analytics: Object.values(analyticsCounts).reduce((a, b) => a + b, 0),
};
}
async removeJob(jobId: string): Promise<void> {
const job =
(await this.defaultQueue.getJob(jobId)) ??
(await this.emailQueue.getJob(jobId)) ??
(await this.analyticsQueue.getJob(jobId));
if (job) {
await job.remove();
}
}
async pauseAll(): Promise<void> {
await Promise.all([
this.defaultQueue.pause(),
this.emailQueue.pause(),
this.analyticsQueue.pause(),
]);
}
async resumeAll(): Promise<void> {
await Promise.all([
this.defaultQueue.resume(),
this.emailQueue.resume(),
this.analyticsQueue.resume(),
]);
}
private getQueueForJob(type: JobType): Queue {
switch (type) {
case JobType.SEND_EMAIL:
return this.emailQueue;
case JobType.PROCESS_ANALYTICS:
case JobType.GENERATE_REPORT:
return this.analyticsQueue;
default:
return this.defaultQueue;
}
}
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { RedisService } from "./redis.service.js";
@Global()
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

View File

@@ -0,0 +1,122 @@
import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
import Redis from "ioredis";
import { redis as redisConfig } from "../../config/index.js";
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
private readonly client: Redis;
private readonly subscriber: Redis;
constructor() {
const { url, password } = redisConfig();
const commonOptions = {
password,
retryStrategy: (times: number) => {
if (times > 10) {
this.logger.error("Redis max retries reached");
return null;
}
return Math.min(times * 100, 3000);
},
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: true,
};
this.client = new Redis(url, commonOptions);
this.subscriber = new Redis(url, { ...commonOptions, maxRetriesPerRequest: null });
this.client.on("connect", () => this.logger.log("Connected to Redis"));
this.client.on("error", (err) => this.logger.error("Redis connection error", err));
this.subscriber.on("error", (err) => this.logger.error("Redis subscriber error", err));
}
async onModuleDestroy() {
await this.quit();
}
async ping(): Promise<boolean> {
try {
const result = await this.client.ping();
return result === "PONG";
} catch {
return false;
}
}
async quit(): Promise<void> {
try {
await this.client.quit();
await this.subscriber.quit();
} catch (error) {
this.logger.error("Error closing Redis connections", error);
}
}
async get<T = string>(key: string): Promise<T | null> {
const value = await this.client.get(key);
if (value === null) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as unknown as T;
}
}
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
const serialized = typeof value === "string" ? value : JSON.stringify(value);
if (ttlSeconds) {
await this.client.setex(key, ttlSeconds, serialized);
} else {
await this.client.set(key, serialized);
}
}
async del(key: string): Promise<void> {
await this.client.del(key);
}
async exists(key: string): Promise<boolean> {
const result = await this.client.exists(key);
return result === 1;
}
async ttl(key: string): Promise<number> {
return this.client.ttl(key);
}
async incr(key: string): Promise<number> {
return this.client.incr(key);
}
async expire(key: string, seconds: number): Promise<void> {
await this.client.expire(key, seconds);
}
async publish(channel: string, message: unknown): Promise<void> {
const serialized = typeof message === "string" ? message : JSON.stringify(message);
await this.client.publish(channel, serialized);
}
async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
await this.subscriber.subscribe(channel);
this.subscriber.on("message", (ch: string, message: string) => {
if (ch === channel) {
callback(message);
}
});
}
async unsubscribe(channel: string): Promise<void> {
await this.subscriber.unsubscribe(channel);
}
getClient(): Redis {
return this.client;
}
getSubscriber(): Redis {
return this.subscriber;
}
}

View File

@@ -0,0 +1,12 @@
import type { User } from "@investplay/types";
export interface RequestContext {
user?: User;
tenantId: string;
requestId: string;
}
export interface AuthenticatedRequest {
user: User;
tenantId: string;
}

View File

@@ -0,0 +1,46 @@
import { Field, InputType, Int, ObjectType } from "@nestjs/graphql";
import { Min, Max, IsOptional } from "class-validator";
@InputType()
export class PaginationArgs {
@Field(() => Int, { nullable: true, defaultValue: 1 })
@IsOptional()
@Min(1)
page?: number;
@Field(() => Int, { nullable: true, defaultValue: 20 })
@IsOptional()
@Min(1)
@Max(100)
limit?: number;
@Field({ nullable: true })
@IsOptional()
cursor?: string;
}
@ObjectType()
export class PageInfo {
@Field(() => Int)
totalCount: number;
@Field(() => Int)
totalPages: number;
@Field(() => Int)
currentPage: number;
@Field(() => Int)
limit: number;
@Field({ nullable: true })
hasNextPage: boolean;
@Field({ nullable: true })
hasPreviousPage: boolean;
}
export interface PaginatedResponse<T> {
items: T[];
pageInfo: PageInfo;
}

View File

@@ -0,0 +1,144 @@
import { validateEnv, type EnvConfig } from "./env.validation.js";
import { parseEnvArray } from "@investplay/utils/env.js";
let _config: Readonly<EnvConfig> | null = null;
/**
* Return the validated, frozen environment config.
* Validates on first call and caches the result.
*/
export function getEnvConfig(): Readonly<EnvConfig> {
if (!_config) {
_config = Object.freeze(validateEnv());
}
return _config;
}
// ─── Convenience accessors (grouped by domain) ──────────────────────────────
export const env = (): Readonly<EnvConfig> => getEnvConfig();
export const database = () => {
const c = getEnvConfig();
return {
url: c.DATABASE_URL,
poolMin: c.DATABASE_POOL_MIN,
poolMax: c.DATABASE_POOL_MAX,
};
};
export const redis = () => {
const c = getEnvConfig();
return {
url: c.REDIS_URL,
password: c.REDIS_PASSWORD || undefined,
};
};
export const jwt = () => {
const c = getEnvConfig();
return {
secret: c.JWT_SECRET,
expiresIn: c.JWT_EXPIRES_IN,
refreshExpiresIn: c.JWT_REFRESH_EXPIRES_IN,
};
};
export const auth = () => {
const c = getEnvConfig();
return {
clerkSecretKey: c.CLERK_SECRET_KEY || undefined,
clerkWebhookSecret: c.CLERK_WEBHOOK_SECRET || undefined,
ssoEnabled: c.AUTH_SSO_ENABLED,
};
};
export const ai = () => {
const c = getEnvConfig();
return {
openaiApiKey: c.OPENAI_API_KEY || undefined,
openaiModel: c.OPENAI_MODEL,
geminiApiKey: c.GEMINI_API_KEY || undefined,
geminiModel: c.GEMINI_MODEL,
defaultDailyLimit: c.AI_DEFAULT_DAILY_LIMIT,
defaultMonthlyTokenBudget: c.AI_DEFAULT_MONTHLY_TOKEN_BUDGET,
maxMessageLength: c.AI_MAX_MESSAGE_LENGTH,
};
};
export const storage = () => {
const c = getEnvConfig();
return {
endpoint: c.S3_ENDPOINT,
port: c.S3_PORT,
useSsl: c.S3_USE_SSL,
accessKey: c.S3_ACCESS_KEY,
secretKey: c.S3_SECRET_KEY,
bucket: c.S3_BUCKET,
region: c.S3_REGION,
};
};
export const email = () => {
const c = getEnvConfig();
return {
resendApiKey: c.RESEND_API_KEY || undefined,
from: c.EMAIL_FROM,
replyTo: c.EMAIL_REPLY_TO,
};
};
export const billing = () => {
const c = getEnvConfig();
return {
stripeSecretKey: c.STRIPE_SECRET_KEY || undefined,
stripeWebhookSecret: c.STRIPE_WEBHOOK_SECRET || undefined,
priceBasic: c.STRIPE_PRICE_BASIC || undefined,
pricePremium: c.STRIPE_PRICE_PREMIUM || undefined,
};
};
export const tenant = () => {
const c = getEnvConfig();
return {
defaultAiMode: c.TENANT_DEFAULT_AI_MODE,
maxTokensMonthly: c.TENANT_MAX_TOKENS_MONTHLY,
};
};
export const i18n = () => {
const c = getEnvConfig();
return {
defaultLocale: c.DEFAULT_LOCALE,
supportedLocales: parseEnvArray(c.SUPPORTED_LOCALES),
};
};
export const app = () => {
const c = getEnvConfig();
return {
nodeEnv: c.NODE_ENV,
port: c.PORT,
appUrl: c.APP_URL,
apiUrl: c.API_URL,
corsOrigins: parseEnvArray(c.CORS_ORIGINS),
};
};
export const security = () => {
const c = getEnvConfig();
return {
rateLimitTtl: c.RATE_LIMIT_TTL,
rateLimitMax: c.RATE_LIMIT_MAX,
graphqlDepthLimit: c.GRAPHQL_DEPTH_LIMIT,
corsEnabled: c.CORS_ENABLED,
};
};
export const logging = () => {
const c = getEnvConfig();
return {
level: c.LOG_LEVEL,
format: c.LOG_FORMAT,
};
};

View File

@@ -0,0 +1,144 @@
import { z } from "zod";
// ─── Zod schema for all environment variables ───────────────────────────────
export const validatedEnvSchema = z
.object({
// ── APP ──────────────────────────────────────────────────────────────────
NODE_ENV: z
.enum(["development", "production", "test", "staging"])
.default("development"),
PORT: z.coerce.number().int().positive().max(65535).default(3001),
APP_URL: z.string().url().default("http://localhost:5173"),
API_URL: z.string().url().default("http://localhost:3001"),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:3000"),
// ── DATABASE ─────────────────────────────────────────────────────────────
DATABASE_URL: z
.string()
.url()
.refine((v) => v.startsWith("postgresql://") || v.startsWith("postgres://"), {
message: "DATABASE_URL must be a valid PostgreSQL connection string starting with postgresql://",
}),
DATABASE_POOL_MIN: z.coerce.number().int().min(1).default(2),
DATABASE_POOL_MAX: z.coerce.number().int().min(1).default(10),
// ── REDIS ────────────────────────────────────────────────────────────────
REDIS_URL: z
.string()
.url()
.refine((v) => v.startsWith("redis://") || v.startsWith("rediss://"), {
message: "REDIS_URL must be a valid Redis connection string starting with redis:// or rediss://",
}),
REDIS_PASSWORD: z.string().optional().default(""),
// ── JWT ──────────────────────────────────────────────────────────────────
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
JWT_EXPIRES_IN: z.string().default("7d"),
JWT_REFRESH_EXPIRES_IN: z.string().default("30d"),
// ── AUTH ─────────────────────────────────────────────────────────────────
CLERK_SECRET_KEY: z.string().optional().default(""),
CLERK_WEBHOOK_SECRET: z.string().optional().default(""),
AUTH_SSO_ENABLED: z
.string()
.transform((v) => v === "true" || v === "1" || v === "yes")
.default("false"),
// ── AI ───────────────────────────────────────────────────────────────────
OPENAI_API_KEY: z.string().optional().default(""),
OPENAI_MODEL: z.string().default("gpt-4o-mini"),
GEMINI_API_KEY: z.string().optional().default(""),
GEMINI_MODEL: z.string().default("gemini-1.5-flash"),
AI_DEFAULT_DAILY_LIMIT: z.coerce.number().int().min(0).default(20),
AI_DEFAULT_MONTHLY_TOKEN_BUDGET: z.coerce.number().int().min(0).default(500000),
AI_MAX_MESSAGE_LENGTH: z.coerce.number().int().min(1).default(250),
// ── STORAGE ──────────────────────────────────────────────────────────────
S3_ENDPOINT: z.string().min(1, "S3_ENDPOINT is required"),
S3_PORT: z.coerce.number().int().positive().max(65535).default(9000),
S3_USE_SSL: z
.string()
.transform((v) => v === "true" || v === "1" || v === "yes")
.default("false"),
S3_ACCESS_KEY: z.string().min(1, "S3_ACCESS_KEY is required"),
S3_SECRET_KEY: z.string().min(1, "S3_SECRET_KEY is required"),
S3_BUCKET: z.string().min(1, "S3_BUCKET is required"),
S3_REGION: z.string().default("us-east-1"),
// ── EMAIL ────────────────────────────────────────────────────────────────
RESEND_API_KEY: z.string().optional().default(""),
EMAIL_FROM: z.string().email("EMAIL_FROM must be a valid email").default("noreply@investplay.dev"),
EMAIL_REPLY_TO: z
.string()
.email("EMAIL_REPLY_TO must be a valid email")
.default("support@investplay.dev"),
// ── BILLING ──────────────────────────────────────────────────────────────
STRIPE_SECRET_KEY: z.string().optional().default(""),
STRIPE_WEBHOOK_SECRET: z.string().optional().default(""),
STRIPE_PRICE_BASIC: z.string().optional().default(""),
STRIPE_PRICE_PREMIUM: z.string().optional().default(""),
// ── TENANT ───────────────────────────────────────────────────────────────
TENANT_DEFAULT_AI_MODE: z
.enum(["MANAGED", "SELF_SERVICE", "DISABLED"])
.default("MANAGED"),
TENANT_MAX_TOKENS_MONTHLY: z.coerce.number().int().min(0).default(1000000),
// ── i18n ─────────────────────────────────────────────────────────────────
DEFAULT_LOCALE: z.string().min(1).default("en"),
SUPPORTED_LOCALES: z.string().default("en,el"),
// ── CMS ──────────────────────────────────────────────────────────────────
CMS_API_URL: z.string().url().default("http://localhost:3000/api"),
CMS_API_KEY: z.string().optional().default(""),
// ── SECURITY ─────────────────────────────────────────────────────────────
RATE_LIMIT_TTL: z.coerce.number().int().positive().default(60),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(100),
GRAPHQL_DEPTH_LIMIT: z.coerce.number().int().positive().default(7),
CORS_ENABLED: z
.string()
.transform((v) => v === "true" || v === "1" || v === "yes")
.default("true"),
// ── LOGGING ──────────────────────────────────────────────────────────────
LOG_LEVEL: z
.enum(["debug", "info", "warn", "error", "fatal"])
.default("debug"),
LOG_FORMAT: z.enum(["pretty", "json"]).default("pretty"),
})
.strict();
export type EnvConfig = z.infer<typeof validatedEnvSchema>;
/**
* Validates `process.env` against the schema and returns the typed config.
* Throws a descriptive ZodError if validation fails, listing every missing
* or malformed variable.
*/
export function validateEnv(env: Record<string, string | undefined> = process.env): EnvConfig {
const result = validatedEnvSchema.safeParse(env);
if (!result.success) {
const formatted = result.error.flatten();
const lines: string[] = ["Environment variable validation failed:"];
for (const [key, messages] of Object.entries(formatted.fieldErrors)) {
for (const msg of messages) {
lines.push(`${key}: ${msg}`);
}
}
if (formatted.formErrors.length > 0) {
for (const err of formatted.formErrors) {
lines.push(` • (global): ${err}`);
}
}
throw new Error(lines.join("\n"));
}
return result.data;
}

View File

@@ -0,0 +1,20 @@
export {
getEnvConfig,
env,
database,
redis,
jwt,
auth,
ai,
storage,
email,
billing,
tenant,
i18n,
app,
security,
logging,
} from "./env.config.js";
export { validateEnv, validatedEnvSchema } from "./env.validation.js";
export type { EnvConfig } from "./env.validation.js";

77
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,77 @@
import { NestFactory } from "@nestjs/core";
import { ValidationPipe, Logger } from "@nestjs/common";
import helmet from "helmet";
import compression from "compression";
import cookieParser from "cookie-parser";
import { AppModule } from "./app.module.js";
import { app as appConfig, security } from "./config/index.js";
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor.js";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter.js";
import { PrismaService } from "./common/prisma/prisma.service.js";
import { RedisService } from "./common/redis/redis.service.js";
async function bootstrap() {
const logger = new Logger("Bootstrap");
const { port, corsOrigins, nodeEnv, apiUrl } = appConfig();
const { rateLimitTtl, rateLimitMax, corsEnabled } = security();
const app = await NestFactory.create(AppModule, {
logger:
nodeEnv === "production"
? ["error", "warn", "log"]
: ["error", "warn", "log", "debug", "verbose"],
});
app.setGlobalPrefix("api");
if (corsEnabled) {
app.enableCors({
origin: corsOrigins,
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "x-tenant-id"],
exposedHeaders: ["x-request-id"],
});
}
app.use(helmet());
app.use(compression());
app.use(cookieParser());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
app.useGlobalInterceptors(new LoggingInterceptor());
app.useGlobalFilters(new HttpExceptionFilter());
const prismaService = app.get(PrismaService);
const redisService = app.get(RedisService);
app.enableShutdownHooks();
await app.listen(port);
logger.log(`Server running on ${apiUrl}`);
logger.log(`Environment: ${nodeEnv}`);
logger.log(`GraphQL endpoint: ${apiUrl}/graphql`);
process.on("beforeExit", async () => {
logger.log("Shutting down gracefully...");
await prismaService.$disconnect();
await redisService.quit();
await app.close();
});
}
bootstrap().catch((err) => {
console.error("Failed to start application", err);
process.exit(1);
});

View File

@@ -0,0 +1,90 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
Logger,
} from "@nestjs/websockets";
import { Server, Socket } from "socket.io";
@WebSocketGateway({
namespace: "/ai-coach",
cors: {
origin: "*",
credentials: true,
},
})
export class AICoachGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(AICoachGateway.name);
private connectedClients = new Map<string, Socket>();
@WebSocketServer()
server!: Server;
handleConnection(client: Socket) {
const userId = client.handshake.auth.userId as string | undefined;
if (userId) {
this.connectedClients.set(userId, client);
client.join(`user:${userId}`);
this.logger.log(`AI Coach client connected: ${client.id} (user: ${userId})`);
} else {
this.logger.warn(`AI Coach client connected without userId: ${client.id}`);
}
}
handleDisconnect(client: Socket) {
for (const [userId, socket] of this.connectedClients) {
if (socket.id === client.id) {
this.connectedClients.delete(userId);
break;
}
}
this.logger.log(`AI Coach client disconnected: ${client.id}`);
}
@SubscribeMessage("ai:message")
async handleMessage(client: Socket, payload: { content: string }) {
const userId = client.handshake.auth.userId as string;
client.emit("ai:typing", { status: "typing" });
const words = payload.content.split(/\s+/);
const responseWords = [
"I",
"understand",
"your",
"question",
"about",
"personal",
"finance.",
"Let",
"me",
"share",
"some",
"insights...",
];
for (let i = 0; i < responseWords.length; i++) {
await this.delay(200 + Math.random() * 300);
client.emit("ai:stream", {
token: responseWords[i],
index: i,
total: responseWords.length,
});
}
client.emit("ai:complete", {
message: responseWords.join(" "),
tokensUsed: payload.content.length / 4,
});
}
async sendMessageToUser(userId: string, event: string, data: unknown) {
this.server.to(`user:${userId}`).emit(event, data);
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { AICoachService } from "./ai-coach.service.js";
import { AICoachResolver } from "./ai-coach.resolver.js";
import { AICoachGateway } from "./ai-coach.gateway.js";
@Module({
providers: [AICoachService, AICoachResolver, AICoachGateway],
exports: [AICoachService],
})
export class AICoachModule {}

View File

@@ -0,0 +1,36 @@
import { UseGuards } from "@nestjs/common";
import { Resolver, Mutation, Query, Args } from "@nestjs/graphql";
import { AICoachService } from "./ai-coach.service.js";
import { AIMessageInput } from "./dto/ai-message.input.js";
import { GqlJwtGuard } from "../../common/guards/gql-jwt.guard.js";
import { CurrentUser } from "../../common/decorators/current-user.decorator.js";
import { CurrentTenant } from "../../common/decorators/current-tenant.decorator.js";
@Resolver()
@UseGuards(GqlJwtGuard)
export class AICoachResolver {
constructor(private readonly aiCoachService: AICoachService) {}
@Mutation(() => Object)
async sendAIMessage(
@CurrentUser() user: { id: string },
@Args("input") input: AIMessageInput,
) {
return this.aiCoachService.sendMessage(user.id, input);
}
@Query(() => Object)
async aiUsage(@CurrentUser() user: { id: string }) {
return this.aiCoachService.getDailyUsage(user.id);
}
@Query(() => Object)
async aiQuota(@CurrentUser() user: { id: string }) {
return this.aiCoachService.checkQuota(user.id);
}
@Query(() => Object)
async aiTokenBudget(@CurrentTenant() tenantId: string) {
return this.aiCoachService.getTokenBudget(tenantId);
}
}

View File

@@ -0,0 +1,92 @@
import { Injectable, Logger, BadRequestException } from "@nestjs/common";
import { PrismaService } from "../../common/prisma/prisma.service.js";
import { RedisService } from "../../common/redis/redis.service.js";
import { ai } from "../../config/index.js";
import type { AIMessageInput } from "./dto/ai-message.input.js";
import type { AICoachMessage, AITokenBudget } from "@investplay/types";
@Injectable()
export class AICoachService {
private readonly logger = new Logger(AICoachService.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
async sendMessage(userId: string, input: AIMessageInput): Promise<AICoachMessage> {
const { maxMessageLength } = ai();
if (input.content.length > maxMessageLength) {
throw new BadRequestException(
`Message exceeds maximum length of ${maxMessageLength} characters`,
);
}
const quotaOk = await this.checkQuota(userId);
if (!quotaOk) {
throw new BadRequestException("Daily AI message limit reached");
}
await this.redis.incr(`ai:daily:${userId}:${new Date().toISOString().slice(0, 10)}`);
const message: AICoachMessage = {
id: `ai-msg-${Date.now()}`,
sessionId: `session-${userId}`,
role: "assistant",
content: this.getStubResponse(input.content),
metadata: {
tokensUsed: Math.ceil(input.content.length / 4),
processingTimeMs: 850,
model: ai().openaiModel ?? ai().geminiModel,
},
createdAt: new Date().toISOString(),
};
this.logger.log(`AI message sent to user ${userId}: ${message.id}`);
return message;
}
async getDailyUsage(userId: string): Promise<{ used: number; limit: number }> {
const today = new Date().toISOString().slice(0, 10);
const usedStr = await this.redis.get<string>(`ai:daily:${userId}:${today}`);
const used = usedStr ? Number.parseInt(usedStr, 10) : 0;
return {
used,
limit: ai().defaultDailyLimit,
};
}
async checkQuota(userId: string): Promise<boolean> {
const usage = await this.getDailyUsage(userId);
return usage.used < usage.limit;
}
async getTokenBudget(tenantId: string): Promise<AITokenBudget> {
const today = new Date();
const resetDate = new Date(today.getFullYear(), today.getMonth() + 1, 1).toISOString();
return {
tenantId,
monthlyLimit: ai().defaultMonthlyTokenBudget,
usedThisMonth: Math.floor(ai().defaultMonthlyTokenBudget * 0.3),
remaining: Math.floor(ai().defaultMonthlyTokenBudget * 0.7),
resetDate,
};
}
private getStubResponse(userMessage: string): string {
const lower = userMessage.toLowerCase();
if (lower.includes("budget") || lower.includes("save")) {
return "Great question about budgeting! A good rule of thumb is the 50/30/20 rule: 50% of your income for needs, 30% for wants, and 20% for savings. Would you like me to help you create a budget?";
}
if (lower.includes("invest") || lower.includes("stock")) {
return "Investing is a powerful way to build wealth over time. Before you start, make sure you have an emergency fund and understand your risk tolerance. Diversification is key — never put all your money in one asset.";
}
if (lower.includes("debt") || lower.includes("loan")) {
return "Managing debt is crucial for financial health. Consider the avalanche method (paying highest interest first) or the snowball method (paying smallest balances first). Both work — the best one is what keeps you motivated!";
}
return "That's an interesting topic! As your AI financial coach, I'm here to help you learn about personal finance, practice with simulations, and build smart money habits. What specific area would you like to explore?";
}
}

View File

@@ -0,0 +1,21 @@
import { Field, InputType } from "@nestjs/graphql";
import { IsString, MinLength, MaxLength, IsOptional } from "class-validator";
@InputType()
export class AIMessageInput {
@Field()
@IsString()
@MinLength(1)
@MaxLength(2000)
content: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
lessonId?: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
simulationType?: string;
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { AnalyticsService } from "./analytics.service.js";
import { AnalyticsResolver } from "./analytics.resolver.js";
@Module({
providers: [AnalyticsService, AnalyticsResolver],
exports: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@@ -0,0 +1,56 @@
import { UseGuards } from "@nestjs/common";
import { Resolver, Query, Mutation, Args } from "@nestjs/graphql";
import { AnalyticsService } from "./analytics.service.js";
import { GqlJwtGuard } from "../../common/guards/gql-jwt.guard.js";
import { GqlRolesGuard } from "../../common/guards/gql-roles.guard.js";
import { Roles } from "../../common/decorators/roles.decorator.js";
import { CurrentUser } from "../../common/decorators/current-user.decorator.js";
import { CurrentTenant } from "../../common/decorators/current-tenant.decorator.js";
import type { AnalyticsEventType } from "@investplay/types";
@Resolver()
@UseGuards(GqlJwtGuard, GqlRolesGuard)
export class AnalyticsResolver {
constructor(private readonly analyticsService: AnalyticsService) {}
@Mutation(() => Object)
async trackEvent(
@CurrentUser() user: { id: string },
@CurrentTenant() tenantId: string,
@Args("eventType") eventType: string,
@Args("payload") payload: Record<string, unknown>,
) {
return this.analyticsService.trackEvent(
eventType as AnalyticsEventType,
user.id,
tenantId,
payload,
);
}
@Query(() => Object)
@Roles("TENANT_ADMIN", "PLATFORM_ADMIN", "RESEARCHER")
async cohortAnalytics(@CurrentTenant() tenantId: string) {
return this.analyticsService.getCohortAnalytics(tenantId);
}
@Query(() => Object)
@Roles("TENANT_ADMIN", "PLATFORM_ADMIN", "RESEARCHER")
async dashboardMetrics(@CurrentTenant() tenantId: string) {
return this.analyticsService.getDashboardMetrics(tenantId);
}
@Mutation(() => Object)
@Roles("TENANT_ADMIN", "PLATFORM_ADMIN", "RESEARCHER")
async generateReport(
@CurrentTenant() tenantId: string,
@Args("reportType") reportType: string,
@Args("startDate") startDate: string,
@Args("endDate") endDate: string,
) {
return this.analyticsService.generateReport(tenantId, reportType, {
start: startDate,
end: endDate,
});
}
}

View File

@@ -0,0 +1,114 @@
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../common/prisma/prisma.service.js";
import { RedisService } from "../../common/redis/redis.service.js";
import { QueueService, JobType } from "../../common/queue/queue.service.js";
import type { AnalyticsEvent, AnalyticsEventType } from "@investplay/types";
@Injectable()
export class AnalyticsService {
private readonly logger = new Logger(AnalyticsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly queueService: QueueService,
) {}
async trackEvent(
eventType: AnalyticsEventType,
userId: string,
tenantId: string,
payload: Record<string, unknown>,
): Promise<AnalyticsEvent> {
const event: AnalyticsEvent = {
id: `evt-${Date.now()}-${userId.slice(0, 6)}`,
eventType,
userId,
tenantId,
sessionId: (payload.sessionId as string) ?? null,
payload,
timestamp: new Date().toISOString(),
};
await this.redis.lpush(`analytics:${tenantId}`, JSON.stringify(event));
await this.redis.ltrim(`analytics:${tenantId}`, 0, 9999);
await this.queueService.addJob(JobType.PROCESS_ANALYTICS, {
eventId: event.id,
eventType,
tenantId,
});
this.logger.debug(`Event tracked: ${eventType} for user ${userId}`);
return event;
}
async getCohortAnalytics(tenantId: string): Promise<{
totalUsers: number;
activeUsers: number;
averageXP: number;
completionRate: number;
topTrack: string;
simulationEngagement: number;
}> {
return {
totalUsers: 245,
activeUsers: 89,
averageXP: 1850,
completionRate: 0.67,
topTrack: "SURVIVAL_BASICS",
simulationEngagement: 0.43,
};
}
async generateReport(tenantId: string, reportType: string, dateRange: { start: string; end: string }): Promise<{
id: string;
type: string;
status: string;
data: Record<string, unknown>;
generatedAt: string;
}> {
this.logger.log(`Generating ${reportType} report for tenant ${tenantId}`);
const jobId = await this.queueService.addJob(JobType.GENERATE_REPORT, {
tenantId,
reportType,
dateRange,
});
return {
id: jobId,
type: reportType,
status: "processing",
data: {
tenantId,
reportType,
dateRange,
estimatedCompletion: new Date(Date.now() + 30000).toISOString(),
},
generatedAt: new Date().toISOString(),
};
}
async getDashboardMetrics(tenantId: string): Promise<{
activeSessions: number;
lessonsCompleted: number;
quizzesPassed: number;
simulationsRun: number;
xpAwarded: number;
aiMessagesSent: number;
periodStart: string;
periodEnd: string;
}> {
return {
activeSessions: 23,
lessonsCompleted: 156,
quizzesPassed: 112,
simulationsRun: 45,
xpAwarded: 28500,
aiMessagesSent: 340,
periodStart: new Date(Date.now() - 86400000 * 7).toISOString(),
periodEnd: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,26 @@
import { Controller, Post, Get, Param, Body, Query, Logger } from "@nestjs/common";
import { AuthService } from "./auth.service.js";
import { Public } from "../../common/decorators/public.decorator.js";
@Controller("auth")
export class AuthController {
private readonly logger = new Logger(AuthController.name);
constructor(private readonly authService: AuthService) {}
@Public()
@Post("sso/callback")
async ssoCallback(
@Body("provider") provider: string,
@Body("profile") profile: Record<string, unknown>,
) {
this.logger.log(`SSO callback from ${provider}`);
return this.authService.handleSSOCallback(provider, profile);
}
@Public()
@Get("verify-email/:token")
async verifyEmail(@Param("token") token: string) {
return this.authService.verifyEmail(token);
}
}

View File

@@ -0,0 +1,27 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { jwt } from "../../config/index.js";
import { AuthService } from "./auth.service.js";
import { AuthResolver } from "./auth.resolver.js";
import { AuthController } from "./auth.controller.js";
import { JwtStrategy } from "./jwt.strategy.js";
import { JwtRefreshStrategy } from "./jwt-refresh.strategy.js";
@Module({
imports: [
PassportModule.register({ defaultStrategy: "jwt" }),
JwtModule.registerAsync({
useFactory: () => ({
secret: jwt().secret,
signOptions: {
expiresIn: jwt().expiresIn,
},
}),
}),
],
providers: [AuthService, AuthResolver, JwtStrategy, JwtRefreshStrategy],
controllers: [AuthController],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,53 @@
import { UseGuards } from "@nestjs/common";
import { Resolver, Mutation, Query, Args, Context } from "@nestjs/graphql";
import { AuthService } from "./auth.service.js";
import { LoginInput } from "./dto/login.input.js";
import { RegisterInput } from "./dto/register.input.js";
import { TokenResponse } from "./dto/token-response.dto.js";
import { UserEntity } from "./entities/user.entity.js";
import { GqlJwtGuard } from "../../common/guards/gql-jwt.guard.js";
import { CurrentUser } from "../../common/decorators/current-user.decorator.js";
import { Public } from "../../common/decorators/public.decorator.js";
@Resolver()
export class AuthResolver {
constructor(private readonly authService: AuthService) {}
@Public()
@Mutation(() => TokenResponse)
async login(@Args("input") input: LoginInput) {
return this.authService.login(input);
}
@Public()
@Mutation(() => TokenResponse)
async register(@Args("input") input: RegisterInput) {
const result = await this.authService.register(input);
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
expiresIn: result.expiresIn,
};
}
@Public()
@Mutation(() => TokenResponse)
async refreshToken(@Args("token") token: string) {
return this.authService.refreshToken(token);
}
@UseGuards(GqlJwtGuard)
@Mutation(() => Boolean)
async logout(@CurrentUser() user: { id: string }, @Context() context: { req: { headers: { authorization?: string } } }) {
const authHeader = context.req.headers.authorization ?? "";
const accessToken = authHeader.replace("Bearer ", "");
await this.authService.logout(user.id, accessToken);
return true;
}
@UseGuards(GqlJwtGuard)
@Query(() => UserEntity)
async me(@CurrentUser() user: { id: string }) {
return user;
}
}

View File

@@ -0,0 +1,202 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
Logger,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { PrismaService } from "../../common/prisma/prisma.service.js";
import { RedisService } from "../../common/redis/redis.service.js";
import { jwt as jwtConfig } from "../../config/index.js";
import type { LoginInput } from "./dto/login.input.js";
import type { RegisterInput } from "./dto/register.input.js";
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly redis: RedisService,
) {}
async validateUser(email: string, password: string) {
const user = await this.prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (!user) {
throw new UnauthorizedException("Invalid email or password");
}
const bcrypt = await import("bcryptjs");
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
throw new UnauthorizedException("Invalid email or password");
}
const { passwordHash: _, ...safeUser } = user;
return safeUser;
}
async login(input: LoginInput) {
const user = await this.validateUser(input.email, input.password);
return this.generateTokens(user);
}
async register(input: RegisterInput) {
if (!input.acceptTerms) {
throw new BadRequestException("You must accept the terms of service");
}
const existing = await this.prisma.user.findUnique({
where: { email: input.email.toLowerCase() },
});
if (existing) {
throw new ConflictException("Email already registered");
}
const bcrypt = await import("bcryptjs");
const passwordHash = await bcrypt.hash(input.password, 12);
const user = await this.prisma.user.create({
data: {
email: input.email.toLowerCase(),
passwordHash,
firstName: input.firstName,
lastName: input.lastName,
locale: input.locale ?? "en",
role: "STUDENT",
tenantId: "default",
},
});
await this.redis.set(`user:${user.id}`, JSON.stringify(user), 3600);
const { passwordHash: _, ...safeUser } = user;
const tokens = this.generateTokens(safeUser);
return {
user: safeUser,
...tokens,
};
}
async refreshToken(refreshToken: string) {
try {
const payload = this.jwtService.verify(refreshToken, {
secret: jwtConfig().secret,
});
if (payload.type !== "refresh") {
throw new UnauthorizedException("Invalid refresh token");
}
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: {
id: true,
email: true,
role: true,
tenantId: true,
firstName: true,
lastName: true,
locale: true,
xp: true,
level: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) {
throw new UnauthorizedException("User not found");
}
return this.generateTokens(user);
} catch {
throw new UnauthorizedException("Invalid or expired refresh token");
}
}
async handleSSOCallback(provider: string, profile: Record<string, unknown>) {
this.logger.log(`SSO callback received for provider: ${provider}`);
const email = profile.email as string;
if (!email) {
throw new BadRequestException("SSO profile must include email");
}
let user = await this.prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (!user) {
user = await this.prisma.user.create({
data: {
email: email.toLowerCase(),
firstName: (profile.firstName as string) ?? (profile.name as string) ?? "",
lastName: (profile.lastName as string) ?? "",
role: "STUDENT",
tenantId: "default",
locale: "en",
avatarUrl: (profile.avatarUrl as string) ?? (profile.picture as string) ?? null,
},
});
}
const { passwordHash: _passwordHash, ...safeUser } = user;
return this.generateTokens(safeUser as typeof safeUser & { passwordHash?: string });
}
async verifyEmail(token: string) {
const payload = this.jwtService.verify(token, { secret: jwtConfig().secret });
const userId = payload.sub;
await this.prisma.user.update({
where: { id: userId },
data: { emailVerified: true },
});
return { verified: true };
}
async logout(userId: string, accessToken: string) {
const decoded = this.jwtService.decode(accessToken) as { exp: number };
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await this.redis.set(`blacklist:${accessToken}`, "true", ttl);
}
await this.redis.del(`user:${userId}`);
}
private generateTokens(user: { id: string; email: string; role: string; tenantId: string }) {
const payload = {
sub: user.id,
email: user.email,
role: user.role,
tenantId: user.tenantId,
};
const accessToken = this.jwtService.sign(payload, {
expiresIn: jwtConfig().expiresIn,
});
const refreshToken = this.jwtService.sign(
{ ...payload, type: "refresh" },
{ expiresIn: jwtConfig().refreshExpiresIn },
);
return {
accessToken,
refreshToken,
expiresIn: 7 * 24 * 3600,
};
}
}

View File

@@ -0,0 +1,14 @@
import { Field, InputType } from "@nestjs/graphql";
import { IsEmail, IsString, MinLength } from "class-validator";
@InputType()
export class LoginInput {
@Field()
@IsEmail()
email: string;
@Field()
@IsString()
@MinLength(1)
password: string;
}

View File

@@ -0,0 +1,34 @@
import { Field, InputType, Boolean as GraphQLBoolean } from "@nestjs/graphql";
import { IsEmail, IsString, MinLength, IsBoolean, IsOptional, IsIn } from "class-validator";
@InputType()
export class RegisterInput {
@Field()
@IsEmail()
email: string;
@Field()
@IsString()
@MinLength(8)
password: string;
@Field()
@IsString()
@MinLength(1)
firstName: string;
@Field()
@IsString()
@MinLength(1)
lastName: string;
@Field({ nullable: true, defaultValue: "en" })
@IsOptional()
@IsString()
@IsIn(["en", "el"])
locale?: string;
@Field(() => GraphQLBoolean)
@IsBoolean()
acceptTerms: boolean;
}

View File

@@ -0,0 +1,13 @@
import { Field, ObjectType, Int } from "@nestjs/graphql";
@ObjectType()
export class TokenResponse {
@Field()
accessToken: string;
@Field()
refreshToken: string;
@Field(() => Int)
expiresIn: number;
}

View File

@@ -0,0 +1,55 @@
import { Field, ObjectType, Int } from "@nestjs/graphql";
@ObjectType()
export class Streaks {
@Field(() => Int)
current: number;
@Field(() => Int)
longest: number;
@Field({ nullable: true })
lastActivityDate: string | null;
}
@ObjectType()
export class UserEntity {
@Field()
id: string;
@Field()
email: string;
@Field()
firstName: string;
@Field()
lastName: string;
@Field()
role: string;
@Field()
locale: string;
@Field()
tenantId: string;
@Field(() => Int)
xp: number;
@Field(() => Int)
level: number;
@Field(() => Streaks)
streaks: Streaks;
@Field({ nullable: true })
avatarUrl: string | null;
@Field()
createdAt: string;
@Field()
updatedAt: string;
}

View File

@@ -0,0 +1,45 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { Request } from "express";
import { jwt } from "../../config/index.js";
interface JwtPayload {
sub: string;
email: string;
role: string;
tenantId: string;
type: "refresh";
}
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, "jwt-refresh") {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
passReqToCallback: true,
ignoreExpiration: false,
secretOrKey: jwt().secret,
});
}
async validate(req: Request, payload: JwtPayload) {
if (payload.type !== "refresh") {
throw new UnauthorizedException("Invalid token type");
}
const refreshToken = req.headers.authorization?.replace("Bearer ", "");
if (!refreshToken) {
throw new UnauthorizedException("Refresh token required");
}
return {
id: payload.sub,
email: payload.email,
role: payload.role,
tenantId: payload.tenantId,
refreshToken,
};
}
}

View File

@@ -0,0 +1,35 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { jwt } from "../../config/index.js";
interface JwtPayload {
sub: string;
email: string;
role: string;
tenantId: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, "jwt") {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwt().secret,
});
}
async validate(payload: JwtPayload) {
if (!payload.sub) {
throw new UnauthorizedException("Invalid token payload");
}
return {
id: payload.sub,
email: payload.email,
role: payload.role,
tenantId: payload.tenantId,
};
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { ClassroomService } from "./classroom.service.js";
import { ClassroomResolver } from "./classroom.resolver.js";
@Module({
providers: [ClassroomService, ClassroomResolver],
exports: [ClassroomService],
})
export class ClassroomModule {}

View File

@@ -0,0 +1,67 @@
import { UseGuards } from "@nestjs/common";
import { Resolver, Query, Mutation, Args } from "@nestjs/graphql";
import { ClassroomService } from "./classroom.service.js";
import { GqlJwtGuard } from "../../common/guards/gql-jwt.guard.js";
import { GqlRolesGuard } from "../../common/guards/gql-roles.guard.js";
import { Roles } from "../../common/decorators/roles.decorator.js";
import { CurrentUser } from "../../common/decorators/current-user.decorator.js";
import { CurrentTenant } from "../../common/decorators/current-tenant.decorator.js";
@Resolver()
@UseGuards(GqlJwtGuard, GqlRolesGuard)
export class ClassroomResolver {
constructor(private readonly classroomService: ClassroomService) {}
@Query(() => [Object])
@Roles("TEACHER", "TENANT_ADMIN")
async classrooms(@CurrentUser() user: { id: string }) {
return this.classroomService.getClassrooms(user.id);
}
@Mutation(() => Object)
@Roles("TEACHER", "TENANT_ADMIN")
async createClassroom(
@CurrentUser() user: { id: string },
@CurrentTenant() tenantId: string,
@Args("name") name: string,
@Args("description", { nullable: true }) description?: string,
) {
return this.classroomService.createClassroom(user.id, tenantId, name, description);
}
@Mutation(() => Object)
@Roles("TEACHER", "TENANT_ADMIN")
async inviteStudent(
@Args("classroomId") classroomId: string,
@Args("email") email: string,
@Args("name") name: string,
) {
return this.classroomService.inviteStudent(classroomId, email, name);
}
@Mutation(() => Object)
@Roles("TEACHER", "TENANT_ADMIN")
async assignLesson(
@Args("classroomId") classroomId: string,
@Args("lessonId") lessonId: string,
@Args("title") title: string,
@Args("dueDate", { nullable: true }) dueDate?: string,
@Args("requiredScore", { nullable: true }) requiredScore?: number,
@Args("maxAttempts", { nullable: true }) maxAttempts?: number,
) {
return this.classroomService.assignLesson(
classroomId,
lessonId,
title,
dueDate,
requiredScore,
maxAttempts,
);
}
@Query(() => Object)
@Roles("TEACHER", "TENANT_ADMIN")
async classroomProgress(@Args("classroomId") classroomId: string) {
return this.classroomService.getProgress(classroomId);
}
}

View File

@@ -0,0 +1,159 @@
import { Injectable, Logger, NotFoundException, ConflictException } from "@nestjs/common";
import { PrismaService } from "../../common/prisma/prisma.service.js";
import { RedisService } from "../../common/redis/redis.service.js";
import type { Classroom, ClassroomStudent, Assignment, TeacherReport, StudentReport, CompositeScore } from "@investplay/types";
@Injectable()
export class ClassroomService {
private readonly logger = new Logger(ClassroomService.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
async createClassroom(
teacherId: string,
tenantId: string,
name: string,
description?: string,
): Promise<Classroom> {
const classroom: Classroom = {
id: `class-${Date.now()}`,
name,
description: description ?? null,
tenantId,
teacherId,
inviteCode: this.generateInviteCode(),
students: [],
assignments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
this.logger.log(`Classroom created: ${classroom.id} (${name})`);
return classroom;
}
async inviteStudent(
classroomId: string,
email: string,
name: string,
): Promise<ClassroomStudent> {
this.logger.log(`Inviting student ${email} to classroom ${classroomId}`);
return {
id: `student-${Date.now()}`,
classroomId,
userId: `user-${email.replace(/[^a-zA-Z0-9]/g, "")}`,
joinedAt: new Date().toISOString(),
studentName: name,
studentEmail: email,
};
}
async assignLesson(
classroomId: string,
lessonId: string,
title: string,
dueDate?: string,
requiredScore = 60,
maxAttempts = 3,
): Promise<Assignment> {
return {
id: `assign-${Date.now()}`,
classroomId,
lessonId,
title,
description: null,
dueDate: dueDate ?? null,
requiredScore,
maxAttempts,
submissions: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getProgress(classroomId: string): Promise<TeacherReport> {
return {
classroomId,
classroomName: `Classroom ${classroomId.slice(0, 8)}`,
totalStudents: 15,
averageScore: 72.5,
completionRate: 0.68,
studentReports: [
{
userId: "student-1",
studentName: "Alice Smith",
compositeScore: {
overall: 85,
roi: 78,
riskManagement: 92,
esgAlignment: 80,
quizScore: 88,
lessonCompletion: 90,
},
lessonsCompleted: 12,
averageQuizScore: 82,
simulationsPlayed: 5,
totalXp: 2450,
lastActivity: new Date().toISOString(),
},
{
userId: "student-2",
studentName: "Bob Johnson",
compositeScore: {
overall: 62,
roi: 55,
riskManagement: 70,
esgAlignment: 60,
quizScore: 65,
lessonCompletion: 58,
},
lessonsCompleted: 7,
averageQuizScore: 65,
simulationsPlayed: 3,
totalXp: 1200,
lastActivity: new Date(Date.now() - 86400000 * 2).toISOString(),
},
],
generatedAt: new Date().toISOString(),
};
}
async getClassrooms(teacherId: string): Promise<Classroom[]> {
return [
{
id: "class-1",
name: "Finance 101 - Section A",
description: "Introduction to personal finance for high school students",
tenantId: "tenant-1",
teacherId,
inviteCode: "FIN101A",
students: [
{
id: "cs-1",
classroomId: "class-1",
userId: "student-1",
joinedAt: new Date(Date.now() - 86400000 * 30).toISOString(),
studentName: "Alice Smith",
studentEmail: "alice@example.com",
},
],
assignments: [],
createdAt: new Date(Date.now() - 86400000 * 45).toISOString(),
updatedAt: new Date().toISOString(),
},
];
}
private generateInviteCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { CurriculumService } from "./curriculum.service.js";
import { CurriculumResolver } from "./curriculum.resolver.js";
@Module({
providers: [CurriculumService, CurriculumResolver],
exports: [CurriculumService],
})
export class CurriculumModule {}

View File

@@ -0,0 +1,47 @@
import { UseGuards } from "@nestjs/common";
import { Resolver, Query, Mutation, Args, Int } from "@nestjs/graphql";
import { CurriculumService } from "./curriculum.service.js";
import { LessonEntity } from "./entities/lesson.entity.js";
import { ModuleEntity } from "./entities/module.entity.js";
import { SubmitQuizInput } from "./dto/submit-quiz.input.js";
import { GqlJwtGuard } from "../../common/guards/gql-jwt.guard.js";
import { CurrentUser } from "../../common/decorators/current-user.decorator.js";
@Resolver()
@UseGuards(GqlJwtGuard)
export class CurriculumResolver {
constructor(private readonly curriculumService: CurriculumService) {}
@Query(() => [Object])
async tracks() {
return this.curriculumService.getTracks();
}
@Query(() => [ModuleEntity])
async modules(@Args("track") track: string) {
return this.curriculumService.getModules(track as never);
}
@Query(() => LessonEntity, { nullable: true })
async lesson(@Args("id") id: string) {
return this.curriculumService.getLesson(id);
}
@Mutation(() => Object)
async submitQuiz(
@Args("input") input: SubmitQuizInput,
@CurrentUser() user: { id: string },
) {
const result = await this.curriculumService.submitQuiz(input);
return {
...result,
userId: user.id,
lessonId: input.lessonId,
};
}
@Query(() => [Object])
async userProgress(@CurrentUser() user: { id: string }) {
return this.curriculumService.getUserProgress(user.id);
}
}

View File

@@ -0,0 +1,144 @@
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
import type { CurriculumTrack, Lesson, Module, UserProgress } from "@investplay/types";
import type { SubmitQuizInput } from "./dto/submit-quiz.input.js";
@Injectable()
export class CurriculumService {
private readonly logger = new Logger(CurriculumService.name);
async getTracks(): Promise<{ id: CurriculumTrack; title: string; description: string }[]> {
return [
{
id: "SURVIVAL_BASICS",
title: "Survival Basics",
description: "Master the essentials of personal finance — budgeting, saving, and avoiding common pitfalls.",
},
{
id: "CREDIT_DEBT",
title: "Credit & Debt",
description: "Understand credit scores, loans, interest rates, and how to manage debt responsibly.",
},
{
id: "WEALTH_BUILDER",
title: "Wealth Builder",
description: "Learn investing fundamentals, compound interest, and long-term wealth strategies.",
},
{
id: "ANTI_HYPE",
title: "Anti-Hype",
description: "Develop critical thinking to avoid scams, hype assets, and emotional financial decisions.",
},
{
id: "MACROECONOMICS",
title: "Macroeconomics",
description: "Understand inflation, recessions, fiscal policy, and how the economy affects your finances.",
},
];
}
async getModules(track: CurriculumTrack): Promise<Module[]> {
return [
{
id: `mod-${track.toLowerCase()}-1`,
slug: `${track.toLowerCase()}-intro`,
title: `${track.replace(/_/g, " ")} Introduction`,
description: `An introduction to ${track.replace(/_/g, " ").toLowerCase()}`,
track,
order: 1,
lessons: [
{
id: `lesson-${track.toLowerCase()}-1-1`,
slug: `${track.toLowerCase()}-what-is`,
title: `What is ${track.replace(/_/g, " ")}?`,
description: `Learn the basics of ${track.replace(/_/g, " ").toLowerCase()}`,
track,
moduleId: `mod-${track.toLowerCase()}-1`,
order: 1,
estimatedMinutes: 10,
xpReward: 50,
blocks: [
{
id: `block-${track.toLowerCase()}-1-1-1`,
type: "HERO",
order: 1,
content: { title: `Welcome to ${track.replace(/_/g, " ")}`, videoUrl: null },
config: null,
},
{
id: `block-${track.toLowerCase()}-1-1-2`,
type: "TEXT",
order: 2,
content: { body: `This lesson covers the fundamental concepts of ${track.replace(/_/g, " ").toLowerCase()}.` },
config: null,
},
],
tags: ["beginner", track.toLowerCase()],
prerequisites: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
}
async getLesson(id: string): Promise<Lesson | null> {
const tracks = await this.getTracks();
for (const track of tracks) {
const modules = await this.getModules(track.id as CurriculumTrack);
for (const mod of modules) {
const lesson = mod.lessons.find((l) => l.id === id);
if (lesson) return lesson;
}
}
throw new NotFoundException(`Lesson ${id} not found`);
}
async submitQuiz(input: SubmitQuizInput): Promise<{ score: number; passed: boolean; xpEarned: number; correctAnswers: number; totalQuestions: number }> {
const totalQuestions = input.answers.length;
const correctAnswers = Math.floor(totalQuestions * 0.7);
const score = Math.round((correctAnswers / totalQuestions) * 100);
const passed = score >= 60;
const xpEarned = passed ? Math.round(100 * (score / 100)) : 0;
this.logger.log(`Quiz submitted for lesson ${input.lessonId}: score=${score}, passed=${passed}, xp=${xpEarned}`);
return { score, passed, xpEarned, correctAnswers, totalQuestions };
}
async trackProgress(
userId: string,
lessonId: string,
completed: boolean,
score: number | null,
timeSpentSeconds: number,
): Promise<UserProgress> {
return {
userId,
lessonId,
completed,
score,
timeSpentSeconds,
attempts: 1,
startedAt: new Date(Date.now() - timeSpentSeconds * 1000).toISOString(),
completedAt: completed ? new Date().toISOString() : null,
};
}
async getUserProgress(userId: string): Promise<UserProgress[]> {
return [
{
userId,
lessonId: "lesson-survival_basics-1-1",
completed: true,
score: 85,
timeSpentSeconds: 420,
attempts: 1,
startedAt: new Date(Date.now() - 86400000).toISOString(),
completedAt: new Date(Date.now() - 82800000).toISOString(),
},
];
}
}

View File

@@ -0,0 +1,30 @@
import { Field, InputType, Int } from "@nestjs/graphql";
import { IsString, IsArray, IsNumber, Min, Max, ArrayMinSize } from "class-validator";
@InputType()
class AnswerInput {
@Field()
@IsString()
blockId: string;
@Field()
@IsString()
answer: string;
}
@InputType()
export class SubmitQuizInput {
@Field()
@IsString()
lessonId: string;
@Field(() => [AnswerInput])
@IsArray()
@ArrayMinSize(1)
answers: AnswerInput[];
@Field(() => Int)
@IsNumber()
@Min(0)
timeSpentSeconds: number;
}

View File

@@ -0,0 +1,64 @@
import { Field, ObjectType, Int } from "@nestjs/graphql";
@ObjectType()
export class LessonBlock {
@Field()
id: string;
@Field()
type: string;
@Field(() => Int)
order: number;
@Field(() => Object)
content: Record<string, unknown>;
@Field(() => Object, { nullable: true })
config: Record<string, unknown> | null;
}
@ObjectType()
export class LessonEntity {
@Field()
id: string;
@Field()
slug: string;
@Field()
title: string;
@Field()
description: string;
@Field()
track: string;
@Field()
moduleId: string;
@Field(() => Int)
order: number;
@Field(() => Int)
estimatedMinutes: number;
@Field(() => Int)
xpReward: number;
@Field(() => [LessonBlock])
blocks: LessonBlock[];
@Field(() => [String])
tags: string[];
@Field(() => [String])
prerequisites: string[];
@Field()
createdAt: string;
@Field()
updatedAt: string;
}

View File

@@ -0,0 +1,32 @@
import { Field, ObjectType, Int } from "@nestjs/graphql";
import { LessonEntity } from "./lesson.entity.js";
@ObjectType()
export class ModuleEntity {
@Field()
id: string;
@Field()
slug: string;
@Field()
title: string;
@Field()
description: string;
@Field()
track: string;
@Field(() => Int)
order: number;
@Field(() => [LessonEntity])
lessons: LessonEntity[];
@Field()
createdAt: string;
@Field()
updatedAt: string;
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { GamificationService } from "./gamification.service.js";
import { GamificationResolver } from "./gamification.resolver.js";
@Module({
providers: [GamificationService, GamificationResolver],
exports: [GamificationService],
})
export class GamificationModule {}

View File

@@ -0,0 +1,40 @@
import { UseGuards } from "@nestjs/common";
import { Resolver, Query, Args, Int } from "@nestjs/graphql";
import { GamificationService } from "./gamification.service.js";
import { GqlJwtGuard } from "../../common/guards/gql-jwt.guard.js";
import { CurrentUser } from "../../common/decorators/current-user.decorator.js";
import { CurrentTenant } from "../../common/decorators/current-tenant.decorator.js";
@Resolver()
@UseGuards(GqlJwtGuard)
export class GamificationResolver {
constructor(private readonly gamificationService: GamificationService) {}
@Query(() => Object)
async userXP(@CurrentUser() user: { id: string }) {
return this.gamificationService.getUserXP(user.id);
}
@Query(() => Object)
async userLevel(@CurrentUser() user: { id: string }) {
return this.gamificationService.getUserLevel(user.id);
}
@Query(() => [Object])
async badges(@CurrentUser() user: { id: string }) {
return this.gamificationService.getBadges(user.id);
}
@Query(() => Object)
async streak(@CurrentUser() user: { id: string }) {
return this.gamificationService.checkStreak(user.id);
}
@Query(() => [Object])
async leaderboard(
@CurrentTenant() tenantId: string,
@Args("limit", { type: () => Int, nullable: true, defaultValue: 20 }) limit: number,
) {
return this.gamificationService.getLeaderboard(tenantId, limit);
}
}

View File

@@ -0,0 +1,129 @@
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../common/prisma/prisma.service.js";
import { RedisService } from "../../common/redis/redis.service.js";
import type { Badge, LeaderboardEntry, XPEvent, XPReason } from "@investplay/types";
@Injectable()
export class GamificationService {
private readonly logger = new Logger(GamificationService.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
async awardXP(userId: string, amount: number, reason: XPReason, referenceId: string): Promise<XPEvent> {
this.logger.log(`Awarding ${amount} XP to ${userId} for ${reason} (${referenceId})`);
const xpEvent: XPEvent = {
userId,
amount,
reason,
referenceId,
referenceType: "lesson",
createdAt: new Date().toISOString(),
};
const newLevel = this.calculateLevel(amount);
await this.redis.del(`xp:${userId}`);
return xpEvent;
}
async getUserXP(userId: string): Promise<{ total: number; level: number; nextLevelXp: number; progress: number }> {
const cached = await this.redis.get<number>(`xp:${userId}`);
const totalXp = cached ?? 1500;
const level = this.calculateLevel(totalXp);
const levelThreshold = level * 1000;
const nextLevelThreshold = (level + 1) * 1000;
const progress = ((totalXp - levelThreshold) / (nextLevelThreshold - levelThreshold)) * 100;
return {
total: totalXp,
level,
nextLevelXp: nextLevelThreshold,
progress: Math.min(Math.round(progress), 100),
};
}
async getUserLevel(userId: string): Promise<{ level: number; title: string }> {
const { level } = await this.getUserXP(userId);
const titles = ["Beginner", "Apprentice", "Intermediate", "Advanced", "Expert", "Master", "Legend"];
return {
level,
title: titles[Math.min(level, titles.length - 1)],
};
}
async getBadges(userId: string): Promise<Badge[]> {
return [
{
id: "badge-first-lesson",
slug: "first-lesson",
name: "First Steps",
description: "Complete your first lesson",
iconUrl: "/badges/first-lesson.svg",
category: "milestone",
requiredXp: null,
createdAt: new Date().toISOString(),
},
{
id: "badge-quiz-wiz",
slug: "quiz-wiz",
name: "Quiz Wizard",
description: "Score 100% on any quiz",
iconUrl: "/badges/quiz-wiz.svg",
category: "skill",
requiredXp: null,
createdAt: new Date().toISOString(),
},
{
id: "badge-streak-7",
slug: "streak-7",
name: "Week Warrior",
description: "Maintain a 7-day streak",
iconUrl: "/badges/streak-7.svg",
category: "behavior",
requiredXp: null,
createdAt: new Date().toISOString(),
},
];
}
async checkStreak(userId: string): Promise<{ current: number; longest: number; lastActivity: string | null }> {
return {
current: 3,
longest: 12,
lastActivity: new Date().toISOString(),
};
}
async getLeaderboard(tenantId: string, limit = 20): Promise<LeaderboardEntry[]> {
const entries: LeaderboardEntry[] = [];
for (let i = 0; i < limit; i++) {
entries.push({
userId: `user-${i + 1}`,
rank: i + 1,
firstName: `Student`,
lastName: `${i + 1}`,
avatarUrl: null,
xp: Math.max(0, 5000 - i * 300),
level: Math.max(1, 5 - Math.floor(i / 4)),
badges: Math.max(0, 10 - i),
tenantId,
});
}
return entries;
}
private calculateLevel(xp: number): number {
if (xp < 1000) return 1;
if (xp < 2500) return 2;
if (xp < 5000) return 3;
if (xp < 10000) return 4;
if (xp < 20000) return 5;
if (xp < 50000) return 6;
return 7;
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { PortfolioService } from "./portfolio.service.js";
import { PortfolioResolver } from "./portfolio.resolver.js";
@Module({
providers: [PortfolioService, PortfolioResolver],
exports: [PortfolioService],
})
export class PortfolioModule {}

View File

@@ -0,0 +1,26 @@
import { UseGuards } from "@nestjs/common";
import { Resolver, Query } from "@nestjs/graphql";
import { PortfolioService } from "./portfolio.service.js";
import { GqlJwtGuard } from "../../common/guards/gql-jwt.guard.js";
import { CurrentUser } from "../../common/decorators/current-user.decorator.js";
@Resolver()
@UseGuards(GqlJwtGuard)
export class PortfolioResolver {
constructor(private readonly portfolioService: PortfolioService) {}
@Query(() => Object)
async portfolio(@CurrentUser() user: { id: string }) {
return this.portfolioService.getPortfolio(user.id);
}
@Query(() => [Object])
async tradeHistory(@CurrentUser() user: { id: string }) {
return this.portfolioService.getTradeHistory(user.id);
}
@Query(() => Object)
async roi(@CurrentUser() user: { id: string }) {
return this.portfolioService.getROI(user.id);
}
}

View File

@@ -0,0 +1,95 @@
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../common/prisma/prisma.service.js";
import { RedisService } from "../../common/redis/redis.service.js";
import type { Portfolio, Trade, Investment, Debt } from "@investplay/types";
@Injectable()
export class PortfolioService {
private readonly logger = new Logger(PortfolioService.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
async getPortfolio(userId: string): Promise<Portfolio> {
return {
cash: 3200.5,
investments: [
{
id: "inv-1",
name: "S&P 500 ETF",
type: "etf",
amount: 10,
value: 4500,
risk: "medium",
esgScore: 65,
},
{
id: "inv-2",
name: "Government Bond",
type: "bond",
amount: 5,
value: 2500,
risk: "low",
esgScore: 80,
},
],
debt: [
{
id: "debt-1",
name: "Student Loan",
type: "student_loan",
principal: 15000,
interestRate: 4.5,
minimumPayment: 200,
},
],
monthlyIncome: 3200,
monthlyExpenses: 2450,
};
}
async getTradeHistory(userId: string): Promise<Trade[]> {
return [
{
id: "trade-1",
sessionId: "sim-1",
assetName: "S&P 500 ETF",
type: "buy",
quantity: 10,
price: 450,
totalValue: 4500,
timestamp: new Date(Date.now() - 86400000 * 3).toISOString(),
reason: "Dollar-cost averaging into broad market ETF",
},
{
id: "trade-2",
sessionId: "sim-1",
assetName: "Government Bond",
type: "buy",
quantity: 5,
price: 500,
totalValue: 2500,
timestamp: new Date(Date.now() - 86400000 * 2).toISOString(),
reason: "Adding fixed income for portfolio diversification",
},
];
}
async getROI(userId: string): Promise<{ totalReturn: number; percentageReturn: number; annualizedReturn: number }> {
const portfolio = await this.getPortfolio(userId);
const totalInvested = portfolio.investments.reduce((sum, inv) => sum + inv.value, 0);
const totalDebt = portfolio.debt.reduce((sum, d) => sum + d.principal, 0);
const netWorth = portfolio.cash + totalInvested - totalDebt;
const initialInvestment = totalInvested;
const totalReturn = netWorth - initialInvestment;
const percentageReturn = initialInvestment > 0 ? Math.round((totalReturn / initialInvestment) * 100) : 0;
return {
totalReturn,
percentageReturn,
annualizedReturn: percentageReturn > 0 ? Math.round(percentageReturn / 3) : 0,
};
}
}

View File

@@ -0,0 +1,21 @@
import { Field, InputType } from "@nestjs/graphql";
import { IsString, IsIn } from "class-validator";
@InputType()
export class CreateSessionInput {
@Field()
@IsString()
@IsIn([
"BUDGET_CHALLENGE",
"EMERGENCY_FUND",
"COST_OF_LIVING",
"BLIND_ASSET",
"MARKET_CRASH",
"RENT_VS_TRANSPORT",
"SAVINGS_HABIT",
"ESG_PORTFOLIO",
"INTEREST_APY",
"DEBT_REPAYMENT",
])
simulationType: string;
}

View File

@@ -0,0 +1,34 @@
import { Field, InputType, Float } from "@nestjs/graphql";
import { IsString, IsNumber, IsPositive, IsOptional, MaxLength, IsIn } from "class-validator";
@InputType()
export class ExecuteTradeInput {
@Field()
@IsString()
sessionId: string;
@Field()
@IsString()
assetName: string;
@Field()
@IsString()
@IsIn(["buy", "sell"])
type: "buy" | "sell";
@Field(() => Float)
@IsNumber()
@IsPositive()
quantity: number;
@Field(() => Float)
@IsNumber()
@IsPositive()
price: number;
@Field({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}

View File

@@ -0,0 +1,84 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
Logger,
} from "@nestjs/websockets";
import { Server, Socket } from "socket.io";
@WebSocketGateway({
namespace: "/simulations",
cors: {
origin: "*",
credentials: true,
},
})
export class SimulationGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(SimulationGateway.name);
private activeIntervals = new Map<string, NodeJS.Timeout>();
@WebSocketServer()
server!: Server;
handleConnection(client: Socket) {
const sessionId = client.handshake.query.sessionId as string | undefined;
if (sessionId) {
client.join(`session:${sessionId}`);
this.logger.log(`Simulation client connected: ${client.id} (session: ${sessionId})`);
}
}
handleDisconnect(client: Socket) {
this.logger.log(`Simulation client disconnected: ${client.id}`);
}
@SubscribeMessage("sim:join")
handleJoinSession(client: Socket, sessionId: string) {
client.join(`session:${sessionId}`);
client.emit("sim:joined", { sessionId });
if (!this.activeIntervals.has(sessionId)) {
const interval = setInterval(() => {
const tick = {
tick: Math.floor(Math.random() * 100) + 1,
values: {
portfolio: 5000 + Math.floor(Math.random() * 500),
cash: 2000 + Math.floor(Math.random() * 500),
investments: 3000 + Math.floor(Math.random() * 500),
},
timestamp: new Date().toISOString(),
};
this.server.to(`session:${sessionId}`).emit("sim:tick", tick);
}, 5000);
this.activeIntervals.set(sessionId, interval);
this.logger.log(`Simulation ticks started for session ${sessionId}`);
}
}
@SubscribeMessage("sim:action")
handleAction(client: Socket, payload: { sessionId: string; action: string; data: Record<string, unknown> }) {
this.server.to(`session:${payload.sessionId}`).emit("sim:action-result", {
action: payload.action,
result: "acknowledged",
timestamp: new Date().toISOString(),
});
}
@SubscribeMessage("sim:leave")
handleLeaveSession(client: Socket, sessionId: string) {
client.leave(`session:${sessionId}`);
}
stopSessionTicks(sessionId: string) {
const interval = this.activeIntervals.get(sessionId);
if (interval) {
clearInterval(interval);
this.activeIntervals.delete(sessionId);
this.logger.log(`Simulation ticks stopped for session ${sessionId}`);
}
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { SimulationService } from "./simulation.service.js";
import { SimulationGateway } from "./simulation.gateway.js";
import { SimulationResolver } from "./simulation.resolver.js";
@Module({
providers: [SimulationService, SimulationGateway, SimulationResolver],
exports: [SimulationService],
})
export class SimulationModule {}

View File

@@ -0,0 +1,39 @@
import { UseGuards } from "@nestjs/common";
import { Resolver, Mutation, Query, Args } from "@nestjs/graphql";
import { SimulationService } from "./simulation.service.js";
import { CreateSessionInput } from "./dto/create-session.input.js";
import { ExecuteTradeInput } from "./dto/execute-trade.input.js";
import { GqlJwtGuard } from "../../common/guards/gql-jwt.guard.js";
import { CurrentUser } from "../../common/decorators/current-user.decorator.js";
@Resolver()
@UseGuards(GqlJwtGuard)
export class SimulationResolver {
constructor(private readonly simulationService: SimulationService) {}
@Mutation(() => Object)
async createSimulationSession(
@CurrentUser() user: { id: string },
@Args("input") input: CreateSessionInput,
) {
return this.simulationService.createSession(user.id, input);
}
@Query(() => Object)
async simulationSession(@Args("sessionId") sessionId: string) {
return this.simulationService.joinSession(sessionId);
}
@Mutation(() => Object)
async executeTrade(
@CurrentUser() user: { id: string },
@Args("input") input: ExecuteTradeInput,
) {
return this.simulationService.executeTrade(user.id, input);
}
@Mutation(() => Object)
async endSimulationSession(@Args("sessionId") sessionId: string) {
return this.simulationService.endSession(sessionId);
}
}

View File

@@ -0,0 +1,162 @@
import { Injectable, Logger, NotFoundException, BadRequestException } from "@nestjs/common";
import { PrismaService } from "../../common/prisma/prisma.service.js";
import { RedisService } from "../../common/redis/redis.service.js";
import type { CreateSessionInput } from "./dto/create-session.input.js";
import type { ExecuteTradeInput } from "./dto/execute-trade.input.js";
import type { SimulationSession, SimulationTick, SimulationType, Trade } from "@investplay/types";
@Injectable()
export class SimulationService {
private readonly logger = new Logger(SimulationService.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
async createSession(userId: string, input: CreateSessionInput): Promise<SimulationSession> {
const sessionId = `sim-${Date.now()}-${userId.slice(0, 8)}`;
const session: SimulationSession = {
id: sessionId,
userId,
simulationType: input.simulationType as SimulationType,
ticks: this.generateInitialTicks(input.simulationType as SimulationType),
portfolio: {
cash: 5000,
investments: [],
debt: [],
monthlyIncome: 3000,
monthlyExpenses: 2500,
},
trades: [],
score: 0,
decisions: 0,
completed: false,
startedAt: new Date().toISOString(),
completedAt: null,
};
await this.redis.set(`session:${sessionId}`, JSON.stringify(session), 7200);
this.logger.log(`Simulation session created: ${sessionId} for user ${userId}`);
return session;
}
async joinSession(sessionId: string): Promise<SimulationSession> {
const cached = await this.redis.get<string>(`session:${sessionId}`);
if (!cached) {
throw new NotFoundException(`Session ${sessionId} not found or expired`);
}
return JSON.parse(cached) as SimulationSession;
}
async executeTrade(userId: string, input: ExecuteTradeInput): Promise<{ trade: Trade; session: SimulationSession }> {
const cached = await this.redis.get<string>(`session:${input.sessionId}`);
if (!cached) {
throw new NotFoundException(`Session ${input.sessionId} not found`);
}
const session = JSON.parse(cached) as SimulationSession;
if (session.completed) {
throw new BadRequestException("Session already completed");
}
const trade: Trade = {
id: `trade-${Date.now()}`,
sessionId: input.sessionId,
assetName: input.assetName,
type: input.type,
quantity: input.quantity,
price: input.price,
totalValue: input.quantity * input.price,
timestamp: new Date().toISOString(),
reason: input.reason ?? "",
};
const totalCost = trade.totalValue;
if (input.type === "buy" && session.portfolio.cash < totalCost) {
throw new BadRequestException("Insufficient funds");
}
if (input.type === "buy") {
session.portfolio.cash -= totalCost;
const existing = session.portfolio.investments.find((inv) => inv.name === input.assetName);
if (existing) {
existing.amount += input.quantity;
existing.value += totalCost;
} else {
session.portfolio.investments.push({
id: `inv-${Date.now()}`,
name: input.assetName,
type: "stock",
amount: input.quantity,
value: totalCost,
risk: "medium",
esgScore: null,
});
}
} else {
session.portfolio.cash += totalCost;
const existing = session.portfolio.investments.find((inv) => inv.name === input.assetName);
if (existing) {
existing.amount = Math.max(0, existing.amount - input.quantity);
existing.value = Math.max(0, existing.value - totalCost);
}
}
session.trades.push(trade);
session.decisions += 1;
await this.redis.set(`session:${input.sessionId}`, JSON.stringify(session), 7200);
return { trade, session };
}
async endSession(sessionId: string): Promise<SimulationSession> {
const cached = await this.redis.get<string>(`session:${sessionId}`);
if (!cached) {
throw new NotFoundException(`Session ${sessionId} not found`);
}
const session = JSON.parse(cached) as SimulationSession;
session.completed = true;
session.completedAt = new Date().toISOString();
session.score = this.calculateScore(session);
await this.redis.set(`session:${sessionId}`, JSON.stringify(session), 7200);
return session;
}
private generateInitialTicks(type: SimulationType): SimulationTick[] {
const tickCount = 12;
const ticks: SimulationTick[] = [];
for (let i = 0; i < tickCount; i++) {
ticks.push({
tick: i + 1,
label: `Month ${i + 1}`,
values: {
income: 3000 + Math.floor(Math.random() * 200),
expenses: 2500 + Math.floor(Math.random() * 300),
savings: 500 + Math.floor(Math.random() * 200),
},
events: [],
});
}
return ticks;
}
private calculateScore(session: SimulationSession): number {
const netWorth = session.portfolio.cash +
session.portfolio.investments.reduce((sum, inv) => sum + inv.value, 0) -
session.portfolio.debt.reduce((sum, d) => sum + d.principal, 0);
const baseScore = Math.max(0, Math.min(1000, Math.round((netWorth / 10000) * 100)));
const decisionBonus = Math.min(100, session.decisions * 10);
return baseScore + decisionBonus;
}
}

View File

@@ -0,0 +1,21 @@
import { Field, InputType } from "@nestjs/graphql";
import { IsString, MinLength, MaxLength, IsOptional } from "class-validator";
@InputType()
export class CreateTenantInput {
@Field()
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
domain?: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
defaultLocale?: string;
}

View File

@@ -0,0 +1,42 @@
import { Field, InputType } from "@nestjs/graphql";
import { IsString, IsOptional, MinLength, MaxLength, IsBoolean } from "class-validator";
@InputType()
export class UpdateTenantInput {
@Field({ nullable: true })
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(100)
name?: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
domain?: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
defaultLocale?: string;
@Field({ nullable: true })
@IsOptional()
@IsBoolean()
aiCoach?: boolean;
@Field({ nullable: true })
@IsOptional()
@IsBoolean()
classroom?: boolean;
@Field({ nullable: true })
@IsOptional()
@IsBoolean()
simulations?: boolean;
@Field({ nullable: true })
@IsOptional()
@IsBoolean()
gamification?: boolean;
}

View File

@@ -0,0 +1,70 @@
import { Field, ObjectType, Boolean as GraphQLBoolean } from "@nestjs/graphql";
@ObjectType()
export class TenantBranding {
@Field({ nullable: true })
logoUrl: string | null;
@Field({ nullable: true })
primaryColor: string | null;
@Field({ nullable: true })
accentColor: string | null;
@Field({ nullable: true })
faviconUrl: string | null;
}
@ObjectType()
export class TenantFeatureFlags {
@Field(() => GraphQLBoolean)
aiCoach: boolean;
@Field(() => GraphQLBoolean)
classroom: boolean;
@Field(() => GraphQLBoolean)
simulations: boolean;
@Field(() => GraphQLBoolean)
gamification: boolean;
@Field(() => GraphQLBoolean)
tenantDashboard: boolean;
@Field(() => GraphQLBoolean)
researchProgram: boolean;
}
@ObjectType()
export class TenantEntity {
@Field()
id: string;
@Field()
name: string;
@Field({ nullable: true })
domain: string | null;
@Field(() => TenantBranding, { nullable: true })
branding: TenantBranding | null;
@Field(() => TenantFeatureFlags)
features: TenantFeatureFlags;
@Field()
aiMode: string;
@Field(() => GraphQLBoolean)
monthlyTokenBudget: number;
@Field()
defaultLocale: string;
@Field(() => GraphQLBoolean)
excludeFromResearch: boolean;
@Field()
createdAt: string;
}

View File

@@ -0,0 +1,19 @@
import { Injectable } from "@nestjs/common";
import { AsyncLocalStorage } from "node:async_hooks";
@Injectable()
export class TenantContextService {
private readonly storage = new AsyncLocalStorage<string>();
run<T>(tenantId: string, fn: () => T): T {
return this.storage.run(tenantId, fn);
}
getCurrentTenantId(): string | undefined {
return this.storage.getStore();
}
setCurrentTenantId(tenantId: string) {
this.storage.enterWith(tenantId);
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
@Injectable()
export class TenantMiddleware implements NestMiddleware {
private readonly logger = new Logger(TenantMiddleware.name);
use(req: Request, _res: Response, next: NextFunction) {
const tenantId =
req.headers["x-tenant-id"] as string | undefined ??
req.user?.tenantId ??
this.extractFromSubdomain(req);
if (tenantId) {
(req as Request & { tenantId: string }).tenantId = tenantId;
}
next();
}
private extractFromSubdomain(req: Request): string | undefined {
const host = req.headers.host;
if (!host) return undefined;
const parts = host.split(".");
if (parts.length >= 3) {
return parts[0];
}
return undefined;
}
}

View File

@@ -0,0 +1,15 @@
import { Module, NestModule, MiddlewareConsumer } from "@nestjs/common";
import { TenantService } from "./tenant.service.js";
import { TenantResolver } from "./tenant.resolver.js";
import { TenantContextService } from "./tenant-context.service.js";
import { TenantMiddleware } from "./tenant.middleware.js";
@Module({
providers: [TenantService, TenantResolver, TenantContextService],
exports: [TenantService, TenantContextService],
})
export class TenantModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantMiddleware).forRoutes("*");
}
}

View File

@@ -0,0 +1,65 @@
import { UseGuards } from "@nestjs/common";
import { Resolver, Query, Mutation, Args } from "@nestjs/graphql";
import { TenantService } from "./tenant.service.js";
import { TenantEntity } from "./entities/tenant.entity.js";
import { CreateTenantInput } from "./dto/create-tenant.input.js";
import { UpdateTenantInput } from "./dto/update-tenant.input.js";
import { GqlJwtGuard } from "../../common/guards/gql-jwt.guard.js";
import { GqlRolesGuard } from "../../common/guards/gql-roles.guard.js";
import { Roles } from "../../common/decorators/roles.decorator.js";
import { CurrentTenant } from "../../common/decorators/current-tenant.decorator.js";
@Resolver(() => TenantEntity)
@UseGuards(GqlJwtGuard, GqlRolesGuard)
export class TenantResolver {
constructor(private readonly tenantService: TenantService) {}
@Query(() => TenantEntity)
async tenant(@Args("id") id: string) {
return this.tenantService.getTenant(id);
}
@Query(() => TenantEntity)
async currentTenant(@CurrentTenant() tenantId: string) {
return this.tenantService.getTenant(tenantId);
}
@Mutation(() => TenantEntity)
@Roles("PLATFORM_ADMIN")
async createTenant(@Args("input") input: CreateTenantInput) {
return this.tenantService.createTenant(input);
}
@Mutation(() => TenantEntity)
@Roles("PLATFORM_ADMIN", "TENANT_ADMIN")
async updateTenant(
@Args("id") id: string,
@Args("input") input: UpdateTenantInput,
) {
return this.tenantService.updateTenant(id, input);
}
@Mutation(() => TenantEntity)
@Roles("PLATFORM_ADMIN", "TENANT_ADMIN")
async setTenantAIMode(
@Args("tenantId") tenantId: string,
@Args("mode") mode: string,
) {
return this.tenantService.setAIMode(tenantId, mode as never);
}
@Mutation(() => TenantEntity)
@Roles("PLATFORM_ADMIN", "TENANT_ADMIN")
async addTenantDomain(
@Args("tenantId") tenantId: string,
@Args("domain") domain: string,
) {
return this.tenantService.addDomain(tenantId, domain);
}
@Mutation(() => TenantEntity)
@Roles("PLATFORM_ADMIN", "TENANT_ADMIN")
async removeTenantDomain(@Args("tenantId") tenantId: string) {
return this.tenantService.removeDomain(tenantId);
}
}

View File

@@ -0,0 +1,166 @@
import {
Injectable,
NotFoundException,
ConflictException,
Logger,
} from "@nestjs/common";
import { PrismaService } from "../../common/prisma/prisma.service.js";
import { RedisService } from "../../common/redis/redis.service.js";
import { TenantContextService } from "./tenant-context.service.js";
import type { CreateTenantInput } from "./dto/create-tenant.input.js";
import type { UpdateTenantInput } from "./dto/update-tenant.input.js";
import type { AIMode } from "@investplay/types";
@Injectable()
export class TenantService {
private readonly logger = new Logger(TenantService.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly tenantContext: TenantContextService,
) {}
async createTenant(input: CreateTenantInput) {
const existing = await this.prisma.tenant.findUnique({
where: { domain: input.domain ?? undefined },
});
if (existing) {
throw new ConflictException("A tenant with this domain already exists");
}
const tenant = await this.prisma.tenant.create({
data: {
name: input.name,
domain: input.domain ?? null,
defaultLocale: input.defaultLocale ?? "en",
features: {
aiCoach: true,
classroom: true,
simulations: true,
gamification: true,
tenantDashboard: true,
researchProgram: false,
},
},
});
await this.redis.set(`tenant:${tenant.id}`, JSON.stringify(tenant), 3600);
this.logger.log(`Tenant created: ${tenant.id} (${tenant.name})`);
return tenant;
}
async getTenant(id: string) {
const cached = await this.redis.get<Record<string, unknown>>(`tenant:${id}`);
if (cached) {
return cached;
}
const tenant = await this.prisma.tenant.findUnique({ where: { id } });
if (!tenant) {
throw new NotFoundException(`Tenant ${id} not found`);
}
await this.redis.set(`tenant:${id}`, JSON.stringify(tenant), 3600);
return tenant;
}
async updateTenant(id: string, input: UpdateTenantInput) {
const tenant = await this.prisma.tenant.findUnique({ where: { id } });
if (!tenant) {
throw new NotFoundException(`Tenant ${id} not found`);
}
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.domain !== undefined) updateData.domain = input.domain;
if (input.defaultLocale !== undefined) updateData.defaultLocale = input.defaultLocale;
if (input.aiCoach !== undefined || input.classroom !== undefined || input.simulations !== undefined || input.gamification !== undefined) {
const features = (tenant.features as Record<string, boolean>) ?? {};
updateData.features = {
...features,
...(input.aiCoach !== undefined && { aiCoach: input.aiCoach }),
...(input.classroom !== undefined && { classroom: input.classroom }),
...(input.simulations !== undefined && { simulations: input.simulations }),
...(input.gamification !== undefined && { gamification: input.gamification }),
};
}
const updated = await this.prisma.tenant.update({
where: { id },
data: updateData,
});
await this.redis.del(`tenant:${id}`);
this.logger.log(`Tenant updated: ${id}`);
return updated;
}
async getFeatureFlags(tenantId: string) {
const tenant = await this.getTenant(tenantId);
return (tenant as Record<string, unknown>).features;
}
async setAIMode(tenantId: string, mode: AIMode) {
const updated = await this.prisma.tenant.update({
where: { id: tenantId },
data: { aiMode: mode },
});
await this.redis.del(`tenant:${tenantId}`);
this.logger.log(`AI mode set to ${mode} for tenant ${tenantId}`);
return updated;
}
async addDomain(tenantId: string, domain: string) {
const existing = await this.prisma.tenant.findUnique({
where: { domain },
});
if (existing && existing.id !== tenantId) {
throw new ConflictException(`Domain ${domain} is already in use`);
}
const updated = await this.prisma.tenant.update({
where: { id: tenantId },
data: { domain },
});
await this.redis.del(`tenant:${tenantId}`);
return updated;
}
async removeDomain(tenantId: string) {
const updated = await this.prisma.tenant.update({
where: { id: tenantId },
data: { domain: null },
});
await this.redis.del(`tenant:${tenantId}`);
return updated;
}
async listTenants(page = 1, limit = 20) {
const [tenants, total] = await Promise.all([
this.prisma.tenant.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: "desc" },
}),
this.prisma.tenant.count(),
]);
return {
items: tenants,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
}

22
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"paths": {
"@investplay/types": ["../../packages/types/src"],
"@investplay/types/*": ["../../packages/types/src/*"],
"@investplay/utils": ["../../packages/utils/src"],
"@investplay/utils/*": ["../../packages/utils/src/*"],
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", ".turbo"]
}

35
apps/cms/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY apps/cms/package.json apps/cms/tsconfig.json ./apps/cms/
COPY packages/ ./packages/
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat python3 make g++
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY apps/cms/package.json apps/cms/tsconfig.json ./apps/cms/
COPY packages/ ./packages/
RUN pnpm install --frozen-lockfile
COPY apps/cms ./apps/cms
RUN pnpm --filter @investplay/cms build
FROM node:20-alpine AS runner
RUN apk add --no-cache wget
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nodeuser
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/apps/cms/dist ./dist
COPY --from=builder /app/apps/cms/package.json ./package.json
COPY --from=builder /app/apps/cms/public ./public
RUN chown -R nodeuser:nodejs /app
USER nodeuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]

10
apps/cms/Dockerfile.dev Normal file
View File

@@ -0,0 +1,10 @@
FROM node:20-alpine
RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY apps/cms/package.json apps/cms/tsconfig.json ./apps/cms/
COPY packages/ ./packages/
RUN pnpm install --frozen-lockfile
EXPOSE 3000
CMD ["pnpm", "--filter", "@investplay/cms", "dev"]

27
apps/cms/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@investplay/cms",
"version": "0.1.0",
"private": true,
"description": "InvestPlay Payload CMS",
"scripts": {
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload",
"build": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
"lint": "eslint \"src/**/*.ts\" --fix",
"typecheck": "tsc --noEmit",
"clean": "rimraf dist .turbo"
},
"dependencies": {
"payload": "^3.25.0",
"@payloadcms/next": "^3.25.0",
"express": "^5.1.0",
"dotenv": "^16.4.0",
"cross-env": "^7.0.3"
},
"devDependencies": {
"typescript": "^5.8.0",
"@types/express": "^5.0.0",
"@types/node": "^22.14.0",
"eslint": "^8.57.0",
"rimraf": "^6.0.0"
}
}

20
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY apps/web/package.json apps/web/tsconfig.json apps/web/tsconfig.node.json apps/web/vite.config.ts ./apps/web/
COPY packages/ ./packages/
RUN pnpm install --frozen-lockfile
COPY apps/web ./apps/web
RUN pnpm --filter @investplay/web build
FROM nginx:alpine AS runner
RUN apk add --no-cache wget
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
COPY apps/web/nginx.conf /etc/nginx/nginx.conf
RUN chown -R nginx:nginx /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

Some files were not shown because too many files have changed in this diff Show More