From e75f913f1c5fbac44f001803bf068a71dc9ba7b6 Mon Sep 17 00:00:00 2001 From: Lefteris Notas Date: Fri, 12 Jun 2026 19:32:57 +0300 Subject: [PATCH] feat: complete Phase 1 foundation scaffold - 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 --- .dockerignore | 45 + .env.example | 123 +++ .env.production.example | 171 ++++ .eslintrc.js | 68 ++ .github/CODEOWNERS | 53 ++ .github/dependabot.yml | 89 ++ .github/workflows/ci.yml | 143 +++ .github/workflows/deploy.yml | 149 ++++ .github/workflows/pr-checks.yml | 135 +++ .husky/pre-commit | 4 + .lintstagedrc.js | 4 + .prettierrc | 8 + README.md | 127 +++ apps/api/Dockerfile | 37 + apps/api/Dockerfile.dev | 12 + apps/api/nest-cli.json | 8 + apps/api/package.json | 74 ++ apps/api/prisma/migrations/.gitkeep | 0 apps/api/prisma/schema.prisma | 572 ++++++++++++ apps/api/prisma/seed.ts | 833 ++++++++++++++++++ apps/api/src/app.controller.ts | 63 ++ apps/api/src/app.module.ts | 116 +++ .../decorators/current-tenant.decorator.ts | 14 + .../decorators/current-user.decorator.ts | 14 + apps/api/src/common/decorators/index.ts | 4 + .../src/common/decorators/public.decorator.ts | 4 + .../src/common/decorators/roles.decorator.ts | 5 + .../common/filters/http-exception.filter.ts | 115 +++ apps/api/src/common/guards/gql-jwt.guard.ts | 37 + apps/api/src/common/guards/gql-roles.guard.ts | 37 + apps/api/src/common/guards/index.ts | 5 + apps/api/src/common/guards/jwt-auth.guard.ts | 31 + apps/api/src/common/guards/roles.guard.ts | 36 + apps/api/src/common/guards/tenant.guard.ts | 29 + .../interceptors/logging.interceptor.ts | 60 ++ .../interceptors/transform.interceptor.ts | 32 + apps/api/src/common/prisma/prisma.module.ts | 9 + apps/api/src/common/prisma/prisma.service.ts | 54 ++ apps/api/src/common/queue/queue.module.ts | 17 + apps/api/src/common/queue/queue.service.ts | 106 +++ apps/api/src/common/redis/redis.module.ts | 9 + apps/api/src/common/redis/redis.service.ts | 122 +++ apps/api/src/common/types/context.ts | 12 + apps/api/src/common/types/pagination.ts | 46 + apps/api/src/config/env.config.ts | 144 +++ apps/api/src/config/env.validation.ts | 144 +++ apps/api/src/config/index.ts | 20 + apps/api/src/main.ts | 77 ++ .../src/modules/ai-coach/ai-coach.gateway.ts | 90 ++ .../src/modules/ai-coach/ai-coach.module.ts | 10 + .../src/modules/ai-coach/ai-coach.resolver.ts | 36 + .../src/modules/ai-coach/ai-coach.service.ts | 92 ++ .../modules/ai-coach/dto/ai-message.input.ts | 21 + .../src/modules/analytics/analytics.module.ts | 9 + .../modules/analytics/analytics.resolver.ts | 56 ++ .../modules/analytics/analytics.service.ts | 114 +++ apps/api/src/modules/auth/auth.controller.ts | 26 + apps/api/src/modules/auth/auth.module.ts | 27 + apps/api/src/modules/auth/auth.resolver.ts | 53 ++ apps/api/src/modules/auth/auth.service.ts | 202 +++++ apps/api/src/modules/auth/dto/login.input.ts | 14 + .../src/modules/auth/dto/register.input.ts | 34 + .../modules/auth/dto/token-response.dto.ts | 13 + .../src/modules/auth/entities/user.entity.ts | 55 ++ .../src/modules/auth/jwt-refresh.strategy.ts | 45 + apps/api/src/modules/auth/jwt.strategy.ts | 35 + .../src/modules/classroom/classroom.module.ts | 9 + .../modules/classroom/classroom.resolver.ts | 67 ++ .../modules/classroom/classroom.service.ts | 159 ++++ .../modules/curriculum/curriculum.module.ts | 9 + .../modules/curriculum/curriculum.resolver.ts | 47 + .../modules/curriculum/curriculum.service.ts | 144 +++ .../curriculum/dto/submit-quiz.input.ts | 30 + .../curriculum/entities/lesson.entity.ts | 64 ++ .../curriculum/entities/module.entity.ts | 32 + .../gamification/gamification.module.ts | 9 + .../gamification/gamification.resolver.ts | 40 + .../gamification/gamification.service.ts | 129 +++ .../src/modules/portfolio/portfolio.module.ts | 9 + .../modules/portfolio/portfolio.resolver.ts | 26 + .../modules/portfolio/portfolio.service.ts | 95 ++ .../simulation/dto/create-session.input.ts | 21 + .../simulation/dto/execute-trade.input.ts | 34 + .../modules/simulation/simulation.gateway.ts | 84 ++ .../modules/simulation/simulation.module.ts | 10 + .../modules/simulation/simulation.resolver.ts | 39 + .../modules/simulation/simulation.service.ts | 162 ++++ .../modules/tenant/dto/create-tenant.input.ts | 21 + .../modules/tenant/dto/update-tenant.input.ts | 42 + .../modules/tenant/entities/tenant.entity.ts | 70 ++ .../modules/tenant/tenant-context.service.ts | 19 + .../src/modules/tenant/tenant.middleware.ts | 32 + apps/api/src/modules/tenant/tenant.module.ts | 15 + .../api/src/modules/tenant/tenant.resolver.ts | 65 ++ apps/api/src/modules/tenant/tenant.service.ts | 166 ++++ apps/api/tsconfig.json | 22 + apps/cms/Dockerfile | 35 + apps/cms/Dockerfile.dev | 10 + apps/cms/package.json | 27 + apps/web/Dockerfile | 20 + apps/web/Dockerfile.dev | 10 + apps/web/index.html | 20 + apps/web/nginx.conf | 125 +++ apps/web/package.json | 65 ++ apps/web/postcss.config.js | 6 + apps/web/src/App.tsx | 80 ++ .../components/curriculum/BlockRenderer.tsx | 63 ++ .../components/curriculum/CalculatorBlock.tsx | 88 ++ .../src/components/curriculum/HeroBlock.tsx | 33 + .../src/components/curriculum/ProgressBar.tsx | 62 ++ .../src/components/curriculum/QuizBlock.tsx | 125 +++ .../src/components/curriculum/TextBlock.tsx | 31 + .../src/components/curriculum/TrackCard.tsx | 104 +++ .../src/components/gamification/BadgeCard.tsx | 71 ++ .../src/components/gamification/BadgeGrid.tsx | 68 ++ .../gamification/LeaderboardTable.tsx | 111 +++ .../components/gamification/StreakCounter.tsx | 45 + .../web/src/components/gamification/XPBar.tsx | 51 ++ .../web/src/components/layout/Breadcrumbs.tsx | 58 ++ apps/web/src/components/layout/MobileNav.tsx | 54 ++ apps/web/src/components/layout/Sidebar.tsx | 122 +++ apps/web/src/components/layout/TopBar.tsx | 117 +++ .../src/components/shared/ConfirmDialog.tsx | 62 ++ apps/web/src/components/shared/EmptyState.tsx | 45 + .../src/components/shared/ErrorBoundary.tsx | 71 ++ .../components/shared/LanguageSwitcher.tsx | 55 ++ .../src/components/shared/LoadingSpinner.tsx | 33 + .../web/src/components/shared/ThemeToggle.tsx | 50 ++ apps/web/src/components/ui/avatar.tsx | 47 + apps/web/src/components/ui/badge.tsx | 33 + apps/web/src/components/ui/button.tsx | 52 ++ apps/web/src/components/ui/card.tsx | 69 ++ apps/web/src/components/ui/dialog.tsx | 84 ++ apps/web/src/components/ui/dropdown-menu.tsx | 85 ++ apps/web/src/components/ui/input.tsx | 51 ++ apps/web/src/components/ui/label.tsx | 33 + apps/web/src/components/ui/progress.tsx | 30 + apps/web/src/components/ui/separator.tsx | 23 + apps/web/src/components/ui/skeleton.tsx | 16 + apps/web/src/components/ui/tabs.tsx | 52 ++ apps/web/src/components/ui/toast.tsx | 119 +++ apps/web/src/components/ui/tooltip.tsx | 25 + apps/web/src/hooks/useAICoach.ts | 68 ++ apps/web/src/hooks/useAuth.ts | 53 ++ apps/web/src/hooks/useLocalizedContent.ts | 29 + apps/web/src/hooks/useMediaQuery.ts | 27 + apps/web/src/hooks/useSimulation.ts | 73 ++ apps/web/src/layouts/AuthenticatedLayout.tsx | 48 + apps/web/src/layouts/PublicLayout.tsx | 113 +++ apps/web/src/layouts/TeacherLayout.tsx | 59 ++ apps/web/src/lib/api.ts | 140 +++ apps/web/src/lib/env.ts | 35 + apps/web/src/lib/graphql.ts | 64 ++ apps/web/src/lib/i18n.ts | 20 + apps/web/src/lib/socket.ts | 63 ++ apps/web/src/main.tsx | 31 + apps/web/src/pages/DashboardPage.tsx | 226 +++++ apps/web/src/pages/HomePage.tsx | 300 +++++++ apps/web/src/pages/LearnPage.tsx | 139 +++ apps/web/src/pages/LessonPage.tsx | 227 +++++ apps/web/src/pages/ModulePage.tsx | 135 +++ apps/web/src/pages/NotFoundPage.tsx | 38 + apps/web/src/pages/PlayPage.tsx | 162 ++++ apps/web/src/pages/PracticePage.tsx | 176 ++++ apps/web/src/pages/ProfilePage.tsx | 160 ++++ apps/web/src/pages/SettingsPage.tsx | 216 +++++ apps/web/src/stores/auth.store.ts | 67 ++ apps/web/src/stores/gamification.store.ts | 76 ++ apps/web/src/stores/ui.store.ts | 52 ++ apps/web/src/styles/globals.css | 85 ++ apps/web/tailwind.config.ts | 81 ++ apps/web/tsconfig.json | 26 + apps/web/vite.config.ts | 38 + docker-compose.dev.yml | 155 ++++ docker-compose.portainer.yml | 236 +++++ docker-compose.prod.yml | 240 +++++ docker/nginx/nginx.conf | 119 +++ docker/traefik/dynamic.yml | 79 ++ docker/traefik/traefik.yml | 45 + docs/API.md | 472 ++++++++++ docs/ARCHITECTURE.md | 638 ++++++++++++++ docs/CONTEXT.md | 320 +++++++ docs/DEVELOPMENT-ROADMAP.md | 389 ++++++++ docs/LOCALIZATION.md | 377 ++++++++ package.json | 30 + packages/i18n/package.json | 23 + packages/i18n/src/index.ts | 53 ++ packages/i18n/src/locales/el/ai-coach.json | 34 + packages/i18n/src/locales/el/classroom.json | 66 ++ packages/i18n/src/locales/el/common.json | 103 +++ packages/i18n/src/locales/el/finance.json | 105 +++ .../i18n/src/locales/el/gamification.json | 62 ++ packages/i18n/src/locales/en/ai-coach.json | 34 + packages/i18n/src/locales/en/classroom.json | 66 ++ packages/i18n/src/locales/en/common.json | 103 +++ packages/i18n/src/locales/en/finance.json | 105 +++ .../i18n/src/locales/en/gamification.json | 62 ++ packages/i18n/tsconfig.json | 12 + packages/types/package.json | 18 + packages/types/src/ai.ts | 38 + packages/types/src/analytics.ts | 22 + packages/types/src/classroom.ts | 76 ++ packages/types/src/curriculum.ts | 70 ++ packages/types/src/gamification.ts | 60 ++ packages/types/src/index.ts | 40 + packages/types/src/simulation.ts | 91 ++ packages/types/src/tenant.ts | 32 + packages/types/src/user.ts | 28 + packages/types/tsconfig.json | 9 + packages/ui/package.json | 41 + packages/ui/src/index.ts | 1 + packages/ui/src/lib/utils.ts | 50 ++ packages/ui/tsconfig.json | 12 + packages/utils/package.json | 23 + packages/utils/src/constants.ts | 34 + packages/utils/src/env.ts | 52 ++ packages/utils/src/formatting.ts | 95 ++ packages/utils/src/index.ts | 26 + packages/utils/src/validation.ts | 44 + packages/utils/tsconfig.json | 11 + pnpm-workspace.yaml | 3 + scripts/backup.sh | 154 ++++ scripts/healthcheck.sh | 121 +++ scripts/setup.sh | 127 +++ tsconfig.base.json | 29 + turbo.json | 29 + 226 files changed, 17547 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 .eslintrc.js create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/pr-checks.yml create mode 100644 .husky/pre-commit create mode 100644 .lintstagedrc.js create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 apps/api/Dockerfile create mode 100644 apps/api/Dockerfile.dev create mode 100644 apps/api/nest-cli.json create mode 100644 apps/api/package.json create mode 100644 apps/api/prisma/migrations/.gitkeep create mode 100644 apps/api/prisma/schema.prisma create mode 100644 apps/api/prisma/seed.ts create mode 100644 apps/api/src/app.controller.ts create mode 100644 apps/api/src/app.module.ts create mode 100644 apps/api/src/common/decorators/current-tenant.decorator.ts create mode 100644 apps/api/src/common/decorators/current-user.decorator.ts create mode 100644 apps/api/src/common/decorators/index.ts create mode 100644 apps/api/src/common/decorators/public.decorator.ts create mode 100644 apps/api/src/common/decorators/roles.decorator.ts create mode 100644 apps/api/src/common/filters/http-exception.filter.ts create mode 100644 apps/api/src/common/guards/gql-jwt.guard.ts create mode 100644 apps/api/src/common/guards/gql-roles.guard.ts create mode 100644 apps/api/src/common/guards/index.ts create mode 100644 apps/api/src/common/guards/jwt-auth.guard.ts create mode 100644 apps/api/src/common/guards/roles.guard.ts create mode 100644 apps/api/src/common/guards/tenant.guard.ts create mode 100644 apps/api/src/common/interceptors/logging.interceptor.ts create mode 100644 apps/api/src/common/interceptors/transform.interceptor.ts create mode 100644 apps/api/src/common/prisma/prisma.module.ts create mode 100644 apps/api/src/common/prisma/prisma.service.ts create mode 100644 apps/api/src/common/queue/queue.module.ts create mode 100644 apps/api/src/common/queue/queue.service.ts create mode 100644 apps/api/src/common/redis/redis.module.ts create mode 100644 apps/api/src/common/redis/redis.service.ts create mode 100644 apps/api/src/common/types/context.ts create mode 100644 apps/api/src/common/types/pagination.ts create mode 100644 apps/api/src/config/env.config.ts create mode 100644 apps/api/src/config/env.validation.ts create mode 100644 apps/api/src/config/index.ts create mode 100644 apps/api/src/main.ts create mode 100644 apps/api/src/modules/ai-coach/ai-coach.gateway.ts create mode 100644 apps/api/src/modules/ai-coach/ai-coach.module.ts create mode 100644 apps/api/src/modules/ai-coach/ai-coach.resolver.ts create mode 100644 apps/api/src/modules/ai-coach/ai-coach.service.ts create mode 100644 apps/api/src/modules/ai-coach/dto/ai-message.input.ts create mode 100644 apps/api/src/modules/analytics/analytics.module.ts create mode 100644 apps/api/src/modules/analytics/analytics.resolver.ts create mode 100644 apps/api/src/modules/analytics/analytics.service.ts create mode 100644 apps/api/src/modules/auth/auth.controller.ts create mode 100644 apps/api/src/modules/auth/auth.module.ts create mode 100644 apps/api/src/modules/auth/auth.resolver.ts create mode 100644 apps/api/src/modules/auth/auth.service.ts create mode 100644 apps/api/src/modules/auth/dto/login.input.ts create mode 100644 apps/api/src/modules/auth/dto/register.input.ts create mode 100644 apps/api/src/modules/auth/dto/token-response.dto.ts create mode 100644 apps/api/src/modules/auth/entities/user.entity.ts create mode 100644 apps/api/src/modules/auth/jwt-refresh.strategy.ts create mode 100644 apps/api/src/modules/auth/jwt.strategy.ts create mode 100644 apps/api/src/modules/classroom/classroom.module.ts create mode 100644 apps/api/src/modules/classroom/classroom.resolver.ts create mode 100644 apps/api/src/modules/classroom/classroom.service.ts create mode 100644 apps/api/src/modules/curriculum/curriculum.module.ts create mode 100644 apps/api/src/modules/curriculum/curriculum.resolver.ts create mode 100644 apps/api/src/modules/curriculum/curriculum.service.ts create mode 100644 apps/api/src/modules/curriculum/dto/submit-quiz.input.ts create mode 100644 apps/api/src/modules/curriculum/entities/lesson.entity.ts create mode 100644 apps/api/src/modules/curriculum/entities/module.entity.ts create mode 100644 apps/api/src/modules/gamification/gamification.module.ts create mode 100644 apps/api/src/modules/gamification/gamification.resolver.ts create mode 100644 apps/api/src/modules/gamification/gamification.service.ts create mode 100644 apps/api/src/modules/portfolio/portfolio.module.ts create mode 100644 apps/api/src/modules/portfolio/portfolio.resolver.ts create mode 100644 apps/api/src/modules/portfolio/portfolio.service.ts create mode 100644 apps/api/src/modules/simulation/dto/create-session.input.ts create mode 100644 apps/api/src/modules/simulation/dto/execute-trade.input.ts create mode 100644 apps/api/src/modules/simulation/simulation.gateway.ts create mode 100644 apps/api/src/modules/simulation/simulation.module.ts create mode 100644 apps/api/src/modules/simulation/simulation.resolver.ts create mode 100644 apps/api/src/modules/simulation/simulation.service.ts create mode 100644 apps/api/src/modules/tenant/dto/create-tenant.input.ts create mode 100644 apps/api/src/modules/tenant/dto/update-tenant.input.ts create mode 100644 apps/api/src/modules/tenant/entities/tenant.entity.ts create mode 100644 apps/api/src/modules/tenant/tenant-context.service.ts create mode 100644 apps/api/src/modules/tenant/tenant.middleware.ts create mode 100644 apps/api/src/modules/tenant/tenant.module.ts create mode 100644 apps/api/src/modules/tenant/tenant.resolver.ts create mode 100644 apps/api/src/modules/tenant/tenant.service.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/cms/Dockerfile create mode 100644 apps/cms/Dockerfile.dev create mode 100644 apps/cms/package.json create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/Dockerfile.dev create mode 100644 apps/web/index.html create mode 100644 apps/web/nginx.conf create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.js create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/components/curriculum/BlockRenderer.tsx create mode 100644 apps/web/src/components/curriculum/CalculatorBlock.tsx create mode 100644 apps/web/src/components/curriculum/HeroBlock.tsx create mode 100644 apps/web/src/components/curriculum/ProgressBar.tsx create mode 100644 apps/web/src/components/curriculum/QuizBlock.tsx create mode 100644 apps/web/src/components/curriculum/TextBlock.tsx create mode 100644 apps/web/src/components/curriculum/TrackCard.tsx create mode 100644 apps/web/src/components/gamification/BadgeCard.tsx create mode 100644 apps/web/src/components/gamification/BadgeGrid.tsx create mode 100644 apps/web/src/components/gamification/LeaderboardTable.tsx create mode 100644 apps/web/src/components/gamification/StreakCounter.tsx create mode 100644 apps/web/src/components/gamification/XPBar.tsx create mode 100644 apps/web/src/components/layout/Breadcrumbs.tsx create mode 100644 apps/web/src/components/layout/MobileNav.tsx create mode 100644 apps/web/src/components/layout/Sidebar.tsx create mode 100644 apps/web/src/components/layout/TopBar.tsx create mode 100644 apps/web/src/components/shared/ConfirmDialog.tsx create mode 100644 apps/web/src/components/shared/EmptyState.tsx create mode 100644 apps/web/src/components/shared/ErrorBoundary.tsx create mode 100644 apps/web/src/components/shared/LanguageSwitcher.tsx create mode 100644 apps/web/src/components/shared/LoadingSpinner.tsx create mode 100644 apps/web/src/components/shared/ThemeToggle.tsx create mode 100644 apps/web/src/components/ui/avatar.tsx create mode 100644 apps/web/src/components/ui/badge.tsx create mode 100644 apps/web/src/components/ui/button.tsx create mode 100644 apps/web/src/components/ui/card.tsx create mode 100644 apps/web/src/components/ui/dialog.tsx create mode 100644 apps/web/src/components/ui/dropdown-menu.tsx create mode 100644 apps/web/src/components/ui/input.tsx create mode 100644 apps/web/src/components/ui/label.tsx create mode 100644 apps/web/src/components/ui/progress.tsx create mode 100644 apps/web/src/components/ui/separator.tsx create mode 100644 apps/web/src/components/ui/skeleton.tsx create mode 100644 apps/web/src/components/ui/tabs.tsx create mode 100644 apps/web/src/components/ui/toast.tsx create mode 100644 apps/web/src/components/ui/tooltip.tsx create mode 100644 apps/web/src/hooks/useAICoach.ts create mode 100644 apps/web/src/hooks/useAuth.ts create mode 100644 apps/web/src/hooks/useLocalizedContent.ts create mode 100644 apps/web/src/hooks/useMediaQuery.ts create mode 100644 apps/web/src/hooks/useSimulation.ts create mode 100644 apps/web/src/layouts/AuthenticatedLayout.tsx create mode 100644 apps/web/src/layouts/PublicLayout.tsx create mode 100644 apps/web/src/layouts/TeacherLayout.tsx create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/env.ts create mode 100644 apps/web/src/lib/graphql.ts create mode 100644 apps/web/src/lib/i18n.ts create mode 100644 apps/web/src/lib/socket.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/pages/DashboardPage.tsx create mode 100644 apps/web/src/pages/HomePage.tsx create mode 100644 apps/web/src/pages/LearnPage.tsx create mode 100644 apps/web/src/pages/LessonPage.tsx create mode 100644 apps/web/src/pages/ModulePage.tsx create mode 100644 apps/web/src/pages/NotFoundPage.tsx create mode 100644 apps/web/src/pages/PlayPage.tsx create mode 100644 apps/web/src/pages/PracticePage.tsx create mode 100644 apps/web/src/pages/ProfilePage.tsx create mode 100644 apps/web/src/pages/SettingsPage.tsx create mode 100644 apps/web/src/stores/auth.store.ts create mode 100644 apps/web/src/stores/gamification.store.ts create mode 100644 apps/web/src/stores/ui.store.ts create mode 100644 apps/web/src/styles/globals.css create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.portainer.yml create mode 100644 docker-compose.prod.yml create mode 100644 docker/nginx/nginx.conf create mode 100644 docker/traefik/dynamic.yml create mode 100644 docker/traefik/traefik.yml create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CONTEXT.md create mode 100644 docs/DEVELOPMENT-ROADMAP.md create mode 100644 docs/LOCALIZATION.md create mode 100644 package.json create mode 100644 packages/i18n/package.json create mode 100644 packages/i18n/src/index.ts create mode 100644 packages/i18n/src/locales/el/ai-coach.json create mode 100644 packages/i18n/src/locales/el/classroom.json create mode 100644 packages/i18n/src/locales/el/common.json create mode 100644 packages/i18n/src/locales/el/finance.json create mode 100644 packages/i18n/src/locales/el/gamification.json create mode 100644 packages/i18n/src/locales/en/ai-coach.json create mode 100644 packages/i18n/src/locales/en/classroom.json create mode 100644 packages/i18n/src/locales/en/common.json create mode 100644 packages/i18n/src/locales/en/finance.json create mode 100644 packages/i18n/src/locales/en/gamification.json create mode 100644 packages/i18n/tsconfig.json create mode 100644 packages/types/package.json create mode 100644 packages/types/src/ai.ts create mode 100644 packages/types/src/analytics.ts create mode 100644 packages/types/src/classroom.ts create mode 100644 packages/types/src/curriculum.ts create mode 100644 packages/types/src/gamification.ts create mode 100644 packages/types/src/index.ts create mode 100644 packages/types/src/simulation.ts create mode 100644 packages/types/src/tenant.ts create mode 100644 packages/types/src/user.ts create mode 100644 packages/types/tsconfig.json create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/lib/utils.ts create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/utils/package.json create mode 100644 packages/utils/src/constants.ts create mode 100644 packages/utils/src/env.ts create mode 100644 packages/utils/src/formatting.ts create mode 100644 packages/utils/src/index.ts create mode 100644 packages/utils/src/validation.ts create mode 100644 packages/utils/tsconfig.json create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/backup.sh create mode 100644 scripts/healthcheck.sh create mode 100644 scripts/setup.sh create mode 100644 tsconfig.base.json create mode 100644 turbo.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f62ad7b --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2d9ca13 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..19eef79 --- /dev/null +++ b/.env.production.example @@ -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 diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..32dd2cf --- /dev/null +++ b/.eslintrc.js @@ -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", + }, + }, + ], +}; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..90019e5 --- /dev/null +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5c51cb0 --- /dev/null +++ b/.github/dependabot.yml @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..562ee7e --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..6585c16 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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}` + }); diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..1cce046 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -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], + }); + } diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..d24fdfc --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx lint-staged diff --git a/.lintstagedrc.js b/.lintstagedrc.js new file mode 100644 index 0000000..0d9df2a --- /dev/null +++ b/.lintstagedrc.js @@ -0,0 +1,4 @@ +module.exports = { + "*.{ts,tsx}": ["prettier --write", "eslint --fix"], + "*.{js,jsx,json,md}": ["prettier --write"], +}; diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..93418b3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "endOfLine": "lf" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac17f0b --- /dev/null +++ b/README.md @@ -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). diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..075fc56 --- /dev/null +++ b/apps/api/Dockerfile @@ -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"] diff --git a/apps/api/Dockerfile.dev b/apps/api/Dockerfile.dev new file mode 100644 index 0000000..7eee8ba --- /dev/null +++ b/apps/api/Dockerfile.dev @@ -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"] diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..0b6db5c --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/prisma/migrations/.gitkeep b/apps/api/prisma/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..c376f58 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -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") +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..3ec1908 --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -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[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[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[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[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[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; + 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(); + }); diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts new file mode 100644 index 0000000..5c36556 --- /dev/null +++ b/apps/api/src/app.controller.ts @@ -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", + }); + } + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..cc17f60 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -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({ + 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 {} diff --git a/apps/api/src/common/decorators/current-tenant.decorator.ts b/apps/api/src/common/decorators/current-tenant.decorator.ts new file mode 100644 index 0000000..98a7f91 --- /dev/null +++ b/apps/api/src/common/decorators/current-tenant.decorator.ts @@ -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; + }, +); diff --git a/apps/api/src/common/decorators/current-user.decorator.ts b/apps/api/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..395374a --- /dev/null +++ b/apps/api/src/common/decorators/current-user.decorator.ts @@ -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; + }, +); diff --git a/apps/api/src/common/decorators/index.ts b/apps/api/src/common/decorators/index.ts new file mode 100644 index 0000000..9506415 --- /dev/null +++ b/apps/api/src/common/decorators/index.ts @@ -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"; diff --git a/apps/api/src/common/decorators/public.decorator.ts b/apps/api/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..7beff6f --- /dev/null +++ b/apps/api/src/common/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from "@nestjs/common"; + +export const IS_PUBLIC_KEY = "isPublic"; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/api/src/common/decorators/roles.decorator.ts b/apps/api/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..bc3281a --- /dev/null +++ b/apps/api/src/common/decorators/roles.decorator.ts @@ -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); diff --git a/apps/api/src/common/filters/http-exception.filter.ts b/apps/api/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..900f88d --- /dev/null +++ b/apps/api/src/common/filters/http-exception.filter.ts @@ -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).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(); + const request = ctx.getRequest(); + + 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; + 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 = { + [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"; + } +} diff --git a/apps/api/src/common/guards/gql-jwt.guard.ts b/apps/api/src/common/guards/gql-jwt.guard.ts new file mode 100644 index 0000000..86bf870 --- /dev/null +++ b/apps/api/src/common/guards/gql-jwt.guard.ts @@ -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(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; + } +} diff --git a/apps/api/src/common/guards/gql-roles.guard.ts b/apps/api/src/common/guards/gql-roles.guard.ts new file mode 100644 index 0000000..bfd9025 --- /dev/null +++ b/apps/api/src/common/guards/gql-roles.guard.ts @@ -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(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; + } +} diff --git a/apps/api/src/common/guards/index.ts b/apps/api/src/common/guards/index.ts new file mode 100644 index 0000000..b7e2182 --- /dev/null +++ b/apps/api/src/common/guards/index.ts @@ -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"; diff --git a/apps/api/src/common/guards/jwt-auth.guard.ts b/apps/api/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..00570b5 --- /dev/null +++ b/apps/api/src/common/guards/jwt-auth.guard.ts @@ -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(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; + } +} diff --git a/apps/api/src/common/guards/roles.guard.ts b/apps/api/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..ae82734 --- /dev/null +++ b/apps/api/src/common/guards/roles.guard.ts @@ -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(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; + } +} diff --git a/apps/api/src/common/guards/tenant.guard.ts b/apps/api/src/common/guards/tenant.guard.ts new file mode 100644 index 0000000..2f6e432 --- /dev/null +++ b/apps/api/src/common/guards/tenant.guard.ts @@ -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(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; + } +} diff --git a/apps/api/src/common/interceptors/logging.interceptor.ts b/apps/api/src/common/interceptors/logging.interceptor.ts new file mode 100644 index 0000000..313521a --- /dev/null +++ b/apps/api/src/common/interceptors/logging.interceptor.ts @@ -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 { + 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(); + const { method, url } = httpRequest; + const startTime = Date.now(); + + return next.handle().pipe( + tap({ + next: () => { + const httpResponse = context.switchToHttp().getResponse(); + 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}`); + }, + }), + ); + } +} diff --git a/apps/api/src/common/interceptors/transform.interceptor.ts b/apps/api/src/common/interceptors/transform.interceptor.ts new file mode 100644 index 0000000..fdadb07 --- /dev/null +++ b/apps/api/src/common/interceptors/transform.interceptor.ts @@ -0,0 +1,32 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from "@nestjs/common"; +import { Observable } from "rxjs"; +import { map } from "rxjs/operators"; + +export interface WrappedResponse { + data: T; + meta: { + timestamp: string; + }; +} + +@Injectable() +export class TransformInterceptor implements NestInterceptor> { + intercept( + _context: ExecutionContext, + next: CallHandler, + ): Observable> { + return next.handle().pipe( + map((data) => ({ + data, + meta: { + timestamp: new Date().toISOString(), + }, + })), + ); + } +} diff --git a/apps/api/src/common/prisma/prisma.module.ts b/apps/api/src/common/prisma/prisma.module.ts new file mode 100644 index 0000000..d7294c4 --- /dev/null +++ b/apps/api/src/common/prisma/prisma.module.ts @@ -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 {} diff --git a/apps/api/src/common/prisma/prisma.service.ts b/apps/api/src/common/prisma/prisma.service.ts new file mode 100644 index 0000000..465a7b8 --- /dev/null +++ b/apps/api/src/common/prisma/prisma.service.ts @@ -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 { + try { + await this.$queryRaw`SELECT 1`; + return true; + } catch { + return false; + } + } + + getTenantSchema(tenantId: string): string { + return `tenant_${tenantId.replace(/-/g, "_")}`; + } + + async withTenant(tenantId: string, fn: (prisma: PrismaClient) => Promise): Promise { + 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); + }); + } +} diff --git a/apps/api/src/common/queue/queue.module.ts b/apps/api/src/common/queue/queue.module.ts new file mode 100644 index 0000000..7357bcb --- /dev/null +++ b/apps/api/src/common/queue/queue.module.ts @@ -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 {} diff --git a/apps/api/src/common/queue/queue.service.ts b/apps/api/src/common/queue/queue.service.ts new file mode 100644 index 0000000..9f9aa83 --- /dev/null +++ b/apps/api/src/common/queue/queue.service.ts @@ -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(type: JobType, data: T, delayMs?: number): Promise { + 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(jobs: { type: JobType; data: T; delayMs?: number }[]): Promise { + const grouped = new Map(); + + 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> { + 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 { + 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 { + await Promise.all([ + this.defaultQueue.pause(), + this.emailQueue.pause(), + this.analyticsQueue.pause(), + ]); + } + + async resumeAll(): Promise { + 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; + } + } +} diff --git a/apps/api/src/common/redis/redis.module.ts b/apps/api/src/common/redis/redis.module.ts new file mode 100644 index 0000000..44ba292 --- /dev/null +++ b/apps/api/src/common/redis/redis.module.ts @@ -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 {} diff --git a/apps/api/src/common/redis/redis.service.ts b/apps/api/src/common/redis/redis.service.ts new file mode 100644 index 0000000..1acc85b --- /dev/null +++ b/apps/api/src/common/redis/redis.service.ts @@ -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 { + try { + const result = await this.client.ping(); + return result === "PONG"; + } catch { + return false; + } + } + + async quit(): Promise { + try { + await this.client.quit(); + await this.subscriber.quit(); + } catch (error) { + this.logger.error("Error closing Redis connections", error); + } + } + + async get(key: string): Promise { + 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 { + 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 { + await this.client.del(key); + } + + async exists(key: string): Promise { + const result = await this.client.exists(key); + return result === 1; + } + + async ttl(key: string): Promise { + return this.client.ttl(key); + } + + async incr(key: string): Promise { + return this.client.incr(key); + } + + async expire(key: string, seconds: number): Promise { + await this.client.expire(key, seconds); + } + + async publish(channel: string, message: unknown): Promise { + const serialized = typeof message === "string" ? message : JSON.stringify(message); + await this.client.publish(channel, serialized); + } + + async subscribe(channel: string, callback: (message: string) => void): Promise { + await this.subscriber.subscribe(channel); + this.subscriber.on("message", (ch: string, message: string) => { + if (ch === channel) { + callback(message); + } + }); + } + + async unsubscribe(channel: string): Promise { + await this.subscriber.unsubscribe(channel); + } + + getClient(): Redis { + return this.client; + } + + getSubscriber(): Redis { + return this.subscriber; + } +} diff --git a/apps/api/src/common/types/context.ts b/apps/api/src/common/types/context.ts new file mode 100644 index 0000000..86e272a --- /dev/null +++ b/apps/api/src/common/types/context.ts @@ -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; +} diff --git a/apps/api/src/common/types/pagination.ts b/apps/api/src/common/types/pagination.ts new file mode 100644 index 0000000..7f5ec1e --- /dev/null +++ b/apps/api/src/common/types/pagination.ts @@ -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 { + items: T[]; + pageInfo: PageInfo; +} diff --git a/apps/api/src/config/env.config.ts b/apps/api/src/config/env.config.ts new file mode 100644 index 0000000..95b8cde --- /dev/null +++ b/apps/api/src/config/env.config.ts @@ -0,0 +1,144 @@ +import { validateEnv, type EnvConfig } from "./env.validation.js"; +import { parseEnvArray } from "@investplay/utils/env.js"; + +let _config: Readonly | null = null; + +/** + * Return the validated, frozen environment config. + * Validates on first call and caches the result. + */ +export function getEnvConfig(): Readonly { + if (!_config) { + _config = Object.freeze(validateEnv()); + } + return _config; +} + +// ─── Convenience accessors (grouped by domain) ────────────────────────────── + +export const env = (): Readonly => 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, + }; +}; diff --git a/apps/api/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts new file mode 100644 index 0000000..b7d61e1 --- /dev/null +++ b/apps/api/src/config/env.validation.ts @@ -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; + +/** + * 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 = 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; +} diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts new file mode 100644 index 0000000..dbac7ba --- /dev/null +++ b/apps/api/src/config/index.ts @@ -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"; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..aaaf91f --- /dev/null +++ b/apps/api/src/main.ts @@ -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); +}); diff --git a/apps/api/src/modules/ai-coach/ai-coach.gateway.ts b/apps/api/src/modules/ai-coach/ai-coach.gateway.ts new file mode 100644 index 0000000..d0e8c60 --- /dev/null +++ b/apps/api/src/modules/ai-coach/ai-coach.gateway.ts @@ -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(); + + @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 { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/apps/api/src/modules/ai-coach/ai-coach.module.ts b/apps/api/src/modules/ai-coach/ai-coach.module.ts new file mode 100644 index 0000000..a40d588 --- /dev/null +++ b/apps/api/src/modules/ai-coach/ai-coach.module.ts @@ -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 {} diff --git a/apps/api/src/modules/ai-coach/ai-coach.resolver.ts b/apps/api/src/modules/ai-coach/ai-coach.resolver.ts new file mode 100644 index 0000000..47be9e0 --- /dev/null +++ b/apps/api/src/modules/ai-coach/ai-coach.resolver.ts @@ -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); + } +} diff --git a/apps/api/src/modules/ai-coach/ai-coach.service.ts b/apps/api/src/modules/ai-coach/ai-coach.service.ts new file mode 100644 index 0000000..ef72edc --- /dev/null +++ b/apps/api/src/modules/ai-coach/ai-coach.service.ts @@ -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 { + 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(`ai:daily:${userId}:${today}`); + const used = usedStr ? Number.parseInt(usedStr, 10) : 0; + + return { + used, + limit: ai().defaultDailyLimit, + }; + } + + async checkQuota(userId: string): Promise { + const usage = await this.getDailyUsage(userId); + return usage.used < usage.limit; + } + + async getTokenBudget(tenantId: string): Promise { + 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?"; + } +} diff --git a/apps/api/src/modules/ai-coach/dto/ai-message.input.ts b/apps/api/src/modules/ai-coach/dto/ai-message.input.ts new file mode 100644 index 0000000..58278d6 --- /dev/null +++ b/apps/api/src/modules/ai-coach/dto/ai-message.input.ts @@ -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; +} diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts new file mode 100644 index 0000000..c58b578 --- /dev/null +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -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 {} diff --git a/apps/api/src/modules/analytics/analytics.resolver.ts b/apps/api/src/modules/analytics/analytics.resolver.ts new file mode 100644 index 0000000..daf90fe --- /dev/null +++ b/apps/api/src/modules/analytics/analytics.resolver.ts @@ -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, + ) { + 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, + }); + } +} diff --git a/apps/api/src/modules/analytics/analytics.service.ts b/apps/api/src/modules/analytics/analytics.service.ts new file mode 100644 index 0000000..2c9895f --- /dev/null +++ b/apps/api/src/modules/analytics/analytics.service.ts @@ -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, + ): Promise { + 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; + 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(), + }; + } +} diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..0c38ea9 --- /dev/null +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -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, + ) { + 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); + } +} diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..8a8f2fd --- /dev/null +++ b/apps/api/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/src/modules/auth/auth.resolver.ts b/apps/api/src/modules/auth/auth.resolver.ts new file mode 100644 index 0000000..dabff74 --- /dev/null +++ b/apps/api/src/modules/auth/auth.resolver.ts @@ -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; + } +} diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..c9e25c1 --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.ts @@ -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) { + 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, + }; + } +} diff --git a/apps/api/src/modules/auth/dto/login.input.ts b/apps/api/src/modules/auth/dto/login.input.ts new file mode 100644 index 0000000..af67a39 --- /dev/null +++ b/apps/api/src/modules/auth/dto/login.input.ts @@ -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; +} diff --git a/apps/api/src/modules/auth/dto/register.input.ts b/apps/api/src/modules/auth/dto/register.input.ts new file mode 100644 index 0000000..17acc4a --- /dev/null +++ b/apps/api/src/modules/auth/dto/register.input.ts @@ -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; +} diff --git a/apps/api/src/modules/auth/dto/token-response.dto.ts b/apps/api/src/modules/auth/dto/token-response.dto.ts new file mode 100644 index 0000000..30c98a8 --- /dev/null +++ b/apps/api/src/modules/auth/dto/token-response.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/auth/entities/user.entity.ts b/apps/api/src/modules/auth/entities/user.entity.ts new file mode 100644 index 0000000..890cf8f --- /dev/null +++ b/apps/api/src/modules/auth/entities/user.entity.ts @@ -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; +} diff --git a/apps/api/src/modules/auth/jwt-refresh.strategy.ts b/apps/api/src/modules/auth/jwt-refresh.strategy.ts new file mode 100644 index 0000000..9d203dc --- /dev/null +++ b/apps/api/src/modules/auth/jwt-refresh.strategy.ts @@ -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, + }; + } +} diff --git a/apps/api/src/modules/auth/jwt.strategy.ts b/apps/api/src/modules/auth/jwt.strategy.ts new file mode 100644 index 0000000..ed3ac85 --- /dev/null +++ b/apps/api/src/modules/auth/jwt.strategy.ts @@ -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, + }; + } +} diff --git a/apps/api/src/modules/classroom/classroom.module.ts b/apps/api/src/modules/classroom/classroom.module.ts new file mode 100644 index 0000000..6854d01 --- /dev/null +++ b/apps/api/src/modules/classroom/classroom.module.ts @@ -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 {} diff --git a/apps/api/src/modules/classroom/classroom.resolver.ts b/apps/api/src/modules/classroom/classroom.resolver.ts new file mode 100644 index 0000000..5f0f466 --- /dev/null +++ b/apps/api/src/modules/classroom/classroom.resolver.ts @@ -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); + } +} diff --git a/apps/api/src/modules/classroom/classroom.service.ts b/apps/api/src/modules/classroom/classroom.service.ts new file mode 100644 index 0000000..b01c6db --- /dev/null +++ b/apps/api/src/modules/classroom/classroom.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/api/src/modules/curriculum/curriculum.module.ts b/apps/api/src/modules/curriculum/curriculum.module.ts new file mode 100644 index 0000000..0059128 --- /dev/null +++ b/apps/api/src/modules/curriculum/curriculum.module.ts @@ -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 {} diff --git a/apps/api/src/modules/curriculum/curriculum.resolver.ts b/apps/api/src/modules/curriculum/curriculum.resolver.ts new file mode 100644 index 0000000..bdbe2fa --- /dev/null +++ b/apps/api/src/modules/curriculum/curriculum.resolver.ts @@ -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); + } +} diff --git a/apps/api/src/modules/curriculum/curriculum.service.ts b/apps/api/src/modules/curriculum/curriculum.service.ts new file mode 100644 index 0000000..294a08c --- /dev/null +++ b/apps/api/src/modules/curriculum/curriculum.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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(), + }, + ]; + } +} diff --git a/apps/api/src/modules/curriculum/dto/submit-quiz.input.ts b/apps/api/src/modules/curriculum/dto/submit-quiz.input.ts new file mode 100644 index 0000000..e6ea5b9 --- /dev/null +++ b/apps/api/src/modules/curriculum/dto/submit-quiz.input.ts @@ -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; +} diff --git a/apps/api/src/modules/curriculum/entities/lesson.entity.ts b/apps/api/src/modules/curriculum/entities/lesson.entity.ts new file mode 100644 index 0000000..e4dc382 --- /dev/null +++ b/apps/api/src/modules/curriculum/entities/lesson.entity.ts @@ -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; + + @Field(() => Object, { nullable: true }) + config: Record | 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; +} diff --git a/apps/api/src/modules/curriculum/entities/module.entity.ts b/apps/api/src/modules/curriculum/entities/module.entity.ts new file mode 100644 index 0000000..e0ee0c0 --- /dev/null +++ b/apps/api/src/modules/curriculum/entities/module.entity.ts @@ -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; +} diff --git a/apps/api/src/modules/gamification/gamification.module.ts b/apps/api/src/modules/gamification/gamification.module.ts new file mode 100644 index 0000000..f77a094 --- /dev/null +++ b/apps/api/src/modules/gamification/gamification.module.ts @@ -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 {} diff --git a/apps/api/src/modules/gamification/gamification.resolver.ts b/apps/api/src/modules/gamification/gamification.resolver.ts new file mode 100644 index 0000000..3cc2207 --- /dev/null +++ b/apps/api/src/modules/gamification/gamification.resolver.ts @@ -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); + } +} diff --git a/apps/api/src/modules/gamification/gamification.service.ts b/apps/api/src/modules/gamification/gamification.service.ts new file mode 100644 index 0000000..20c2815 --- /dev/null +++ b/apps/api/src/modules/gamification/gamification.service.ts @@ -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 { + 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(`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 { + 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 { + 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; + } +} diff --git a/apps/api/src/modules/portfolio/portfolio.module.ts b/apps/api/src/modules/portfolio/portfolio.module.ts new file mode 100644 index 0000000..cf34d73 --- /dev/null +++ b/apps/api/src/modules/portfolio/portfolio.module.ts @@ -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 {} diff --git a/apps/api/src/modules/portfolio/portfolio.resolver.ts b/apps/api/src/modules/portfolio/portfolio.resolver.ts new file mode 100644 index 0000000..e016b02 --- /dev/null +++ b/apps/api/src/modules/portfolio/portfolio.resolver.ts @@ -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); + } +} diff --git a/apps/api/src/modules/portfolio/portfolio.service.ts b/apps/api/src/modules/portfolio/portfolio.service.ts new file mode 100644 index 0000000..2206fd7 --- /dev/null +++ b/apps/api/src/modules/portfolio/portfolio.service.ts @@ -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 { + 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 { + 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, + }; + } +} diff --git a/apps/api/src/modules/simulation/dto/create-session.input.ts b/apps/api/src/modules/simulation/dto/create-session.input.ts new file mode 100644 index 0000000..c034e6a --- /dev/null +++ b/apps/api/src/modules/simulation/dto/create-session.input.ts @@ -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; +} diff --git a/apps/api/src/modules/simulation/dto/execute-trade.input.ts b/apps/api/src/modules/simulation/dto/execute-trade.input.ts new file mode 100644 index 0000000..02a9b56 --- /dev/null +++ b/apps/api/src/modules/simulation/dto/execute-trade.input.ts @@ -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; +} diff --git a/apps/api/src/modules/simulation/simulation.gateway.ts b/apps/api/src/modules/simulation/simulation.gateway.ts new file mode 100644 index 0000000..726c940 --- /dev/null +++ b/apps/api/src/modules/simulation/simulation.gateway.ts @@ -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(); + + @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 }) { + 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}`); + } + } +} diff --git a/apps/api/src/modules/simulation/simulation.module.ts b/apps/api/src/modules/simulation/simulation.module.ts new file mode 100644 index 0000000..2b550bf --- /dev/null +++ b/apps/api/src/modules/simulation/simulation.module.ts @@ -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 {} diff --git a/apps/api/src/modules/simulation/simulation.resolver.ts b/apps/api/src/modules/simulation/simulation.resolver.ts new file mode 100644 index 0000000..f216ba3 --- /dev/null +++ b/apps/api/src/modules/simulation/simulation.resolver.ts @@ -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); + } +} diff --git a/apps/api/src/modules/simulation/simulation.service.ts b/apps/api/src/modules/simulation/simulation.service.ts new file mode 100644 index 0000000..4763aa1 --- /dev/null +++ b/apps/api/src/modules/simulation/simulation.service.ts @@ -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 { + 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 { + const cached = await this.redis.get(`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(`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 { + const cached = await this.redis.get(`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; + } +} diff --git a/apps/api/src/modules/tenant/dto/create-tenant.input.ts b/apps/api/src/modules/tenant/dto/create-tenant.input.ts new file mode 100644 index 0000000..d460219 --- /dev/null +++ b/apps/api/src/modules/tenant/dto/create-tenant.input.ts @@ -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; +} diff --git a/apps/api/src/modules/tenant/dto/update-tenant.input.ts b/apps/api/src/modules/tenant/dto/update-tenant.input.ts new file mode 100644 index 0000000..456857f --- /dev/null +++ b/apps/api/src/modules/tenant/dto/update-tenant.input.ts @@ -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; +} diff --git a/apps/api/src/modules/tenant/entities/tenant.entity.ts b/apps/api/src/modules/tenant/entities/tenant.entity.ts new file mode 100644 index 0000000..1e13916 --- /dev/null +++ b/apps/api/src/modules/tenant/entities/tenant.entity.ts @@ -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; +} diff --git a/apps/api/src/modules/tenant/tenant-context.service.ts b/apps/api/src/modules/tenant/tenant-context.service.ts new file mode 100644 index 0000000..e5f7759 --- /dev/null +++ b/apps/api/src/modules/tenant/tenant-context.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from "@nestjs/common"; +import { AsyncLocalStorage } from "node:async_hooks"; + +@Injectable() +export class TenantContextService { + private readonly storage = new AsyncLocalStorage(); + + run(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); + } +} diff --git a/apps/api/src/modules/tenant/tenant.middleware.ts b/apps/api/src/modules/tenant/tenant.middleware.ts new file mode 100644 index 0000000..2988c89 --- /dev/null +++ b/apps/api/src/modules/tenant/tenant.middleware.ts @@ -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; + } +} diff --git a/apps/api/src/modules/tenant/tenant.module.ts b/apps/api/src/modules/tenant/tenant.module.ts new file mode 100644 index 0000000..783894b --- /dev/null +++ b/apps/api/src/modules/tenant/tenant.module.ts @@ -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("*"); + } +} diff --git a/apps/api/src/modules/tenant/tenant.resolver.ts b/apps/api/src/modules/tenant/tenant.resolver.ts new file mode 100644 index 0000000..f36ae2b --- /dev/null +++ b/apps/api/src/modules/tenant/tenant.resolver.ts @@ -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); + } +} diff --git a/apps/api/src/modules/tenant/tenant.service.ts b/apps/api/src/modules/tenant/tenant.service.ts new file mode 100644 index 0000000..5baf49a --- /dev/null +++ b/apps/api/src/modules/tenant/tenant.service.ts @@ -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>(`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 = {}; + 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) ?? {}; + 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).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), + }; + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..95dddf3 --- /dev/null +++ b/apps/api/tsconfig.json @@ -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"] +} diff --git a/apps/cms/Dockerfile b/apps/cms/Dockerfile new file mode 100644 index 0000000..f190648 --- /dev/null +++ b/apps/cms/Dockerfile @@ -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"] diff --git a/apps/cms/Dockerfile.dev b/apps/cms/Dockerfile.dev new file mode 100644 index 0000000..571f7f2 --- /dev/null +++ b/apps/cms/Dockerfile.dev @@ -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"] diff --git a/apps/cms/package.json b/apps/cms/package.json new file mode 100644 index 0000000..c4c545c --- /dev/null +++ b/apps/cms/package.json @@ -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" + } +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..00aa5d9 --- /dev/null +++ b/apps/web/Dockerfile @@ -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;"] diff --git a/apps/web/Dockerfile.dev b/apps/web/Dockerfile.dev new file mode 100644 index 0000000..c2aae8c --- /dev/null +++ b/apps/web/Dockerfile.dev @@ -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/web/package.json apps/web/tsconfig.json apps/web/vite.config.ts ./apps/web/ +COPY packages/ ./packages/ +RUN pnpm install --frozen-lockfile +EXPOSE 5173 +CMD ["pnpm", "--filter", "@investplay/web", "dev", "--host"] diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..e58fc5b --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + InvestPlay + + +
+ + + diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf new file mode 100644 index 0000000..9fe23bc --- /dev/null +++ b/apps/web/nginx.conf @@ -0,0 +1,125 @@ +worker_processes auto; +worker_rlimit_nofile 65535; + +events { + multi_accept on; + worker_connections 65535; + use epoll; +} + +http { + charset utf-8; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + server_tokens off; + log_not_found off; + types_hash_max_size 2048; + client_max_body_size 100M; + + # MIME types + include mime.types; + default_type application/octet-stream; + + # Logging + access_log /var/log/nginx/access.log combined buffer=512k flush=1m; + error_log /var/log/nginx/error.log warn; + + # Timeouts + keepalive_timeout 65; + keepalive_requests 100; + client_body_timeout 60; + client_header_timeout 60; + send_timeout 60; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types + application/javascript + application/json + application/manifest+json + application/xml + image/svg+xml + text/css + text/javascript + text/plain + text/xml; + + # Cache static assets with hashed filenames + location ~* \.(?:css|js|map|json)$ { + root /usr/share/nginx/html; + expires 1y; + access_log off; + add_header Cache-Control "public, immutable, max-age=31536000"; + } + + # Cache images, fonts, media + location ~* \.(?:gif|jpe?g|png|webp|avif|ico|svg|woff2?|eot|ttf|otf)$ { + root /usr/share/nginx/html; + expires 1y; + access_log off; + add_header Cache-Control "public, immutable, max-age=31536000"; + } + + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + + # API proxy + location /api/ { + proxy_pass http://api:3001/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 90; + proxy_buffering off; + } + + location /graphql { + proxy_pass http://api:3001/graphql; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 90; + proxy_buffering off; + } + + # SPA fallback + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + # Deny access to hidden files + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + # Deny access to sensitive paths + location ~ ^/(\.env|\.git|node_modules|dist) { + deny all; + access_log off; + log_not_found off; + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..29788c4 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,65 @@ +{ + "name": "@investplay/web", + "version": "0.1.0", + "private": true, + "description": "InvestPlay React frontend", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "lint": "eslint \"src/**/*.{ts,tsx}\" --fix", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "clean": "rimraf dist .turbo" + }, + "dependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^7.5.0", + "react-i18next": "^15.4.0", + "i18next": "^24.2.0", + "zustand": "^5.0.0", + "@tanstack/react-query": "^5.75.0", + "socket.io-client": "^4.8.0", + "recharts": "^2.15.0", + "zod": "^3.24.0", + "lucide-react": "^0.490.0", + "clsx": "^2.1.0", + "tailwind-merge": "^3.2.0", + "class-variance-authority": "^0.7.1", + "@radix-ui/react-dialog": "^1.1.0", + "@radix-ui/react-dropdown-menu": "^2.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.0", + "@radix-ui/react-popover": "^1.1.0", + "@radix-ui/react-select": "^2.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-separator": "^1.1.0" + }, + "devDependencies": { + "vite": "^6.3.0", + "@vitejs/plugin-react": "^4.4.0", + "tailwindcss": "^4.1.0", + "@tailwindcss/vite": "^4.1.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.5.0", + "vitest": "^3.1.0", + "@testing-library/react": "^16.3.0", + "@testing-library/jest-dom": "^6.6.0", + "@playwright/test": "^1.52.0", + "typescript": "^5.8.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.2.0", + "rimraf": "^6.0.0" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..f69c5d4 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..6112837 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,80 @@ +import React, { Suspense } from "react"; +import { Routes, Route } from "react-router-dom"; +import { I18nextProvider } from "react-i18next"; +import i18nInstance from "@/lib/i18n"; +import { useUIStore } from "@/stores/ui.store"; +import { PublicLayout } from "@/layouts/PublicLayout"; +import { AuthenticatedLayout } from "@/layouts/AuthenticatedLayout"; +import { TeacherLayout } from "@/layouts/TeacherLayout"; +import { LoadingSpinner } from "@/components/shared/LoadingSpinner"; +import { ErrorBoundary } from "@/components/shared/ErrorBoundary"; + +const HomePage = React.lazy(() => import("@/pages/HomePage").then((m) => ({ default: m.HomePage }))); +const DashboardPage = React.lazy(() => import("@/pages/DashboardPage").then((m) => ({ default: m.DashboardPage }))); +const LearnPage = React.lazy(() => import("@/pages/LearnPage").then((m) => ({ default: m.LearnPage }))); +const ModulePage = React.lazy(() => import("@/pages/ModulePage").then((m) => ({ default: m.ModulePage }))); +const LessonPage = React.lazy(() => import("@/pages/LessonPage").then((m) => ({ default: m.LessonPage }))); +const PlayPage = React.lazy(() => import("@/pages/PlayPage").then((m) => ({ default: m.PlayPage }))); +const ChallengePage = React.lazy(() => import("@/pages/PlayPage").then((m) => ({ default: m.PlayPage }))); +const PracticePage = React.lazy(() => import("@/pages/PracticePage").then((m) => ({ default: m.PracticePage }))); +const PortfolioPage = React.lazy(() => import("@/pages/PracticePage").then((m) => ({ default: m.PracticePage }))); +const SettingsPage = React.lazy(() => import("@/pages/SettingsPage").then((m) => ({ default: m.SettingsPage }))); +const ProfilePage = React.lazy(() => import("@/pages/ProfilePage").then((m) => ({ default: m.ProfilePage }))); +const NotFoundPage = React.lazy(() => import("@/pages/NotFoundPage").then((m) => ({ default: m.NotFoundPage }))); + +function PageLoader() { + return ( +
+ +
+ ); +} + +function AppRoutes() { + return ( + + }> + + }> + } /> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + }> + Teacher overview} /> + Overview} /> + Classrooms} /> + Assignments} /> + Reports} /> + } /> + + } /> + } /> + + } /> + + + + ); +} + +export default function App() { + const { locale } = useUIStore(); + + if (i18nInstance.language !== locale) { + void i18nInstance.changeLanguage(locale); + } + + return ( + + + + ); +} diff --git a/apps/web/src/components/curriculum/BlockRenderer.tsx b/apps/web/src/components/curriculum/BlockRenderer.tsx new file mode 100644 index 0000000..a54cbb2 --- /dev/null +++ b/apps/web/src/components/curriculum/BlockRenderer.tsx @@ -0,0 +1,63 @@ +import { BlockType, type LessonBlock } from "@investplay/types"; +import { HeroBlock } from "./HeroBlock"; +import { TextBlock } from "./TextBlock"; +import { QuizBlock } from "./QuizBlock"; +import { CalculatorBlock } from "./CalculatorBlock"; + +interface BlockRendererProps { + block: LessonBlock; + onQuizAnswer?: (blockId: string, answer: string | string[]) => void; + quizResults?: Record; +} + +export function BlockRenderer({ block, onQuizAnswer, quizResults }: BlockRendererProps) { + switch (block.type) { + case BlockType.HERO: + return ; + + case BlockType.TEXT: + case BlockType.SUMMARY: + return ; + + case BlockType.QUIZ: + return ( + + ); + + case BlockType.CALCULATOR: + return ; + + case BlockType.REFLECTION: + return ( +
+

Reflection

+

+ {block.content.prompt as string ?? "Take a moment to reflect on what you've learned."} +

+
+ ); + + case BlockType.TEACHER_NOTE: + return ( +
+

+ {block.content.note as string ?? "Teacher note"} +

+
+ ); + + default: + return ( +
+

+ Unsupported block type: {block.type} +

+
+ ); + } +} diff --git a/apps/web/src/components/curriculum/CalculatorBlock.tsx b/apps/web/src/components/curriculum/CalculatorBlock.tsx new file mode 100644 index 0000000..734afdb --- /dev/null +++ b/apps/web/src/components/curriculum/CalculatorBlock.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { formatCurrency } from "@investplay/ui"; + +interface CalculatorBlockProps { + content: Record; +} + +type CalcType = "compound" | "loan" | "savings"; + +export function CalculatorBlock({ content }: CalculatorBlockProps) { + const { t } = useTranslation(); + const calcType = (content.type as CalcType) ?? "compound"; + const [principal, setPrincipal] = useState("10000"); + const [rate, setRate] = useState("5"); + const [years, setYears] = useState("10"); + const [result, setResult] = useState(null); + + const calculate = () => { + const p = parseFloat(principal); + const r = parseFloat(rate) / 100; + const n = parseFloat(years); + + if (isNaN(p) || isNaN(r) || isNaN(n)) return; + + let res = 0; + switch (calcType) { + case "compound": + res = p * Math.pow(1 + r, n); + break; + case "loan": { + const monthlyRate = r / 12; + const payments = n * 12; + res = (p * monthlyRate * Math.pow(1 + monthlyRate, payments)) / + (Math.pow(1 + monthlyRate, payments) - 1); + break; + } + case "savings": + res = p * r * n + p; + break; + } + setResult(Math.round(res * 100) / 100); + }; + + return ( +
+

+ {content.title as string ?? "Financial Calculator"} +

+ +
+ setPrincipal(e.target.value)} + /> + setRate(e.target.value)} + /> + setYears(e.target.value)} + /> +
+ + + + {result !== null && ( +
+

Result

+

+ {formatCurrency(result)} +

+
+ )} +
+ ); +} diff --git a/apps/web/src/components/curriculum/HeroBlock.tsx b/apps/web/src/components/curriculum/HeroBlock.tsx new file mode 100644 index 0000000..2d174ca --- /dev/null +++ b/apps/web/src/components/curriculum/HeroBlock.tsx @@ -0,0 +1,33 @@ +interface HeroBlockProps { + content: Record; +} + +export function HeroBlock({ content }: HeroBlockProps) { + const title = content.title as string | undefined; + const description = content.description as string | undefined; + const imageUrl = content.imageUrl as string | undefined; + + return ( +
+
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ {imageUrl && ( + {title + )} +
+ ); +} diff --git a/apps/web/src/components/curriculum/ProgressBar.tsx b/apps/web/src/components/curriculum/ProgressBar.tsx new file mode 100644 index 0000000..85db25b --- /dev/null +++ b/apps/web/src/components/curriculum/ProgressBar.tsx @@ -0,0 +1,62 @@ +import { cn } from "@investplay/ui"; + +interface ProgressBarProps { + value: number; + max?: number; + showLabel?: boolean; + size?: "sm" | "md" | "lg"; + className?: string; + variant?: "default" | "accent" | "success"; +} + +const sizeMap = { + sm: "h-1.5", + md: "h-2.5", + lg: "h-4", +}; + +const variantMap = { + default: "bg-primary", + accent: "bg-accent", + success: "bg-emerald-500", +}; + +export function ProgressBar({ + value, + max = 100, + showLabel = false, + size = "md", + className, + variant = "default", +}: ProgressBarProps) { + const percentage = Math.min(Math.max((value / max) * 100, 0), 100); + + return ( +
+
+
+
+ {showLabel && ( + + {Math.round(percentage)}% + + )} +
+ ); +} diff --git a/apps/web/src/components/curriculum/QuizBlock.tsx b/apps/web/src/components/curriculum/QuizBlock.tsx new file mode 100644 index 0000000..dddc56b --- /dev/null +++ b/apps/web/src/components/curriculum/QuizBlock.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@investplay/ui"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { CheckCircle, XCircle } from "lucide-react"; + +interface QuizBlockProps { + content: Record; + blockId: string; + result?: { correct: boolean; answer: string | string[] }; + onAnswer?: (blockId: string, answer: string | string[]) => void; +} + +interface QuestionData { + question: string; + options: string[]; + correctAnswer: string | string[]; + type: "multiple-choice" | "true-false"; + explanation?: string; +} + +export function QuizBlock({ content, blockId, result, onAnswer }: QuizBlockProps) { + const { t } = useTranslation(); + const [selected, setSelected] = useState(null); + const [submitted, setSubmitted] = useState(false); + + const question = content.question as string; + const options = content.options as string[] | undefined; + const type = (content.type as string) ?? "multiple-choice"; + const explanation = content.explanation as string | undefined; + + const isCorrect = result?.correct; + const isAnswered = !!result || submitted; + + const handleSubmit = () => { + if (!selected || !onAnswer) return; + onAnswer(blockId, selected); + setSubmitted(true); + }; + + const isCorrectAnswer = (option: string) => { + if (!result) return false; + const correct = result.answer; + return Array.isArray(correct) ? correct.includes(option) : correct === option; + }; + + return ( +
+
+ + {type === "true-false" ? "True / False" : t("actions.submit")} + + {isAnswered && ( + + {isCorrect ? t("actions.confirm") : t("actions.cancel")} + + )} +
+ +

{question}

+ +
+ {options?.map((option) => { + const isSelected = selected === option; + const isOptionCorrect = isCorrectAnswer(option); + + return ( + + ); + })} +
+ + {!isAnswered && ( + + )} + + {isAnswered && explanation && ( +
+

Explanation

+

{explanation}

+
+ )} +
+ ); +} diff --git a/apps/web/src/components/curriculum/TextBlock.tsx b/apps/web/src/components/curriculum/TextBlock.tsx new file mode 100644 index 0000000..77709ba --- /dev/null +++ b/apps/web/src/components/curriculum/TextBlock.tsx @@ -0,0 +1,31 @@ +interface TextBlockProps { + content: Record; +} + +export function TextBlock({ content }: TextBlockProps) { + const text = content.text as string | undefined; + const variant = (content.variant as string) ?? "body"; + const level = (content.level as number) ?? 2; + + if (!text) return null; + + const className: Record = { + h1: "text-3xl font-bold mt-8 mb-4", + h2: "text-2xl font-semibold mt-6 mb-3", + h3: "text-xl font-semibold mt-5 mb-2", + body: "text-base leading-relaxed text-muted-foreground mb-4", + small: "text-sm text-muted-foreground", + quote: + "border-l-4 border-primary pl-4 italic text-muted-foreground my-4", + }; + + const Tag = variant === "body" || variant === "small" || variant === "quote" + ? "div" + : (`h${level}` as keyof JSX.IntrinsicElements); + + return ( + + {text} + + ); +} diff --git a/apps/web/src/components/curriculum/TrackCard.tsx b/apps/web/src/components/curriculum/TrackCard.tsx new file mode 100644 index 0000000..671dc9a --- /dev/null +++ b/apps/web/src/components/curriculum/TrackCard.tsx @@ -0,0 +1,104 @@ +import { useTranslation } from "react-i18next"; +import { cn, formatPercentage } from "@investplay/ui"; +import { + BookOpen, + CreditCard, + TrendingUp, + ShieldAlert, + Globe, + type LucideIcon, +} from "lucide-react"; +import type { CurriculumTrack } from "@investplay/types"; +import { Badge } from "@/components/ui/badge"; +import { ProgressBar } from "./ProgressBar"; + +interface TrackCardProps { + track: CurriculumTrack; + title: string; + description: string; + moduleCount: number; + estimatedHours: number; + progress: number; + difficulty: "beginner" | "intermediate" | "advanced"; + onClick?: () => void; +} + +const trackIcons: Record = { + SURVIVAL_BASICS: ShieldAlert, + CREDIT_DEBT: CreditCard, + WEALTH_BUILDER: TrendingUp, + ANTI_HYPE: ShieldAlert, + MACROECONOMICS: Globe, +}; + +const trackGradients: Record = { + SURVIVAL_BASICS: "from-blue-500/20 to-cyan-500/20", + CREDIT_DEBT: "from-orange-500/20 to-red-500/20", + WEALTH_BUILDER: "from-emerald-500/20 to-teal-500/20", + ANTI_HYPE: "from-purple-500/20 to-pink-500/20", + MACROECONOMICS: "from-amber-500/20 to-yellow-500/20", +}; + +const difficultyColors: Record = { + beginner: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-100", + intermediate: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-100", + advanced: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-100", +}; + +export function TrackCard({ + track, + title, + description, + moduleCount, + estimatedHours, + progress, + difficulty, + onClick, +}: TrackCardProps) { + const { t } = useTranslation(); + const Icon = trackIcons[track] ?? BookOpen; + + return ( + + ); +} diff --git a/apps/web/src/components/gamification/BadgeCard.tsx b/apps/web/src/components/gamification/BadgeCard.tsx new file mode 100644 index 0000000..3b299c2 --- /dev/null +++ b/apps/web/src/components/gamification/BadgeCard.tsx @@ -0,0 +1,71 @@ +import { useTranslation } from "react-i18next"; +import { cn } from "@investplay/ui"; +import type { Badge } from "@investplay/types"; +import { Lock, Award } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface BadgeCardProps { + badge: Badge; + earned: boolean; + earnedDate?: string; +} + +export function BadgeCard({ badge, earned, earnedDate }: BadgeCardProps) { + const { t } = useTranslation(); + + return ( + + + +
+
+ {earned ? ( + + ) : ( + + )} +
+ + {badge.name} + +
+
+ +

{badge.name}

+

+ {badge.description} +

+ {earnedDate && ( +

+ Earned {new Date(earnedDate).toLocaleDateString()} +

+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/gamification/BadgeGrid.tsx b/apps/web/src/components/gamification/BadgeGrid.tsx new file mode 100644 index 0000000..df630ff --- /dev/null +++ b/apps/web/src/components/gamification/BadgeGrid.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from "react-i18next"; +import { cn } from "@investplay/ui"; +import type { Badge } from "@investplay/types"; +import { BadgeCard } from "./BadgeCard"; + +interface BadgeGridProps { + badges: Badge[]; + earnedBadgeIds: Set; + className?: string; + limit?: number; +} + +export function BadgeGrid({ + badges, + earnedBadgeIds, + className, + limit, +}: BadgeGridProps) { + const { t } = useTranslation(); + const displayBadges = limit ? badges.slice(0, limit) : badges; + + const categories = [ + { key: "milestone", labelKey: "gamification:badges.categories.milestone" }, + { key: "skill", labelKey: "gamification:badges.categories.skill" }, + { key: "behavior", labelKey: "gamification:badges.categories.behavior" }, + { key: "special", labelKey: "gamification:badges.categories.special" }, + ] as const; + + if (!limit) { + return ( +
+ {categories.map(({ key, labelKey }) => { + const categoryBadges = badges.filter((b) => b.category === key); + if (categoryBadges.length === 0) return null; + + return ( +
+

+ {t(labelKey)} +

+
+ {categoryBadges.map((badge) => ( + + ))} +
+
+ ); + })} +
+ ); + } + + return ( +
+ {displayBadges.map((badge) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/gamification/LeaderboardTable.tsx b/apps/web/src/components/gamification/LeaderboardTable.tsx new file mode 100644 index 0000000..10fc2ee --- /dev/null +++ b/apps/web/src/components/gamification/LeaderboardTable.tsx @@ -0,0 +1,111 @@ +import { useTranslation } from "react-i18next"; +import { cn, formatXP } from "@investplay/ui"; +import type { LeaderboardEntry } from "@investplay/types"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { TrendingUp, TrendingDown, Minus, Trophy } from "lucide-react"; + +interface LeaderboardTableProps { + entries: LeaderboardEntry[]; + currentUserId?: string; + className?: string; +} + +const rankStyles = [ + "text-yellow-500", + "text-gray-400", + "text-amber-600", +]; + +const TrendIcon = ({ rank }: { rank: number }) => { + if (rank <= 3) return null; + if (rank <= 5) return ; + if (rank >= 10) return ; + return ; +}; + +export function LeaderboardTable({ + entries, + currentUserId, + className, +}: LeaderboardTableProps) { + const { t } = useTranslation(); + + if (entries.length === 0) { + return ( +
+ +

+ {t("gamification:leaderboard.empty")} +

+
+ ); + } + + return ( +
+ {entries.map((entry) => { + const isCurrentUser = entry.userId === currentUserId; + + return ( +
+ + {entry.rank <= 3 ? ( + + ) : ( + entry.rank + )} + + + + {entry.avatarUrl && ( + + )} + + {entry.firstName[0]}{entry.lastName[0]} + + + +
+

+ {entry.firstName} {entry.lastName} +

+

+ {t("gamification:level.label")} {entry.level} ·{" "} + {entry.badges} {t("gamification:badges.title")} +

+
+ +
+ + {formatXP(entry.xp)} + + +
+ + {isCurrentUser && ( + + {t("gamification:leaderboard.yourRank")} + + )} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/gamification/StreakCounter.tsx b/apps/web/src/components/gamification/StreakCounter.tsx new file mode 100644 index 0000000..f64869c --- /dev/null +++ b/apps/web/src/components/gamification/StreakCounter.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "react-i18next"; +import { cn } from "@investplay/ui"; +import { Flame } from "lucide-react"; + +interface StreakCounterProps { + current: number; + longest: number; + className?: string; + size?: "sm" | "md"; +} + +export function StreakCounter({ current, longest, className, size = "md" }: StreakCounterProps) { + const { t } = useTranslation(); + const hasStreak = current > 0; + + return ( +
+
+ + {current} +
+ + {t("gamification:streaks.longest", { count: longest })} + +
+ ); +} diff --git a/apps/web/src/components/gamification/XPBar.tsx b/apps/web/src/components/gamification/XPBar.tsx new file mode 100644 index 0000000..fb5810b --- /dev/null +++ b/apps/web/src/components/gamification/XPBar.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from "react-i18next"; +import { cn, formatXP } from "@investplay/ui"; +import { Progress } from "@/components/ui/progress"; +import { useGamificationStore, levelProgress } from "@/stores/gamification.store"; + +interface XPBarProps { + className?: string; + showLabel?: boolean; + size?: "sm" | "md" | "lg"; +} + +export function XPBar({ className, showLabel = true, size = "md" }: XPBarProps) { + const { t } = useTranslation(); + const { xp, level } = useGamificationStore(); + const progress = levelProgress(xp, level); + + return ( +
+
+ {showLabel && ( + <> + + {t("gamification:level.current", { level })} + + + {formatXP(xp)} XP + + + )} +
+ + {showLabel && ( +

+ {t("gamification:level.progress", { + current: level, + progress: Math.round(progress * 100), + next: level + 1, + })} +

+ )} +
+ ); +} diff --git a/apps/web/src/components/layout/Breadcrumbs.tsx b/apps/web/src/components/layout/Breadcrumbs.tsx new file mode 100644 index 0000000..dde8f9d --- /dev/null +++ b/apps/web/src/components/layout/Breadcrumbs.tsx @@ -0,0 +1,58 @@ +import { useLocation, Link } from "react-router-dom"; +import { ChevronRight, Home } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +const routeLabels: Record = { + dashboard: "nav.dashboard", + learn: "nav.learn", + play: "nav.play", + practice: "nav.practice", + teach: "nav.teach", + settings: "nav.settings", + profile: "nav.profile", +}; + +export function Breadcrumbs() { + const { t } = useTranslation(); + const { pathname } = useLocation(); + const segments = pathname.split("/").filter(Boolean); + + if (segments.length === 0) return null; + + return ( + + ); +} diff --git a/apps/web/src/components/layout/MobileNav.tsx b/apps/web/src/components/layout/MobileNav.tsx new file mode 100644 index 0000000..d6d17ba --- /dev/null +++ b/apps/web/src/components/layout/MobileNav.tsx @@ -0,0 +1,54 @@ +import { NavLink, useLocation } from "react-router-dom"; +import { cn } from "@investplay/ui"; +import { + LayoutDashboard, + BookOpen, + Gamepad2, + Brain, + MoreHorizontal, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +const mobileItems = [ + { to: "/dashboard", icon: LayoutDashboard, labelKey: "nav.dashboard" }, + { to: "/learn", icon: BookOpen, labelKey: "nav.learn" }, + { to: "/play", icon: Gamepad2, labelKey: "nav.play" }, + { to: "/practice", icon: Brain, labelKey: "nav.practice" }, + { to: "/more", icon: MoreHorizontal, labelKey: "nav.settings" }, +]; + +export function MobileNav() { + const { t } = useTranslation(); + const location = useLocation(); + + return ( + + ); +} diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..53c94e7 --- /dev/null +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -0,0 +1,122 @@ +import { NavLink, useLocation } from "react-router-dom"; +import { cn } from "@investplay/ui"; +import { + LayoutDashboard, + BookOpen, + Gamepad2, + Brain, + GraduationCap, + User, + Settings, + ChevronLeft, + ChevronRight, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { UserRole } from "@investplay/types"; +import { Button } from "@/components/ui/button"; +import { useUIStore } from "@/stores/ui.store"; + +interface SidebarProps { + role?: UserRole | null; +} + +const navItems = [ + { to: "/dashboard", icon: LayoutDashboard, labelKey: "nav.dashboard" }, + { to: "/learn", icon: BookOpen, labelKey: "nav.learn" }, + { to: "/play", icon: Gamepad2, labelKey: "nav.play" }, + { to: "/practice", icon: Brain, labelKey: "nav.practice" }, +]; + +const teacherItems = [ + { to: "/teach", icon: GraduationCap, labelKey: "nav.teach" }, +]; + +const bottomItems = [ + { to: "/profile", icon: User, labelKey: "nav.profile" }, + { to: "/settings", icon: Settings, labelKey: "nav.settings" }, +]; + +export function Sidebar({ role }: SidebarProps) { + const { t } = useTranslation(); + const { sidebarCollapsed, toggleSidebar } = useUIStore(); + const location = useLocation(); + + const isTeacher = + role === "TEACHER" || role === "TENANT_ADMIN" || role === "PLATFORM_ADMIN"; + + const linkClasses = (to: string) => + cn( + "flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors", + location.pathname.startsWith(to) + ? "bg-primary/10 text-primary" + : "text-muted-foreground hover:bg-secondary hover:text-foreground", + sidebarCollapsed && "justify-center px-2", + ); + + return ( + + ); +} diff --git a/apps/web/src/components/layout/TopBar.tsx b/apps/web/src/components/layout/TopBar.tsx new file mode 100644 index 0000000..2d0181d --- /dev/null +++ b/apps/web/src/components/layout/TopBar.tsx @@ -0,0 +1,117 @@ +import { useTranslation } from "react-i18next"; +import { Bell, Search, Moon, Sun, Monitor } from "lucide-react"; +import { cn, formatXP } from "@investplay/ui"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { LanguageSwitcher } from "@/components/shared/LanguageSwitcher"; +import { useAuth } from "@/hooks/useAuth"; +import { useUIStore, type ThemeMode } from "@/stores/ui.store"; +import { useGamificationStore } from "@/stores/gamification.store"; + +export function TopBar() { + const { t } = useTranslation(); + const { user } = useAuth(); + const { theme, setTheme } = useUIStore(); + const { xp, level } = useGamificationStore(); + + const userInitials = + user?.firstName && user?.lastName + ? `${user.firstName[0]}${user.lastName[0]}` + : "U"; + + const themeIcon = { + light: Sun, + dark: Moon, + system: Monitor, + }; + + const nextTheme: Record = { + light: "dark", + dark: "system", + system: "light", + }; + + return ( +
+
+
+ + +
+
+ +
+ {user && ( + + {formatXP(xp)} + XP + · + + {t("gamification:level.label")} {level} + + + )} + + + + + + + + + + + + + + {t("nav.profile")} + + + {t("nav.settings")} + + + +
+
+ ); +} diff --git a/apps/web/src/components/shared/ConfirmDialog.tsx b/apps/web/src/components/shared/ConfirmDialog.tsx new file mode 100644 index 0000000..26b5304 --- /dev/null +++ b/apps/web/src/components/shared/ConfirmDialog.tsx @@ -0,0 +1,62 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; + +interface ConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: "default" | "destructive"; + onConfirm: () => void; + isLoading?: boolean; +} + +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel, + cancelLabel, + variant = "default", + onConfirm, + isLoading, +}: ConfirmDialogProps) { + const { t } = useTranslation(); + + return ( + + + + {title} + {description} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/shared/EmptyState.tsx b/apps/web/src/components/shared/EmptyState.tsx new file mode 100644 index 0000000..4b10715 --- /dev/null +++ b/apps/web/src/components/shared/EmptyState.tsx @@ -0,0 +1,45 @@ +import { cn } from "@investplay/ui"; +import { Button } from "@/components/ui/button"; +import { Inbox } from "lucide-react"; + +interface EmptyStateProps { + icon?: React.ReactNode; + title: string; + description?: string; + actionLabel?: string; + onAction?: () => void; + className?: string; +} + +export function EmptyState({ + icon, + title, + description, + actionLabel, + onAction, + className, +}: EmptyStateProps) { + return ( +
+
+ {icon ?? } +
+

{title}

+ {description && ( +

+ {description} +

+ )} + {actionLabel && onAction && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/shared/ErrorBoundary.tsx b/apps/web/src/components/shared/ErrorBoundary.tsx new file mode 100644 index 0000000..c1788dc --- /dev/null +++ b/apps/web/src/components/shared/ErrorBoundary.tsx @@ -0,0 +1,71 @@ +import React, { Component, type ErrorInfo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { AlertTriangle } from "lucide-react"; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error("ErrorBoundary caught:", error, errorInfo); + } + + handleRetry = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + return ; + } + + return this.props.children; + } +} + +function ErrorFallback({ onRetry }: { onRetry: () => void }) { + const { t } = useTranslation(); + + return ( +
+ +

{t("errors.generic")}

+

+ {t("errors.serverError")} +

+ +
+ ); +} + +export function useErrorHandler() { + const [error, setError] = React.useState(null); + + if (error) { + throw error; + } + + return setError; +} diff --git a/apps/web/src/components/shared/LanguageSwitcher.tsx b/apps/web/src/components/shared/LanguageSwitcher.tsx new file mode 100644 index 0000000..84b04c3 --- /dev/null +++ b/apps/web/src/components/shared/LanguageSwitcher.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from "react-i18next"; +import { Globe } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useUIStore } from "@/stores/ui.store"; + +const languages = [ + { code: "en", label: "language.en", flag: "🇬🇧" }, + { code: "el", label: "language.el", flag: "🇬🇷" }, +]; + +export function LanguageSwitcher() { + const { t, i18n } = useTranslation(); + const { setLocale } = useUIStore(); + + const handleChange = (code: string) => { + void i18n.changeLanguage(code); + setLocale(code); + }; + + const currentLang = languages.find((l) => l.code === i18n.language) ?? languages[0]; + + return ( + + + + + + {languages.map((lang) => ( + handleChange(lang.code)} + className={i18n.language === lang.code ? "bg-secondary" : ""} + > + {lang.flag} + {t(lang.label)} + + ))} + + + ); +} diff --git a/apps/web/src/components/shared/LoadingSpinner.tsx b/apps/web/src/components/shared/LoadingSpinner.tsx new file mode 100644 index 0000000..ff0d1ea --- /dev/null +++ b/apps/web/src/components/shared/LoadingSpinner.tsx @@ -0,0 +1,33 @@ +import { cn } from "@investplay/ui"; + +interface LoadingSpinnerProps { + size?: "sm" | "md" | "lg"; + className?: string; + label?: string; +} + +const sizeMap = { + sm: "h-4 w-4 border-2", + md: "h-8 w-8 border-3", + lg: "h-12 w-12 border-4", +}; + +export function LoadingSpinner({ size = "md", className, label }: LoadingSpinnerProps) { + return ( +
+
+ {label && ( + {label} + )} +
+ ); +} diff --git a/apps/web/src/components/shared/ThemeToggle.tsx b/apps/web/src/components/shared/ThemeToggle.tsx new file mode 100644 index 0000000..6bc0b01 --- /dev/null +++ b/apps/web/src/components/shared/ThemeToggle.tsx @@ -0,0 +1,50 @@ +import { useTranslation } from "react-i18next"; +import { Sun, Moon, Monitor } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useUIStore, type ThemeMode } from "@/stores/ui.store"; + +const themes: { mode: ThemeMode; icon: typeof Sun; labelKey: string }[] = [ + { mode: "light", icon: Sun, labelKey: "theme.light" }, + { mode: "dark", icon: Moon, labelKey: "theme.dark" }, + { mode: "system", icon: Monitor, labelKey: "theme.system" }, +]; + +export function ThemeToggle() { + const { t } = useTranslation(); + const { theme, setTheme } = useUIStore(); + + const currentIcon = themes.find((th) => th.mode === theme)?.icon ?? Sun; + const Icon = currentIcon; + + return ( + + + + + + {themes.map(({ mode, icon: ThemeIcon, labelKey }) => ( + setTheme(mode)} + className={theme === mode ? "bg-secondary" : ""} + > + + {t(labelKey)} + + ))} + + + ); +} diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx new file mode 100644 index 0000000..34e8b42 --- /dev/null +++ b/apps/web/src/components/ui/avatar.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import { cn } from "@investplay/ui"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..ffa6054 --- /dev/null +++ b/apps/web/src/components/ui/badge.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@investplay/ui"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground", + secondary: "border-transparent bg-secondary text-secondary-foreground", + destructive: "border-transparent bg-destructive text-destructive-foreground", + outline: "text-foreground", + success: "border-transparent bg-accent text-accent-foreground", + warning: + "border-transparent bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx new file mode 100644 index 0000000..6ae4416 --- /dev/null +++ b/apps/web/src/components/ui/button.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@investplay/ui"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: "border border-border bg-transparent hover:bg-secondary hover:text-secondary-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-secondary hover:text-secondary-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + sm: "h-9 px-3 text-xs", + md: "h-10 px-4 py-2", + lg: "h-11 px-8 text-base", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "md", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx new file mode 100644 index 0000000..e744fbd --- /dev/null +++ b/apps/web/src/components/ui/card.tsx @@ -0,0 +1,69 @@ +import * as React from "react"; +import { cn } from "@investplay/ui"; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..3e2d355 --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,84 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { cn } from "@investplay/ui"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +}; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..8c39b29 --- /dev/null +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,85 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { cn } from "@investplay/ui"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuLabel, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuRadioGroup, +}; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 0000000..3fbb387 --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import { cn } from "@investplay/ui"; +import { Label } from "./label"; + +export interface InputProps extends React.InputHTMLAttributes { + label?: string; + error?: string; + helperText?: string; +} + +const Input = React.forwardRef( + ({ className, type, label, error, helperText, id, ...props }, ref) => { + const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-"); + + return ( +
+ {label && ( + + )} + + {error && ( + + )} + {helperText && !error && ( +

+ {helperText} +

+ )} +
+ ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 0000000..9ed00f0 --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@investplay/ui"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", + { + variants: { + error: { + true: "text-destructive", + }, + }, + defaultVariants: { + error: false, + }, + }, +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, error, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx new file mode 100644 index 0000000..4b6130a --- /dev/null +++ b/apps/web/src/components/ui/progress.tsx @@ -0,0 +1,30 @@ +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; +import { cn } from "@investplay/ui"; + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + indicatorClassName?: string; + } +>(({ className, value, indicatorClassName, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx new file mode 100644 index 0000000..0f7fec9 --- /dev/null +++ b/apps/web/src/components/ui/separator.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "@investplay/ui"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..2627d1f --- /dev/null +++ b/apps/web/src/components/ui/skeleton.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; +import { cn } from "@investplay/ui"; + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx new file mode 100644 index 0000000..73577d9 --- /dev/null +++ b/apps/web/src/components/ui/tabs.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { cn } from "@investplay/ui"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx new file mode 100644 index 0000000..b168570 --- /dev/null +++ b/apps/web/src/components/ui/toast.tsx @@ -0,0 +1,119 @@ +import * as React from "react"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@investplay/ui"; + +const ToastProvider = ToastPrimitives.Provider; + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-xl border p-4 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-slide-up data-[state=closed]:animate-fade-in data-[swipe=end]:animate-out", + { + variants: { + variant: { + default: "border bg-background text-foreground", + success: "border-accent bg-accent/10 text-foreground", + destructive: "destructive group border-destructive bg-destructive/10 text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; + +interface UseToastReturn { + toast: (props: { title?: string; description?: string; variant?: "default" | "success" | "destructive" }) => void; +} + +function useToast(): UseToastReturn { + const toast = React.useCallback( + ({ title, description, variant = "default" }: { title?: string; description?: string; variant?: "default" | "success" | "destructive" }) => { + const event = new CustomEvent("investplay-toast", { + detail: { title, description, variant }, + }); + window.dispatchEvent(event); + }, + [], + ); + + return { toast }; +} + +export { + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + useToast, +}; diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..eb65fc7 --- /dev/null +++ b/apps/web/src/components/ui/tooltip.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { cn } from "@investplay/ui"; + +const TooltipProvider = TooltipPrimitive.Provider; +const Tooltip = TooltipPrimitive.Root; +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/apps/web/src/hooks/useAICoach.ts b/apps/web/src/hooks/useAICoach.ts new file mode 100644 index 0000000..14cf017 --- /dev/null +++ b/apps/web/src/hooks/useAICoach.ts @@ -0,0 +1,68 @@ +import { useState, useCallback, useRef } from "react"; +import { getSocket } from "@/lib/socket"; +import type { AICoachMessage } from "@investplay/types"; + +interface UseAICoachOptions { + sessionId: string; + lessonId?: string; + locale?: string; +} + +export function useAICoach({ sessionId, lessonId, locale = "en" }: UseAICoachOptions) { + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [error, setError] = useState(null); + const streamRef = useRef(""); + + const sendMessage = useCallback( + (content: string) => { + const socket = getSocket(); + if (!socket || isStreaming) return; + + const userMessage: AICoachMessage = { + id: crypto.randomUUID(), + sessionId, + role: "user", + content, + metadata: null, + createdAt: new Date().toISOString(), + }; + + setMessages((prev) => [...prev, userMessage]); + setIsStreaming(true); + setError(null); + streamRef.current = ""; + + socket.emit( + "ai:message", + { + sessionId, + content, + lessonId, + locale, + }, + (response: { error?: string }) => { + if (response.error) { + setError(response.error); + setIsStreaming(false); + } + }, + ); + }, + [sessionId, lessonId, locale, isStreaming], + ); + + const clearChat = useCallback(() => { + setMessages([]); + setError(null); + streamRef.current = ""; + }, []); + + return { + messages, + isStreaming, + error, + sendMessage, + clearChat, + }; +} diff --git a/apps/web/src/hooks/useAuth.ts b/apps/web/src/hooks/useAuth.ts new file mode 100644 index 0000000..566d6c9 --- /dev/null +++ b/apps/web/src/hooks/useAuth.ts @@ -0,0 +1,53 @@ +import { useCallback } from "react"; +import { useAuthStore } from "@/stores/auth.store"; +import { setAuthTokens, clearAuthTokens } from "@/lib/api"; +import { setGraphQLToken } from "@/lib/graphql"; +import { disconnectSocket } from "@/lib/socket"; +import type { User } from "@investplay/types"; + +export function useAuth() { + const store = useAuthStore(); + + const login = useCallback( + (user: User, accessToken: string, refreshToken: string) => { + setAuthTokens(accessToken, refreshToken); + setGraphQLToken(accessToken); + store.login(user, accessToken, refreshToken); + }, + [store], + ); + + const register = useCallback( + (user: User, accessToken: string, refreshToken: string) => { + setAuthTokens(accessToken, refreshToken); + setGraphQLToken(accessToken); + store.register(user, accessToken, refreshToken); + }, + [store], + ); + + const logout = useCallback(() => { + clearAuthTokens(); + setGraphQLToken(null); + disconnectSocket(); + store.logout(); + }, [store]); + + const updateUser = useCallback( + (partial: Partial) => { + store.updateUser(partial); + }, + [store], + ); + + return { + user: store.user, + accessToken: store.accessToken, + isAuthenticated: store.isAuthenticated, + isLoading: store.isLoading, + login, + register, + logout, + updateUser, + }; +} diff --git a/apps/web/src/hooks/useLocalizedContent.ts b/apps/web/src/hooks/useLocalizedContent.ts new file mode 100644 index 0000000..a4796d6 --- /dev/null +++ b/apps/web/src/hooks/useLocalizedContent.ts @@ -0,0 +1,29 @@ +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { useTranslation } from "react-i18next"; + +interface LocalizedContent { + data: T; + locale: string; +} + +export function useLocalizedContent( + key: string, + path: string, + options?: Omit>, "queryKey" | "queryFn">, +) { + const { i18n } = useTranslation(); + + return useQuery>({ + queryKey: [key, path, i18n.language], + queryFn: async () => { + const response = await api(path, { + headers: { + "Accept-Language": i18n.language, + }, + }); + return { data: response, locale: i18n.language }; + }, + ...options, + }); +} diff --git a/apps/web/src/hooks/useMediaQuery.ts b/apps/web/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..2c44003 --- /dev/null +++ b/apps/web/src/hooks/useMediaQuery.ts @@ -0,0 +1,27 @@ +import { useState, useEffect } from "react"; + +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => { + if (typeof window !== "undefined") { + return window.matchMedia(query).matches; + } + return false; + }); + + useEffect(() => { + const mediaQuery = window.matchMedia(query); + + const handleChange = (event: MediaQueryListEvent) => { + setMatches(event.matches); + }; + + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, [query]); + + return matches; +} + +export const useIsMobile = () => useMediaQuery("(max-width: 767px)"); +export const useIsTablet = () => useMediaQuery("(min-width: 768px) and (max-width: 1023px)"); +export const useIsDesktop = () => useMediaQuery("(min-width: 1024px)"); diff --git a/apps/web/src/hooks/useSimulation.ts b/apps/web/src/hooks/useSimulation.ts new file mode 100644 index 0000000..df5d86b --- /dev/null +++ b/apps/web/src/hooks/useSimulation.ts @@ -0,0 +1,73 @@ +import { useState, useCallback, useEffect, useRef } from "react"; +import { getSocket, connectSocket, disconnectSocket } from "@/lib/socket"; +import type { SimulationSession, SimulationType } from "@investplay/types"; + +interface UseSimulationOptions { + simulationType: SimulationType; + token: string; +} + +export function useSimulation({ simulationType, token }: UseSimulationOptions) { + const [session, setSession] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + const tickRef = useRef(0); + + useEffect(() => { + const socket = connectSocket(token); + + socket.on("connect", () => setIsConnected(true)); + socket.on("disconnect", () => setIsConnected(false)); + + socket.emit("simulation:join", { simulationType }); + + socket.on("simulation:state", (data: SimulationSession) => { + setSession(data); + }); + + socket.on("simulation:tick", (data: SimulationSession) => { + tickRef.current = data.ticks.length; + setSession(data); + }); + + socket.on("simulation:error", (err: { message: string }) => { + setError(err.message); + }); + + return () => { + socket.off("simulation:state"); + socket.off("simulation:tick"); + socket.off("simulation:error"); + socket.emit("simulation:leave", { sessionId: session?.id }); + disconnectSocket(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [simulationType, token]); + + const makeDecision = useCallback( + (optionId: string) => { + const socket = getSocket(); + if (!socket || !session) return; + socket.emit("simulation:decide", { + sessionId: session.id, + optionId, + currentTick: tickRef.current, + }); + }, + [session], + ); + + const completeSimulation = useCallback(() => { + const socket = getSocket(); + if (!socket || !session) return; + socket.emit("simulation:complete", { sessionId: session.id }); + }, [session]); + + return { + session, + isConnected, + error, + makeDecision, + completeSimulation, + }; +} diff --git a/apps/web/src/layouts/AuthenticatedLayout.tsx b/apps/web/src/layouts/AuthenticatedLayout.tsx new file mode 100644 index 0000000..e1ed814 --- /dev/null +++ b/apps/web/src/layouts/AuthenticatedLayout.tsx @@ -0,0 +1,48 @@ +import { Outlet, Navigate } from "react-router-dom"; +import { useAuth } from "@/hooks/useAuth"; +import { useUIStore } from "@/stores/ui.store"; +import { useIsMobile } from "@/hooks/useMediaQuery"; +import { Sidebar } from "@/components/layout/Sidebar"; +import { TopBar } from "@/components/layout/TopBar"; +import { MobileNav } from "@/components/layout/MobileNav"; +import { LoadingSpinner } from "@/components/shared/LoadingSpinner"; +import { cn } from "@investplay/ui"; + +export function AuthenticatedLayout() { + const { isAuthenticated, isLoading, user } = useAuth(); + const { sidebarCollapsed } = useUIStore(); + const isMobile = useIsMobile(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return ( +
+ {!isMobile && } + +
+ +
+ +
+
+ + {isMobile && } +
+ ); +} diff --git a/apps/web/src/layouts/PublicLayout.tsx b/apps/web/src/layouts/PublicLayout.tsx new file mode 100644 index 0000000..f361647 --- /dev/null +++ b/apps/web/src/layouts/PublicLayout.tsx @@ -0,0 +1,113 @@ +import { Link, Outlet } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { LanguageSwitcher } from "@/components/shared/LanguageSwitcher"; +import { ThemeToggle } from "@/components/shared/ThemeToggle"; +import { Menu, X } from "lucide-react"; +import { useState } from "react"; + +const footerLinks = [ + { href: "/about", label: "About" }, + { href: "/privacy", label: "Privacy" }, + { href: "/terms", label: "Terms" }, + { href: "/contact", label: "Contact" }, +]; + +export function PublicLayout() { + const { t } = useTranslation(); + const [mobileOpen, setMobileOpen] = useState(false); + + return ( +
+
+
+ + InvestPlay + + + + +
+ + +
+ + +
+ +
+
+ + {mobileOpen && ( +
+ +
+ )} +
+ +
+ +
+ +
+
+
+ InvestPlay + +

+ © {new Date().getFullYear()} InvestPlay. All rights reserved. +

+
+
+
+
+ ); +} diff --git a/apps/web/src/layouts/TeacherLayout.tsx b/apps/web/src/layouts/TeacherLayout.tsx new file mode 100644 index 0000000..48fd9dc --- /dev/null +++ b/apps/web/src/layouts/TeacherLayout.tsx @@ -0,0 +1,59 @@ +import { Outlet } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@/hooks/useAuth"; +import { cn } from "@investplay/ui"; +import { Breadcrumbs } from "@/components/layout/Breadcrumbs"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { useNavigate, useLocation } from "react-router-dom"; + +const teacherTabs = [ + { value: "overview", labelKey: "classroom:dashboard.overview" }, + { value: "classrooms", labelKey: "classroom:cohorts.title" }, + { value: "assignments", labelKey: "classroom:assignments.title" }, + { value: "reports", labelKey: "classroom:reports.title" }, +]; + +export function TeacherLayout() { + const { t } = useTranslation(); + const { user } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const isTeacher = + user?.role === "TEACHER" || user?.role === "TENANT_ADMIN" || user?.role === "PLATFORM_ADMIN"; + + const currentTab = location.pathname.split("/").filter(Boolean)[1] ?? "overview"; + + if (!isTeacher) { + return ; + } + + return ( +
+ + +
+

{t("classroom:dashboard.title")}

+

+ {t("classroom:dashboard.overview")} +

+
+ + navigate(`/teach/${value}`)} + className="mb-6" + > + + {teacherTabs.map((tab) => ( + + {t(tab.labelKey)} + + ))} + + + + +
+ ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..27cd9f3 --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,140 @@ +import { env } from "./env"; + +export class ApiError extends Error { + constructor( + public status: number, + message: string, + public code?: string, + public details?: unknown, + ) { + super(message); + this.name = "ApiError"; + } +} + +export class NetworkError extends Error { + constructor(cause: unknown) { + super("Network error. Please check your connection."); + this.name = "NetworkError"; + this.cause = cause; + } +} + +let accessToken: string | null = null; +let refreshToken: string | null = null; +let onTokenRefresh: ((tokens: { accessToken: string; refreshToken: string }) => void) | null = null; + +export function setAuthTokens(access: string, refresh: string): void { + accessToken = access; + refreshToken = refresh; +} + +export function clearAuthTokens(): void { + accessToken = null; + refreshToken = null; +} + +export function setTokenRefreshHandler( + handler: (tokens: { accessToken: string; refreshToken: string }) => void, +): void { + onTokenRefresh = handler; +} + +interface RequestConfig extends Omit { + params?: Record; + body?: unknown; +} + +export async function api( + path: string, + config: RequestConfig = {}, +): Promise { + const { params, body, headers: customHeaders, ...rest } = config; + + let url = `${env.API_URL}${path}`; + + if (params) { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + searchParams.set(key, value); + } + } + const qs = searchParams.toString(); + if (qs) url += `?${qs}`; + } + + const headers: Record = { + "Content-Type": "application/json", + ...customHeaders, + } as Record; + + if (accessToken) { + headers["Authorization"] = `Bearer ${accessToken}`; + } + + const response = await fetch(url, { + ...rest, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (response.status === 401 && refreshToken && onTokenRefresh) { + const refreshed = await attemptTokenRefresh(); + if (refreshed) { + headers["Authorization"] = `Bearer ${accessToken}`; + const retryResponse = await fetch(url, { + ...rest, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + if (!retryResponse.ok) { + throw await parseError(retryResponse); + } + return retryResponse.json() as Promise; + } + } + + if (!response.ok) { + throw await parseError(response); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; +} + +async function parseError(response: Response): Promise { + try { + const body = await response.json(); + return new ApiError( + response.status, + body.message ?? response.statusText, + body.code, + body.details, + ); + } catch { + return new ApiError(response.status, response.statusText); + } +} + +async function attemptTokenRefresh(): Promise { + if (!refreshToken || !onTokenRefresh) return false; + try { + const response = await fetch(`${env.API_URL}/auth/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken }), + }); + if (!response.ok) return false; + const data = await response.json(); + accessToken = data.accessToken; + refreshToken = data.refreshToken; + onTokenRefresh({ accessToken: data.accessToken, refreshToken: data.refreshToken }); + return true; + } catch { + return false; + } +} diff --git a/apps/web/src/lib/env.ts b/apps/web/src/lib/env.ts new file mode 100644 index 0000000..24ff661 --- /dev/null +++ b/apps/web/src/lib/env.ts @@ -0,0 +1,35 @@ +/** + * Frontend-safe environment variables. + * + * ⚠️ Only variables prefixed with `VITE_` are available at runtime in the + * Vite-based web app. Never expose secrets here. + */ + +function getString(key: string, fallback: string): string { + return (import.meta.env[key] as string | undefined) ?? fallback; +} + +function getBoolean(key: string, fallback: boolean): boolean { + const raw = import.meta.env[key] as string | undefined; + if (raw === undefined) return fallback; + return raw === "true" || raw === "1" || raw === "yes"; +} + +export const env = { + /** Base URL for the API server (e.g. "http://localhost:3001") */ + API_URL: getString("VITE_API_URL", "http://localhost:3001"), + + /** WebSocket URL for real-time features (defaults to API_URL) */ + WS_URL: getString("VITE_WS_URL", "http://localhost:3001"), + + /** Public-facing URL of the frontend itself */ + APP_URL: getString("VITE_APP_URL", "http://localhost:5173"), + + /** Default UI locale */ + DEFAULT_LOCALE: getString("VITE_DEFAULT_LOCALE", "en"), + + /** Whether Clerk authentication is enabled on the frontend */ + CLERK_ENABLED: getBoolean("VITE_CLERK_ENABLED", false), +} as const; + +export type WebEnv = typeof env; diff --git a/apps/web/src/lib/graphql.ts b/apps/web/src/lib/graphql.ts new file mode 100644 index 0000000..6a298d0 --- /dev/null +++ b/apps/web/src/lib/graphql.ts @@ -0,0 +1,64 @@ +import { ApolloClient, InMemoryCache, ApolloLink, HttpLink, from } from "@apollo/client"; +import { setContext } from "@apollo/client/link/context"; +import { onError } from "@apollo/client/link/error"; +import { env } from "./env"; + +let accessToken: string | null = null; + +export function setGraphQLToken(token: string | null): void { + accessToken = token; +} + +const httpLink = new HttpLink({ + uri: `${env.API_URL}/graphql`, + credentials: "include", +}); + +const authLink = setContext((_, { headers }) => { + const authHeaders: Record = {}; + if (accessToken) { + authHeaders["Authorization"] = `Bearer ${accessToken}`; + } + return { + headers: { + ...headers, + ...authHeaders, + }, + }; +}); + +const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => { + if (graphQLErrors) { + for (const err of graphQLErrors) { + if (err.extensions?.code === "UNAUTHENTICATED") { + return forward(operation); + } + } + } + if (networkError) { + console.error("GraphQL network error:", networkError.message); + } +}); + +export const apolloClient = new ApolloClient({ + link: from([errorLink, authLink, httpLink]), + cache: new InMemoryCache({ + typePolicies: { + User: { + keyFields: ["id"], + }, + Lesson: { + keyFields: ["id"], + }, + Module: { + keyFields: ["id"], + }, + }, + }), + defaultOptions: { + watchQuery: { + fetchPolicy: "cache-and-network", + errorPolicy: "all", + }, + }, +}); diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts new file mode 100644 index 0000000..a1cea56 --- /dev/null +++ b/apps/web/src/lib/i18n.ts @@ -0,0 +1,20 @@ +import { initI18n, useTranslation } from "@investplay/i18n"; +import { env } from "./env"; + +const i18nInstance = initI18n({ + lng: env.DEFAULT_LOCALE, +}); + +export default i18nInstance; + +export function useTrans() { + return useTranslation(); +} + +export function changeLanguage(lng: string): void { + void i18nInstance.changeLanguage(lng); +} + +export function getCurrentLanguage(): string { + return i18nInstance.language; +} diff --git a/apps/web/src/lib/socket.ts b/apps/web/src/lib/socket.ts new file mode 100644 index 0000000..475e733 --- /dev/null +++ b/apps/web/src/lib/socket.ts @@ -0,0 +1,63 @@ +import { io, Socket } from "socket.io-client"; +import { env } from "./env"; + +let socket: Socket | null = null; + +export function connectSocket(token: string): Socket { + if (socket?.connected) { + return socket; + } + + socket = io(env.WS_URL, { + auth: { token }, + transports: ["websocket", "polling"], + reconnection: true, + reconnectionAttempts: 10, + reconnectionDelay: 1000, + reconnectionDelayMax: 10000, + }); + + socket.on("connect_error", (error) => { + console.error("Socket connection error:", error.message); + }); + + socket.on("disconnect", (reason) => { + if (reason === "io server disconnect") { + socket?.connect(); + } + }); + + return socket; +} + +export function disconnectSocket(): void { + if (socket) { + socket.disconnect(); + socket = null; + } +} + +export function getSocket(): Socket | null { + return socket; +} + +export interface SimulationEventPayload { + sessionId: string; + tick: number; + values: Record; +} + +export interface AICoachEventPayload { + sessionId: string; + messageId: string; + content: string; + done: boolean; +} + +export interface NotificationPayload { + id: string; + type: "xp" | "badge" | "assignment" | "announcement"; + title: string; + body: string; + data?: Record; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..d0beaee --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./styles/globals.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + retry: 2, + refetchOnWindowFocus: false, + }, + }, +}); + +const rootElement = document.getElementById("root"); +if (!rootElement) { + throw new Error("Root element not found"); +} + +ReactDOM.createRoot(rootElement).render( + + + + + + + , +); diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..5668cc7 --- /dev/null +++ b/apps/web/src/pages/DashboardPage.tsx @@ -0,0 +1,226 @@ +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { cn, formatXP } from "@investplay/ui"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { XPBar } from "@/components/gamification/XPBar"; +import { StreakCounter } from "@/components/gamification/StreakCounter"; +import { BadgeGrid } from "@/components/gamification/BadgeGrid"; +import { useAuth } from "@/hooks/useAuth"; +import { useGamificationStore } from "@/stores/gamification.store"; +import { + TrendingUp, + BookOpen, + Gamepad2, + Brain, + ArrowRight, + Flame, + Trophy, + Clock, + Target, +} from "lucide-react"; + +const recentActivity = [ + { type: "lesson", label: "Completed: Budgeting Basics", time: "2h ago", icon: BookOpen }, + { type: "challenge", label: "Scored 85% on Emergency Fund", time: "1d ago", icon: Gamepad2 }, + { type: "badge", label: "Earned: First Steps badge", time: "3d ago", icon: Trophy }, +]; + +export function DashboardPage() { + const { t } = useTranslation(); + const { user } = useAuth(); + const { xp, level, streaks, badges } = useGamificationStore(); + + const today = new Date().toLocaleDateString(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); + + const earnedBadgeIds = new Set(badges.map((b) => b.id)); + + return ( +
+
+
+

+ {t("auth.welcomeBack")}, {user?.firstName ?? "Learner"} +

+

{today}

+
+
+ +
+ + +
+ +
+
+

{t("gamification:level.label")}

+

{level}

+
+
+
+ + +
+ +
+
+

{t("gamification:streaks.label")}

+

{streaks.current} days

+
+
+
+ + +
+ +
+
+

Lessons

+

12

+
+
+
+ + +
+ +
+
+

{t("gamification:badges.title")}

+

{badges.length}

+
+
+
+
+ + + +
+ + + + + Continue Learning + + + +
+
+
+
+

Budgeting Basics

+

Module 2 of 6

+
+ In Progress +
+ +
+ +
+
+
+ + + + + + Weekly Goal + + + +
+
+ + + + + 60% +
+

3 of 5 lessons

+
+
+
+
+ +
+ + + Recent Activity + + +
+ {recentActivity.map((item, i) => ( +
+
+ +
+
+

{item.label}

+

{item.time}

+
+
+ ))} +
+
+
+ + + + Quick Actions + + + + + + + +
+ + {badges.length > 0 && ( + + + {t("gamification:badges.title")} + + + + + + )} +
+ ); +} diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..734eea4 --- /dev/null +++ b/apps/web/src/pages/HomePage.tsx @@ -0,0 +1,300 @@ +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { cn } from "@investplay/ui"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + BookOpen, + Gamepad2, + Brain, + GraduationCap, + ArrowRight, + Star, + TrendingUp, + Shield, + Users, + Sparkles, + Check, +} from "lucide-react"; + +const features = [ + { + icon: BookOpen, + title: "Learn", + description: "Interactive lessons on personal finance, investing, and economics.", + color: "from-blue-500 to-cyan-500", + }, + { + icon: Gamepad2, + title: "Play", + description: "Risk-free simulations and challenges that test your financial skills.", + color: "from-orange-500 to-red-500", + }, + { + icon: Brain, + title: "Practice", + description: "Hands-on portfolio management with real-time market simulations.", + color: "from-emerald-500 to-teal-500", + }, + { + icon: GraduationCap, + title: "Teach", + description: "Tools for educators to create classrooms and track student progress.", + color: "from-purple-500 to-pink-500", + }, +]; + +const steps = [ + { + icon: Users, + title: "Join", + description: "Create your free account in seconds.", + step: "01", + }, + { + icon: BookOpen, + title: "Learn", + description: "Complete interactive lessons at your own pace.", + step: "02", + }, + { + icon: TrendingUp, + title: "Apply", + description: "Test your knowledge in simulations and challenges.", + step: "03", + }, +]; + +const testimonials = [ + { + name: "Maria K.", + role: "Student", + text: "InvestPlay made learning about investing actually fun. The simulations helped me understand risks without losing real money.", + rating: 5, + }, + { + name: "John D.", + role: "Teacher", + text: "My students are more engaged than ever. The classroom features make it easy to track progress and assign lessons.", + rating: 5, + }, + { + name: "Elena P.", + role: "Professional", + text: "Wish I had this when I was starting out. The AI coach is incredibly helpful for understanding complex concepts.", + rating: 5, + }, +]; + +const trustedBy = [ + "FinEdu Alliance", + "EduTech Europe", + "Future Finance", + "Econ Academy", +]; + +export function HomePage() { + const { t } = useTranslation(); + + return ( +
+
+
+
+ +
+ + + Financial Literacy for Everyone + +

+ Master Your + + Financial Future + +

+

+ Learn personal finance through interactive lessons, risk-free simulations, + and engaging challenges. Build skills that last a lifetime. +

+
+ + +
+
+
+ +
+
+
+

Everything you need to learn finance

+

+ Four pillars of financial education +

+
+
+ {features.map((feature) => ( + + +
+ +
+

{feature.title}

+

{feature.description}

+
+
+ ))} +
+
+
+ +
+
+
+

How it works

+

+ Three simple steps to financial literacy +

+
+
+ {steps.map((step, i) => ( +
+
+ +
+ Step {step.step} +

{step.title}

+

{step.description}

+
+ ))} +
+
+
+ +
+
+
+

Loved by learners everywhere

+
+
+ {testimonials.map((t) => ( + + +
+ {Array.from({ length: t.rating }).map((_, i) => ( + + ))} +
+

“{t.text}”

+
+

{t.name}

+

{t.role}

+
+
+
+ ))} +
+
+
+ +
+
+
+

Simple, transparent pricing

+

Start free, upgrade when you need more

+
+
+ + +

Freemium

+

Free

+
    + {["All basic lessons", "3 simulations", "Basic AI Coach", "Progress tracking"].map((item) => ( +
  • + + {item} +
  • + ))} +
+ +
+
+ + Popular + +

Institution

+

Contact us

+
    + {["All lessons & simulations", "Unlimited AI Coach", "Classroom management", "Analytics & reports", "Priority support"].map((item) => ( +
  • + + {item} +
  • + ))} +
+ +
+
+
+
+
+ +
+
+

+ Trusted by +

+
+ {trustedBy.map((name) => ( + {name} + ))} +
+
+
+ +
+
+

+ Ready to master your financial future? +

+

+ Join thousands of learners building real financial skills. +

+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/pages/LearnPage.tsx b/apps/web/src/pages/LearnPage.tsx new file mode 100644 index 0000000..d3b283a --- /dev/null +++ b/apps/web/src/pages/LearnPage.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { cn } from "@investplay/ui"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { TrackCard } from "@/components/curriculum/TrackCard"; +import { EmptyState } from "@/components/shared/EmptyState"; +import { CurriculumTrack } from "@investplay/types"; +import { Search, Filter } from "lucide-react"; + +interface TrackData { + track: CurriculumTrack; + title: string; + description: string; + moduleCount: number; + estimatedHours: number; + progress: number; + difficulty: "beginner" | "intermediate" | "advanced"; +} + +const tracks: TrackData[] = [ + { + track: CurriculumTrack.SURVIVAL_BASICS, + title: "Survival Basics", + description: "Essential personal finance skills: budgeting, saving, and avoiding common pitfalls.", + moduleCount: 6, + estimatedHours: 8, + progress: 33, + difficulty: "beginner", + }, + { + track: CurriculumTrack.CREDIT_DEBT, + title: "Credit & Debt", + description: "Understand credit scores, loans, interest rates, and debt management strategies.", + moduleCount: 5, + estimatedHours: 7, + progress: 0, + difficulty: "beginner", + }, + { + track: CurriculumTrack.WEALTH_BUILDER, + title: "Wealth Builder", + description: "Learn investing fundamentals, compound interest, and long-term wealth building.", + moduleCount: 8, + estimatedHours: 12, + progress: 0, + difficulty: "intermediate", + }, + { + track: CurriculumTrack.ANTI_HYPE, + title: "Anti-Hype", + description: "Develop critical thinking about market trends, scams, and get-rich-quick schemes.", + moduleCount: 4, + estimatedHours: 5, + progress: 0, + difficulty: "intermediate", + }, + { + track: CurriculumTrack.MACROECONOMICS, + title: "Macroeconomics", + description: "Understand inflation, monetary policy, and how the broader economy works.", + moduleCount: 6, + estimatedHours: 9, + progress: 0, + difficulty: "advanced", + }, +]; + +type DifficultyFilter = "all" | "beginner" | "intermediate" | "advanced"; + +export function LearnPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [search, setSearch] = useState(""); + const [difficulty, setDifficulty] = useState("all"); + + const filtered = tracks.filter((track) => { + const matchesSearch = + !search || + track.title.toLowerCase().includes(search.toLowerCase()) || + track.description.toLowerCase().includes(search.toLowerCase()); + const matchesDifficulty = + difficulty === "all" || track.difficulty === difficulty; + return matchesSearch && matchesDifficulty; + }); + + return ( +
+
+

{t("nav.learn")}

+

+ Explore our financial literacy tracks +

+
+ +
+
+ + setSearch(e.target.value)} + /> +
+ + setDifficulty(v as DifficultyFilter)} + > + + All + Beginner + Intermediate + Advanced + + +
+ + {filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map((track) => ( + navigate(`/learn/${track.track.toLowerCase()}`)} + /> + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/LessonPage.tsx b/apps/web/src/pages/LessonPage.tsx new file mode 100644 index 0000000..3dd1065 --- /dev/null +++ b/apps/web/src/pages/LessonPage.tsx @@ -0,0 +1,227 @@ +import { useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { cn, formatXP } from "@investplay/ui"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { Breadcrumbs } from "@/components/layout/Breadcrumbs"; +import { BlockRenderer } from "@/components/curriculum/BlockRenderer"; +import { + ArrowLeft, + ArrowRight, + CheckCircle2, + MessageCircle, + X, + Sparkles, + Zap, +} from "lucide-react"; +import type { LessonBlock, BlockType } from "@investplay/types"; + +const mockBlocks: LessonBlock[] = [ + { + id: "b1", + type: "HERO" as BlockType, + order: 1, + content: { + title: "Creating Your First Budget", + description: "Learn the 50/30/20 rule and start managing your money effectively.", + }, + config: null, + }, + { + id: "b2", + type: "TEXT" as BlockType, + order: 2, + content: { + text: "A budget is a plan for your money. It helps you track income, control spending, and save for goals. The most popular method is the 50/30/20 rule: 50% for needs, 30% for wants, and 20% for savings.", + variant: "body", + }, + config: null, + }, + { + id: "b3", + type: "QUIZ" as BlockType, + order: 3, + content: { + question: "What percentage of income should go to 'needs' in the 50/30/20 rule?", + options: ["30%", "50%", "20%", "60%"], + type: "multiple-choice", + correctAnswer: "50%", + explanation: "The 50/30/20 rule allocates 50% of income to needs, 30% to wants, and 20% to savings.", + }, + config: null, + }, +]; + +const totalLessons = 6; +const currentLessonNumber = 3; + +export function LessonPage() { + const { moduleId, lessonId } = useParams<{ moduleId: string; lessonId: string }>(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const [quizResults, setQuizResults] = useState>({}); + const [aiCoachOpen, setAiCoachOpen] = useState(false); + const [completed, setCompleted] = useState(false); + + const progress = (currentLessonNumber / totalLessons) * 100; + + const handleQuizAnswer = (blockId: string, answer: string | string[]) => { + const block = mockBlocks.find((b) => b.id === blockId); + if (!block) return; + + const correctAnswer = block.content.correctAnswer as string; + const correct = Array.isArray(answer) + ? answer.every((a) => (correctAnswer as string[]).includes(a)) + : answer === correctAnswer; + + setQuizResults((prev) => ({ + ...prev, + [blockId]: { correct, answer }, + })); + }; + + const handleComplete = () => { + setCompleted(true); + }; + + return ( +
+ + +
+ + +
+ + + {formatXP(75)} XP + + + Lesson {currentLessonNumber} of {totalLessons} + +
+
+ + + + {completed ? ( +
+ +

Lesson Complete!

+

+ You earned {formatXP(75)} XP for completing this lesson. +

+
+ + +
+
+ ) : ( +
+
+ {mockBlocks.map((block) => ( + + ))} + +
+ + + +
+
+ +
+
+
+ +

AI Coach

+
+ +
+ +
+
+
+

+ Hi! I'm your AI Coach. Ask me anything about budgeting! +

+
+
+ +
+
+ + +
+
+
+
+ + +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/ModulePage.tsx b/apps/web/src/pages/ModulePage.tsx new file mode 100644 index 0000000..7c57c03 --- /dev/null +++ b/apps/web/src/pages/ModulePage.tsx @@ -0,0 +1,135 @@ +import { useParams, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { cn, formatXP } from "@investplay/ui"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { Breadcrumbs } from "@/components/layout/Breadcrumbs"; +import { + CheckCircle2, + Circle, + Lock, + Clock, + Sparkles, + ArrowLeft, + ArrowRight, + Zap, +} from "lucide-react"; + +const mockLessons = [ + { id: "1", title: "What is a Budget?", duration: "10 min", xp: 50, completed: true }, + { id: "2", title: "Tracking Your Expenses", duration: "15 min", xp: 50, completed: true }, + { id: "3", title: "Creating Your First Budget", duration: "20 min", xp: 75, completed: false }, + { id: "4", title: "The 50/30/20 Rule", duration: "15 min", xp: 75, completed: false }, + { id: "5", title: "Budgeting Tools & Apps", duration: "10 min", xp: 50, completed: false }, + { id: "6", title: "Module Quiz", duration: "15 min", xp: 100, completed: false }, +]; + +export function ModulePage() { + const { moduleId } = useParams<{ moduleId: string }>(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const moduleTitle = "Budgeting Basics"; + const moduleDescription = "Learn how to create and maintain a personal budget that works for you."; + const completedCount = mockLessons.filter((l) => l.completed).length; + const progress = (completedCount / mockLessons.length) * 100; + + return ( +
+ + + + +
+
+
+ Module +

{moduleTitle}

+

+ {moduleDescription} +

+
+ + + ~1.5 hours + + + + {formatXP(400)} XP total + +
+
+ +
+ +
+
+ {completedCount} of {mockLessons.length} completed + {Math.round(progress)}% +
+ +
+
+ +
+ {mockLessons.map((lesson, index) => { + const isLocked = index > 0 && !mockLessons[index - 1].completed && !lesson.completed; + const isActive = !lesson.completed && !isLocked; + const isFirstUncompleted = isActive && (index === 0 || mockLessons[index - 1].completed); + + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/web/src/pages/NotFoundPage.tsx b/apps/web/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..6c29be6 --- /dev/null +++ b/apps/web/src/pages/NotFoundPage.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Home, ArrowLeft } from "lucide-react"; + +export function NotFoundPage() { + const { t } = useTranslation(); + + return ( +
+
+
404
+
+ +

+ {t("errors.notFound")} +

+ +

+ The page you're looking for doesn't exist or has been moved. + Let's get you back on track. +

+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/pages/PlayPage.tsx b/apps/web/src/pages/PlayPage.tsx new file mode 100644 index 0000000..278f0e6 --- /dev/null +++ b/apps/web/src/pages/PlayPage.tsx @@ -0,0 +1,162 @@ +import { useTranslation } from "react-i18next"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { LeaderboardTable } from "@/components/gamification/LeaderboardTable"; +import { + Wallet, + Siren, + TrendingDown, + PiggyBank, + Home, + Car, + BarChart3, + Heart, + Sparkles, + Clock, + Star, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +interface Challenge { + id: string; + title: string; + description: string; + difficulty: "easy" | "medium" | "hard"; + timeMinutes: number; + icon: LucideIcon; + highScore?: number; + daily?: boolean; +} + +const challenges: Challenge[] = [ + { id: "budget", title: "Budget Challenge", description: "Manage a monthly budget with unexpected expenses.", difficulty: "easy", timeMinutes: 15, icon: Wallet, highScore: 850 }, + { id: "emergency", title: "Emergency Fund", description: "Build an emergency fund while handling life events.", difficulty: "medium", timeMinutes: 20, icon: Siren, highScore: 720, daily: true }, + { id: "crash", title: "Market Crash", description: "Navigate a sudden market downturn without panic selling.", difficulty: "hard", timeMinutes: 25, icon: TrendingDown, highScore: 680 }, + { id: "savings", title: "Savings Habit", description: "Develop consistent saving habits over 12 months.", difficulty: "easy", timeMinutes: 10, icon: PiggyBank }, + { id: "rent-vs", title: "Rent vs. Transport", description: "Optimize your housing and transportation costs.", difficulty: "medium", timeMinutes: 15, icon: Home }, + { id: "debt", title: "Debt Repayment", description: "Pay off debt using snowball or avalanche methods.", difficulty: "medium", timeMinutes: 20, icon: Car }, + { id: "esg", title: "ESG Portfolio", description: "Build a portfolio that balances returns with sustainability.", difficulty: "hard", timeMinutes: 30, icon: Heart }, + { id: "interest", title: "Interest & APY", description: "Master compound interest and APY calculations.", difficulty: "easy", timeMinutes: 10, icon: BarChart3 }, +]; + +const mockLeaderboard = [ + { userId: "1", rank: 1, firstName: "Maria", lastName: "K.", avatarUrl: null, xp: 2840, level: 12, badges: 8, tenantId: "t1" }, + { userId: "2", rank: 2, firstName: "John", lastName: "D.", avatarUrl: null, xp: 2560, level: 11, badges: 6, tenantId: "t1" }, + { userId: "3", rank: 3, firstName: "Elena", lastName: "P.", avatarUrl: null, xp: 2320, level: 10, badges: 7, tenantId: "t1" }, + { userId: "4", rank: 4, firstName: "Alex", lastName: "M.", avatarUrl: null, xp: 2100, level: 9, badges: 5, tenantId: "t1" }, + { userId: "5", rank: 5, firstName: "Sarah", lastName: "W.", avatarUrl: null, xp: 1950, level: 8, badges: 4, tenantId: "t1" }, +]; + +const difficultyColors = { + easy: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-100", + medium: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-100", + hard: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-100", +}; + +export function PlayPage() { + const { t } = useTranslation(); + + const dailyChallenge = challenges.find((c) => c.daily); + + return ( +
+
+

{t("nav.play")}

+

+ Test your skills with interactive challenges +

+
+ + {dailyChallenge && ( + + +
+
+
+ +
+
+
+ + + Daily Challenge + +
+

{dailyChallenge.title}

+

{dailyChallenge.description}

+
+ + + {dailyChallenge.timeMinutes} min + + + {dailyChallenge.difficulty} + +
+
+
+ +
+
+
+ )} + +
+ {challenges.map((challenge) => ( + + +
+
+ +
+ + {challenge.difficulty} + +
+ +

{challenge.title}

+

+ {challenge.description} +

+ +
+ + + {challenge.timeMinutes} min + + {challenge.highScore && ( + + + {challenge.highScore} + + )} +
+
+
+ ))} +
+ + + + +
+

Leaderboard

+ + Global + Classroom + +
+ + + + + + +
+
+
+
+ ); +} diff --git a/apps/web/src/pages/PracticePage.tsx b/apps/web/src/pages/PracticePage.tsx new file mode 100644 index 0000000..6942ff0 --- /dev/null +++ b/apps/web/src/pages/PracticePage.tsx @@ -0,0 +1,176 @@ +import { useTranslation } from "react-i18next"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { + BarChart3, + Calculator, + PiggyBank, + Landmark, + TrendingUp, + TrendingDown, + ArrowRight, + Wallet, +} from "lucide-react"; + +export function PracticePage() { + const { t } = useTranslation(); + + return ( +
+
+

{t("nav.practice")}

+

+ Apply your knowledge with hands-on tools +

+
+ + + +
+
+
+ +
+
+

Your Portfolio

+

€12,450.00

+
+ + + +3.2% + + All time return +
+
+
+ +
+ +
+
+

Invested

+

€10,000

+
+
+

Gain

+

€2,450

+
+
+

Dividends

+

€320

+
+
+
+
+ + + + Calculators + Decision Labs + Statistics + + + +
+ + +
+ +
+

Budget Planner

+

+ Plan your monthly budget with the 50/30/20 rule +

+
+
+ + + +
+ +
+

Savings Calculator

+

+ See how compound interest grows your savings +

+
+
+ + + +
+ +
+

Loan Calculator

+

+ Compare loan options and total interest costs +

+
+
+
+
+ + +
+ + +

Risk Tolerance Lab

+

+ Discover your risk profile through interactive scenarios +

+ +
+
+ + +

Diversification Lab

+

+ Build a diversified portfolio and analyze correlations +

+ +
+
+
+
+ + +
+ + + Avg. Lesson Score + + +

85%

+ +
+
+ + + Simulations Played + + +

24

+

12 this month

+
+
+ + + Challenges Won + + +

8

+

of 15 attempted

+
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/pages/ProfilePage.tsx b/apps/web/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..dc6909d --- /dev/null +++ b/apps/web/src/pages/ProfilePage.tsx @@ -0,0 +1,160 @@ +import { useTranslation } from "react-i18next"; +import { cn, formatXP } from "@investplay/ui"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { XPBar } from "@/components/gamification/XPBar"; +import { StreakCounter } from "@/components/gamification/StreakCounter"; +import { BadgeGrid } from "@/components/gamification/BadgeGrid"; +import { useAuth } from "@/hooks/useAuth"; +import { useGamificationStore } from "@/stores/gamification.store"; +import { + BookOpen, + Gamepad2, + Trophy, + Clock, + Calendar, +} from "lucide-react"; + +const activityTimeline = [ + { type: "lesson", label: "Completed: Budgeting Basics", date: "2 hours ago", icon: BookOpen }, + { type: "challenge", label: "Scored 85% on Emergency Fund", date: "1 day ago", icon: Gamepad2 }, + { type: "badge", label: "Earned: First Steps badge", date: "3 days ago", icon: Trophy }, + { type: "lesson", label: "Completed: Credit Scores 101", date: "5 days ago", icon: BookOpen }, + { type: "challenge", label: "Completed Budget Challenge", date: "1 week ago", icon: Gamepad2 }, +]; + +export function ProfilePage() { + const { t } = useTranslation(); + const { user } = useAuth(); + const { xp, level, streaks, badges } = useGamificationStore(); + + const userInitials = user?.firstName && user?.lastName + ? `${user.firstName[0]}${user.lastName[0]}` + : "U"; + + const earnedBadgeIds = new Set(badges.map((b) => b.id)); + + return ( +
+ + +
+ + {user?.avatarUrl && } + {userInitials} + + +
+

+ {user?.firstName} {user?.lastName} +

+

{user?.email}

+
+ + {t("gamification:level.current", { level })} + + +
+
+ +
+
+

{formatXP(xp)}

+

Total XP

+
+
+

{badges.length}

+

{t("gamification:badges.title")}

+
+
+

12

+

Lessons

+
+
+
+ + +
+
+ +
+ + + {t("gamification:badges.title")} + + + {badges.length > 0 ? ( + + ) : ( +
+ +

{t("gamification:achievements.empty")}

+
+ )} +
+
+ + + + + + Recent Activity + + + +
+ {activityTimeline.map((item, i) => ( +
+
+
+ +
+ {i < activityTimeline.length - 1 && ( +
+ )} +
+
+

{item.label}

+

{item.date}

+
+
+ ))} +
+ + +
+ + + + + + Learning Stats + + + +
+ {[ + { label: "Lessons Completed", value: "12", icon: BookOpen }, + { label: "Challenges Played", value: "15", icon: Gamepad2 }, + { label: "Simulations Run", value: "8", icon: Trophy }, + { label: "Current Streak", value: `${streaks.current}d`, icon: Calendar }, + ].map((stat) => ( +
+
+ +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..119ccd4 --- /dev/null +++ b/apps/web/src/pages/SettingsPage.tsx @@ -0,0 +1,216 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { Separator } from "@/components/ui/separator"; +import { LanguageSwitcher } from "@/components/shared/LanguageSwitcher"; +import { ThemeToggle } from "@/components/shared/ThemeToggle"; +import { ConfirmDialog } from "@/components/shared/ConfirmDialog"; +import { useAuth } from "@/hooks/useAuth"; +import { + User, + Bell, + Shield, + Building2, + Download, + Trash2, +} from "lucide-react"; + +export function SettingsPage() { + const { t } = useTranslation(); + const { user } = useAuth(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const userInitials = user?.firstName && user?.lastName + ? `${user.firstName[0]}${user.lastName[0]}` + : "U"; + + const handleDeleteAccount = () => { + console.log("Delete account requested"); + setDeleteDialogOpen(false); + }; + + return ( +
+
+

{t("nav.settings")}

+

+ Manage your account preferences +

+
+ + + + + + Profile + + Update your personal information + + +
+ + {user?.avatarUrl && } + {userInitials} + + +
+ +
+ + +
+ +
+ + +
+
+
+ + + + + Preferences + + Customize your experience + + +
+
+ +

+ Choose your preferred language +

+
+ +
+ +
+
+ +

+ Choose between light, dark, or system theme +

+
+ +
+
+
+ + + + + + Notifications + + Manage your notification preferences + + + {["Lesson reminders", "Challenge invitations", "New badges and achievements", "Classroom announcements"].map( + (item) => ( +
+ + +
+ ), + )} +
+
+ + + + + + Privacy & Data + + + Manage your data and privacy settings (GDPR) + + + +
+
+

Export My Data

+

+ Download all your personal data in a machine-readable format +

+
+ +
+ +
+
+

Delete Account

+

+ Permanently delete your account and all associated data +

+
+ +
+
+
+ + + + + + Institution + + Your connected institution information + + +
+

Not connected to any institution

+

+ Join a classroom using an invite code to connect to your institution. +

+
+
+
+ + +
+ ); +} diff --git a/apps/web/src/stores/auth.store.ts b/apps/web/src/stores/auth.store.ts new file mode 100644 index 0000000..d66dc83 --- /dev/null +++ b/apps/web/src/stores/auth.store.ts @@ -0,0 +1,67 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { User } from "@investplay/types"; + +interface AuthState { + user: User | null; + accessToken: string | null; + refreshToken: string | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (user: User, accessToken: string, refreshToken: string) => void; + register: (user: User, accessToken: string, refreshToken: string) => void; + logout: () => void; + refreshAuth: (accessToken: string, refreshToken: string) => void; + updateUser: (user: Partial) => void; + setLoading: (loading: boolean) => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + isLoading: true, + login: (user, accessToken, refreshToken) => + set({ + user, + accessToken, + refreshToken, + isAuthenticated: true, + isLoading: false, + }), + register: (user, accessToken, refreshToken) => + set({ + user, + accessToken, + refreshToken, + isAuthenticated: true, + isLoading: false, + }), + logout: () => + set({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false, + }), + refreshAuth: (accessToken, refreshToken) => + set({ accessToken, refreshToken }), + updateUser: (partial) => + set((state) => ({ + user: state.user ? { ...state.user, ...partial } : null, + })), + setLoading: (loading) => set({ isLoading: loading }), + }), + { + name: "investplay-auth", + partialize: (state) => ({ + accessToken: state.accessToken, + refreshToken: state.refreshToken, + }), + }, + ), +); diff --git a/apps/web/src/stores/gamification.store.ts b/apps/web/src/stores/gamification.store.ts new file mode 100644 index 0000000..2120003 --- /dev/null +++ b/apps/web/src/stores/gamification.store.ts @@ -0,0 +1,76 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { Badge, LeaderboardEntry, XPReason } from "@investplay/types"; + +interface GamificationState { + xp: number; + level: number; + streaks: { + current: number; + longest: number; + lastActivityDate: string | null; + }; + badges: Badge[]; + leaderboard: LeaderboardEntry[]; + awardXP: (amount: number, reason: XPReason, referenceId: string) => void; + updateStreak: (current: number, longest: number, lastActivityDate: string) => void; + addBadge: (badge: Badge) => void; + setLeaderboard: (entries: LeaderboardEntry[]) => void; +} + +const XP_PER_LEVEL = 500; + +export function xpToNextLevel(level: number): number { + return XP_PER_LEVEL * level; +} + +export function levelProgress(xp: number, level: number): number { + const needed = xpToNextLevel(level); + const currentLevelXp = (level - 1) * XP_PER_LEVEL * level / 2; + const earnedInLevel = xp - currentLevelXp; + return Math.min(Math.max(earnedInLevel / needed, 0), 1); +} + +export const useGamificationStore = create()( + persist( + (set) => ({ + xp: 0, + level: 1, + streaks: { + current: 0, + longest: 0, + lastActivityDate: null, + }, + badges: [], + leaderboard: [], + awardXP: (amount) => { + set((state) => { + const newXp = state.xp + amount; + let newLevel = state.level; + while (newXp >= xpToNextLevel(newLevel)) { + newLevel++; + } + return { xp: newXp, level: newLevel }; + }); + }, + updateStreak: (current, longest, lastActivityDate) => + set({ streaks: { current, longest, lastActivityDate } }), + addBadge: (badge) => + set((state) => ({ + badges: state.badges.some((b) => b.id === badge.id) + ? state.badges + : [...state.badges, badge], + })), + setLeaderboard: (entries) => set({ leaderboard: entries }), + }), + { + name: "investplay-gamification", + partialize: (state) => ({ + xp: state.xp, + level: state.level, + streaks: state.streaks, + badges: state.badges, + }), + }, + ), +); diff --git a/apps/web/src/stores/ui.store.ts b/apps/web/src/stores/ui.store.ts new file mode 100644 index 0000000..4373cc0 --- /dev/null +++ b/apps/web/src/stores/ui.store.ts @@ -0,0 +1,52 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export type ThemeMode = "light" | "dark" | "system"; + +interface UIState { + sidebarCollapsed: boolean; + theme: ThemeMode; + locale: string; + toggleSidebar: () => void; + setTheme: (theme: ThemeMode) => void; + setLocale: (locale: string) => void; +} + +function applyTheme(theme: ThemeMode): void { + const root = document.documentElement; + root.classList.remove("light", "dark"); + + if (theme === "system") { + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + root.classList.add(prefersDark ? "dark" : "light"); + } else { + root.classList.add(theme); + } +} + +export const useUIStore = create()( + persist( + (set, get) => ({ + sidebarCollapsed: false, + theme: "system", + locale: "en", + toggleSidebar: () => + set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })), + setTheme: (theme) => { + applyTheme(theme); + set({ theme }); + }, + setLocale: (locale) => set({ locale }), + }), + { + name: "investplay-ui", + onRehydrateStorage: () => { + return (state) => { + if (state) { + applyTheme(state.theme); + } + }; + }, + }, + ), +); diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css new file mode 100644 index 0000000..6b3f8b7 --- /dev/null +++ b/apps/web/src/styles/globals.css @@ -0,0 +1,85 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@layer base { + :root { + --background: 210 40% 98%; + --foreground: 222 47% 11%; + --card: 0 0% 100%; + --card-foreground: 222 47% 11%; + --popover: 0 0% 100%; + --popover-foreground: 222 47% 11%; + --primary: 221 83% 53%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96%; + --secondary-foreground: 222 47% 11%; + --muted: 210 40% 96%; + --muted-foreground: 215 16% 47%; + --accent: 160 60% 45%; + --accent-foreground: 210 40% 98%; + --destructive: 0 84% 60%; + --destructive-foreground: 210 40% 98%; + --border: 214 32% 91%; + --input: 214 32% 91%; + --ring: 221 83% 53%; + --radius: 0.5rem; + } + + .dark { + --background: 222 47% 11%; + --foreground: 210 40% 98%; + --card: 222 47% 11%; + --card-foreground: 210 40% 98%; + --popover: 222 47% 11%; + --popover-foreground: 210 40% 98%; + --primary: 217 91% 60%; + --primary-foreground: 222 47% 11%; + --secondary: 217 33% 17%; + --secondary-foreground: 210 40% 98%; + --muted: 217 33% 17%; + --muted-foreground: 215 20% 65%; + --accent: 160 60% 45%; + --accent-foreground: 210 40% 98%; + --destructive: 0 63% 31%; + --destructive-foreground: 210 40% 98%; + --border: 217 33% 17%; + --input: 217 33% 17%; + --ring: 217 91% 60%; + } + + * { + border-color: hsl(var(--border)); + } + + body { + font-family: "Inter", system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + } + + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.3); + border-radius: 9999px; + } + + ::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.5); + } + + *:focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; + } +} diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 0000000..348573b --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -0,0 +1,81 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: [ + "./index.html", + "./src/**/*.{ts,tsx}", + "../../packages/ui/src/**/*.{ts,tsx}", + ], + darkMode: "class", + theme: { + extend: { + colors: { + primary: { + 50: "#eff6ff", + 100: "#dbeafe", + 200: "#bfdbfe", + 300: "#93c5fd", + 400: "#60a5fa", + 500: "#3b82f6", + 600: "#2563eb", + 700: "#1d4ed8", + 800: "#1e40af", + 900: "#1e3a8a", + 950: "#172554", + }, + accent: { + 50: "#ecfdf5", + 100: "#d1fae5", + 200: "#a7f3d0", + 300: "#6ee7b7", + 400: "#34d399", + 500: "#10b981", + 600: "#059669", + 700: "#047857", + 800: "#065f46", + 900: "#064e3b", + 950: "#022c22", + }, + surface: { + 50: "#f8fafc", + 100: "#f1f5f9", + 200: "#e2e8f0", + 300: "#cbd5e1", + 400: "#94a3b8", + 500: "#64748b", + 600: "#475569", + 700: "#334155", + 800: "#1e293b", + 900: "#0f172a", + 950: "#020617", + }, + }, + fontFamily: { + sans: ["Inter", "system-ui", "-apple-system", "sans-serif"], + mono: ["JetBrains Mono", "Fira Code", "monospace"], + }, + animation: { + "fade-in": "fadeIn 0.3s ease-in-out", + "slide-up": "slideUp 0.3s ease-out", + "slide-down": "slideDown 0.3s ease-out", + }, + keyframes: { + fadeIn: { + "0%": { opacity: "0" }, + "100%": { opacity: "1" }, + }, + slideUp: { + "0%": { transform: "translateY(10px)", opacity: "0" }, + "100%": { transform: "translateY(0)", opacity: "1" }, + }, + slideDown: { + "0%": { transform: "translateY(-10px)", opacity: "0" }, + "100%": { transform: "translateY(0)", opacity: "1" }, + }, + }, + }, + }, + plugins: [], +}; + +export default config; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..d327121 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": "./", + "types": ["vite/client"], + "paths": { + "@investplay/types": ["../../packages/types/src"], + "@investplay/types/*": ["../../packages/types/src/*"], + "@investplay/ui": ["../../packages/ui/src"], + "@investplay/ui/*": ["../../packages/ui/src/*"], + "@investplay/i18n": ["../../packages/i18n/src"], + "@investplay/i18n/*": ["../../packages/i18n/src/*"], + "@investplay/utils": ["../../packages/utils/src"], + "@investplay/utils/*": ["../../packages/utils/src/*"], + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist", ".turbo"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..ab4a46d --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import path from "path"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@investplay/types": path.resolve(__dirname, "../../packages/types/src"), + "@investplay/ui": path.resolve(__dirname, "../../packages/ui/src"), + "@investplay/i18n": path.resolve(__dirname, "../../packages/i18n/src"), + "@investplay/utils": path.resolve(__dirname, "../../packages/utils/src"), + }, + }, + server: { + port: 3000, + proxy: { + "/api": { + target: "http://localhost:3001", + changeOrigin: true, + }, + "/graphql": { + target: "http://localhost:3001/graphql", + changeOrigin: true, + }, + "/socket.io": { + target: "http://localhost:3001", + ws: true, + }, + }, + }, + build: { + outDir: "dist", + sourcemap: true, + }, +}); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..41c462e --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,155 @@ +name: investplay-dev + +services: + postgres: + image: postgres:16-alpine + container_name: investplay-postgres-dev + restart: unless-stopped + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: ${POSTGRES_USER:-investplay} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-investplay_dev_pass} + POSTGRES_DB: ${POSTGRES_DB:-investplay} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-investplay} -d ${POSTGRES_DB:-investplay}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - investplay-dev + + redis: + image: redis:7-alpine + container_name: investplay-redis-dev + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes --loglevel warning + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - investplay-dev + + minio: + image: minio/minio:latest + container_name: investplay-minio-dev + restart: unless-stopped + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio-data:/data + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin_dev} + command: server /data --console-address :9001 + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + networks: + - investplay-dev + + api: + build: + context: . + dockerfile: apps/api/Dockerfile.dev + container_name: investplay-api-dev + ports: + - "3001:3001" + volumes: + - ./apps/api/src:/app/apps/api/src + - ./apps/api/prisma:/app/apps/api/prisma + - ./packages:/app/packages + - api-node_modules:/app/apps/api/node_modules + environment: + NODE_ENV: development + PORT: 3001 + DATABASE_URL: ${DATABASE_URL:-postgresql://investplay:investplay_dev_pass@postgres:5432/investplay} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + MINIO_ENDPOINT: ${MINIO_ENDPOINT:-minio} + MINIO_PORT: ${MINIO_PORT:-9000} + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin_dev} + MINIO_USE_SSL: ${MINIO_USE_SSL:-false} + JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-in-production} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + networks: + - investplay-dev + + web: + build: + context: . + dockerfile: apps/web/Dockerfile.dev + container_name: investplay-web-dev + ports: + - "5173:5173" + volumes: + - ./apps/web/src:/app/apps/web/src + - ./apps/web/public:/app/apps/web/public + - ./apps/web/index.html:/app/apps/web/index.html + - ./packages:/app/packages + - web-node_modules:/app/apps/web/node_modules + environment: + NODE_ENV: development + VITE_API_URL: ${VITE_API_URL:-http://localhost:3001} + depends_on: + - api + networks: + - investplay-dev + + cms: + build: + context: . + dockerfile: apps/cms/Dockerfile.dev + container_name: investplay-cms-dev + ports: + - "3000:3000" + volumes: + - ./apps/cms/src:/app/apps/cms/src + - ./packages:/app/packages + - cms-node_modules:/app/apps/cms/node_modules + environment: + NODE_ENV: development + PORT: 3000 + DATABASE_URL: ${CMS_DATABASE_URL:-postgresql://investplay:investplay_dev_pass@postgres:5432/investplay_cms} + PAYLOAD_SECRET: ${PAYLOAD_SECRET:-dev-payload-secret-change-in-production} + depends_on: + postgres: + condition: service_healthy + networks: + - investplay-dev + +volumes: + postgres-data: + name: investplay-postgres-data-dev + redis-data: + name: investplay-redis-data-dev + minio-data: + name: investplay-minio-data-dev + api-node_modules: + web-node_modules: + cms-node_modules: + +networks: + investplay-dev: + name: investplay-dev + driver: bridge diff --git a/docker-compose.portainer.yml b/docker-compose.portainer.yml new file mode 100644 index 0000000..411b7b5 --- /dev/null +++ b/docker-compose.portainer.yml @@ -0,0 +1,236 @@ +name: investplay + +services: + postgres: + image: postgres:16-alpine + container_name: investplay-postgres + restart: always + expose: + - "5432" + volumes: + - pgdata:/var/lib/postgresql/data + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-investplay} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB:-investplay}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" + reservations: + memory: 256M + cpus: "0.5" + networks: + - investplay + + redis: + image: redis:7-alpine + container_name: investplay-redis + restart: always + expose: + - "6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes --loglevel warning + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + reservations: + memory: 128M + cpus: "0.25" + networks: + - investplay + + minio: + image: minio/minio:latest + container_name: investplay-minio + restart: always + expose: + - "9000" + - "9001" + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio-data:/data + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + command: server /data --console-address :9001 + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" + reservations: + memory: 256M + cpus: "0.5" + networks: + - investplay + + api: + build: + context: . + dockerfile: apps/api/Dockerfile + container_name: investplay-api + restart: always + expose: + - "3001" + ports: + - "3001:3001" + environment: + NODE_ENV: production + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + MINIO_ENDPOINT: minio + MINIO_PORT: 9000 + MINIO_USE_SSL: false + JWT_SECRET: ${JWT_SECRET} + PORT: 3001 + env_file: + - .env.production + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"] + interval: 30s + timeout: 10s + start_period: 40s + retries: 3 + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" + reservations: + memory: 256M + cpus: "0.5" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + networks: + - investplay + + web: + build: + context: . + dockerfile: apps/web/Dockerfile + container_name: investplay-web + restart: always + expose: + - "80" + ports: + - "80:80" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"] + interval: 30s + timeout: 10s + start_period: 10s + retries: 3 + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + reservations: + memory: 128M + cpus: "0.25" + depends_on: + - api + networks: + - investplay + + cms: + build: + context: . + dockerfile: apps/cms/Dockerfile + container_name: investplay-cms + restart: always + expose: + - "3000" + ports: + - "3000:3000" + environment: + NODE_ENV: production + DATABASE_URL: ${CMS_DATABASE_URL} + PAYLOAD_SECRET: ${PAYLOAD_SECRET} + PORT: 3000 + env_file: + - .env.production + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + start_period: 30s + retries: 3 + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" + reservations: + memory: 256M + cpus: "0.5" + depends_on: + postgres: + condition: service_healthy + networks: + - investplay + + portainer-agent: + image: portainer/agent:latest + container_name: investplay-portainer-agent + restart: always + ports: + - "9001:9001" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - portainer-agent-data:/data + deploy: + resources: + limits: + memory: 128M + cpus: "0.25" + reservations: + memory: 64M + cpus: "0.1" + networks: + - investplay + +volumes: + pgdata: + name: investplay-pgdata + redis-data: + name: investplay-redis-data + minio-data: + name: investplay-minio-data + portainer-agent-data: + name: investplay-portainer-agent-data + +networks: + investplay: + name: investplay + driver: bridge diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..cbd7e05 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,240 @@ +name: investplay-prod + +services: + traefik: + image: traefik:v3.0 + container_name: investplay-traefik + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./docker/traefik/traefik.yml:/etc/traefik/traefik.yml:ro + - ./docker/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro + - letsencrypt:/letsencrypt + - traefik-logs:/var/log + labels: + - "traefik.enable=true" + - "traefik.http.routers.dashboard.rule=Host(`traefik.investplay.app`)" + - "traefik.http.routers.dashboard.service=api@internal" + - "traefik.http.routers.dashboard.middlewares=auth-basic" + - "traefik.http.routers.dashboard.tls=true" + - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt" + networks: + - investplay + - traefik-public + depends_on: + - api + - web + - cms + + postgres: + image: postgres:16-alpine + container_name: investplay-postgres + restart: always + expose: + - "5432" + volumes: + - pgdata:/var/lib/postgresql/data + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-investplay} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB:-investplay}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - investplay + + redis: + image: redis:7-alpine + container_name: investplay-redis + restart: always + expose: + - "6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes --loglevel warning + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - investplay + + minio: + image: minio/minio:latest + container_name: investplay-minio + restart: always + expose: + - "9000" + - "9001" + volumes: + - minio-data:/data + environment: + MINIO_ROOT_USER_FILE: /run/secrets/minio_root_user + MINIO_ROOT_PASSWORD_FILE: /run/secrets/minio_root_password + command: server /data --console-address :9001 + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + secrets: + - minio_root_user + - minio_root_password + networks: + - investplay + + api: + build: + context: . + dockerfile: apps/api/Dockerfile + container_name: investplay-api + restart: always + expose: + - "3001" + environment: + NODE_ENV: production + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + MINIO_ENDPOINT: minio + MINIO_PORT: 9000 + MINIO_USE_SSL: false + JWT_SECRET: ${JWT_SECRET} + PORT: 3001 + env_file: + - .env.production + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"] + interval: 30s + timeout: 10s + start_period: 40s + retries: 3 + labels: + - "traefik.enable=true" + - "traefik.http.routers.api.rule=Host(`api.investplay.app`)" + - "traefik.http.routers.api.entrypoints=websecure" + - "traefik.http.routers.api.tls=true" + - "traefik.http.routers.api.tls.certresolver=letsencrypt" + - "traefik.http.services.api.loadbalancer.server.port=3001" + - "traefik.http.routers.api.middlewares=security-headers,rate-limit,compression" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + networks: + - investplay + + web: + build: + context: . + dockerfile: apps/web/Dockerfile + container_name: investplay-web + restart: always + expose: + - "80" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"] + interval: 30s + timeout: 10s + start_period: 10s + retries: 3 + labels: + - "traefik.enable=true" + - "traefik.http.routers.web.rule=Host(`investplay.app`) || Host(`www.investplay.app`)" + - "traefik.http.routers.web.entrypoints=websecure" + - "traefik.http.routers.web.tls=true" + - "traefik.http.routers.web.tls.certresolver=letsencrypt" + - "traefik.http.services.web.loadbalancer.server.port=80" + - "traefik.http.routers.web.middlewares=security-headers,compression" + depends_on: + - api + networks: + - investplay + + cms: + build: + context: . + dockerfile: apps/cms/Dockerfile + container_name: investplay-cms + restart: always + expose: + - "3000" + environment: + NODE_ENV: production + DATABASE_URL: ${CMS_DATABASE_URL} + PAYLOAD_SECRET: ${PAYLOAD_SECRET} + PORT: 3000 + env_file: + - .env.production + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + start_period: 30s + retries: 3 + labels: + - "traefik.enable=true" + - "traefik.http.routers.cms.rule=Host(`cms.investplay.app`)" + - "traefik.http.routers.cms.entrypoints=websecure" + - "traefik.http.routers.cms.tls=true" + - "traefik.http.routers.cms.tls.certresolver=letsencrypt" + - "traefik.http.services.cms.loadbalancer.server.port=3000" + - "traefik.http.routers.cms.middlewares=security-headers,rate-limit,compression" + depends_on: + postgres: + condition: service_healthy + networks: + - investplay + + portainer-agent: + image: portainer/agent:latest + container_name: investplay-portainer-agent + restart: always + ports: + - "9001:9001" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - portainer-agent-data:/data + networks: + - traefik-public + +secrets: + minio_root_user: + file: ./secrets/minio_root_user.txt + minio_root_password: + file: ./secrets/minio_root_password.txt + +volumes: + pgdata: + name: investplay-pgdata + redis-data: + name: investplay-redis-data + minio-data: + name: investplay-minio-data + letsencrypt: + name: investplay-letsencrypt + traefik-logs: + name: investplay-traefik-logs + portainer-agent-data: + name: investplay-portainer-agent-data + +networks: + investplay: + name: investplay + driver: bridge + internal: true + traefik-public: + name: traefik-public + external: true diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..52b53c5 --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,119 @@ +worker_processes auto; +worker_rlimit_nofile 65535; + +events { + multi_accept on; + worker_connections 65535; + use epoll; +} + +http { + charset utf-8; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + server_tokens off; + log_not_found off; + types_hash_max_size 2048; + client_max_body_size 100M; + + include mime.types; + default_type application/octet-stream; + + access_log /var/log/nginx/access.log combined buffer=512k flush=1m; + error_log /var/log/nginx/error.log warn; + + keepalive_timeout 65; + keepalive_requests 100; + client_body_timeout 60; + client_header_timeout 60; + send_timeout 60; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types + application/javascript + application/json + application/manifest+json + application/xml + image/svg+xml + text/css + text/javascript + text/plain + text/xml; + + # Cache static assets with hashed filenames + location ~* \.(?:css|js|map|json)$ { + root /usr/share/nginx/html; + expires 1y; + access_log off; + add_header Cache-Control "public, immutable, max-age=31536000"; + } + + location ~* \.(?:gif|jpe?g|png|webp|avif|ico|svg|woff2?|eot|ttf|otf)$ { + root /usr/share/nginx/html; + expires 1y; + access_log off; + add_header Cache-Control "public, immutable, max-age=31536000"; + } + + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + + # API proxy + location /api/ { + proxy_pass http://api:3001/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 90; + proxy_buffering off; + } + + location /graphql { + proxy_pass http://api:3001/graphql; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 90; + proxy_buffering off; + } + + # SPA fallback + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + location ~ ^/(\.env|\.git|node_modules|dist) { + deny all; + access_log off; + log_not_found off; + } +} diff --git a/docker/traefik/dynamic.yml b/docker/traefik/dynamic.yml new file mode 100644 index 0000000..af73095 --- /dev/null +++ b/docker/traefik/dynamic.yml @@ -0,0 +1,79 @@ +http: + middlewares: + security-headers: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + stsPreload: true + frameDeny: true + contentTypeNosniff: true + browserXssFilter: true + referrerPolicy: "strict-origin-when-cross-origin" + contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none'; form-action 'self';" + permissionsPolicy: "camera=(), microphone=(), geolocation=(), interest-cohort=()" + customResponseHeaders: + X-Robots-Tag: "noindex, nofollow" + X-Powered-By: "" + + rate-limit: + rateLimit: + average: 100 + burst: 50 + period: 1s + sourceCriterion: + ipStrategy: + depth: 1 + + compression: + compress: + excludedContentTypes: + - text/event-stream + - application/octet-stream + + strip-api-prefix: + stripPrefix: + prefixes: + - /api + + cors: + headers: + accessControlAllowMethods: + - GET + - POST + - PUT + - PATCH + - DELETE + - OPTIONS + accessControlAllowHeaders: + - Content-Type + - Authorization + - X-Requested-With + accessControlAllowOriginList: + - "https://investplay.app" + - "https://www.investplay.app" + - "https://cms.investplay.app" + accessControlMaxAge: 86400 + accessControlAllowCredentials: true + + cms-strip-prefix: + stripPrefix: + prefixes: + - /cms + + auth-basic: + basicAuth: + users: + - "admin:$2y$10$YourGeneratedHashHere" + +tls: + options: + default: + minVersion: VersionTLS12 + cipherSuites: + - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 + - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 + sniStrict: true diff --git a/docker/traefik/traefik.yml b/docker/traefik/traefik.yml new file mode 100644 index 0000000..4b33059 --- /dev/null +++ b/docker/traefik/traefik.yml @@ -0,0 +1,45 @@ +api: + dashboard: true + debug: false + +entryPoints: + web: + address: ":80" + http: + redirections: + entryPoint: + to: websecure + scheme: https + permanent: true + websecure: + address: ":443" + +providers: + docker: + endpoint: "unix:///var/run/docker.sock" + exposedByDefault: false + network: investplay + file: + directory: /etc/traefik + watch: true + +certificatesResolvers: + letsencrypt: + acme: + email: admin@investplay.app + storage: /letsencrypt/acme.json + httpChallenge: + entryPoint: web + +log: + level: INFO + filePath: /var/log/traefik.log + +accessLog: + filePath: /var/log/traefik-access.log + format: json + bufferingSize: 100 + filters: + statusCodes: + - "400-599" + retryAttempts: true diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..2488bda --- /dev/null +++ b/docs/API.md @@ -0,0 +1,472 @@ +# InvestPlay — API Documentation + +## Base URL Structure + +| Environment | Base URL | +|-------------|----------| +| Local development | `http://localhost:3001` | +| Production | `https://api.investplay.app` | +| Staging | `https://api.staging.investplay.app` | + +## Authentication + +### JWT Bearer Token + +All authenticated requests require an `Authorization` header: + +``` +Authorization: Bearer +``` + +Access tokens are RS256-signed JWTs issued by the auth service. They are short-lived (15 minutes in production, 7 days in development). + +### Token Refresh + +When the access token expires, use the refresh token to obtain a new pair: + +``` +POST /auth/refresh +Content-Type: application/json +Cookie: refreshToken= + +Response 200: +{ + "accessToken": "eyJhbG...", + "expiresIn": 900 +} +``` + +The refresh token is stored in an httpOnly, Secure, SameSite=Strict cookie set at login. If the refresh token is also expired, the user must re-authenticate. + +### Token Errors + +| Status | Error Code | Description | +|--------|-----------|-------------| +| 401 | `TOKEN_EXPIRED` | Access token has expired; refresh required | +| 401 | `TOKEN_INVALID` | Token signature verification failed | +| 401 | `TOKEN_MISSING` | No Authorization header provided | +| 403 | `INSUFFICIENT_ROLE` | User lacks required role for this operation | + +## GraphQL Endpoint + +**URL:** `POST /graphql` + +**Content-Type:** `application/json` + +### Example Query + +```graphql +query GetLessonProgress { + me { + id + displayName + xp + level + progress { + completedLessons + totalLessons + currentStreak + } + } + lesson(id: "lesson_abc123") { + title + estimatedMinutes + blocks { + ... on HeroBlock { + headline + subtitle + imageUrl + } + ... on TextBlock { + body + } + ... on QuizBlock { + question + options + correctAnswer + } + } + } +} +``` + +### Example Mutation + +```graphql +mutation SubmitQuiz { + submitQuiz( + lessonId: "lesson_abc123", + answers: [ + { questionId: "q1", selectedOption: 2 }, + { questionId: "q2", selectedOption: 0 } + ] + ) { + ... on QuizResultSuccess { + score + totalQuestions + correctAnswers + xpEarned + passed + } + ... on QuizResultError { + code + message + } + } +} +``` + +### Response Format + +```json +{ + "data": { ... }, + "errors": [ + { + "message": "Validation failed", + "extensions": { + "code": "VALIDATION_ERROR", + "field": "answers", + "details": "Each answer must include a valid questionId" + } + } + ] +} +``` + +### Error Codes + +| Extension Code | HTTP Status | Description | +|---------------|-------------|-------------| +| `UNAUTHENTICATED` | 401 | Not authenticated | +| `FORBIDDEN` | 403 | Insufficient permissions | +| `VALIDATION_ERROR` | 400 | Input validation failed | +| `NOT_FOUND` | 404 | Requested resource not found | +| `RATE_LIMITED` | 429 | Too many requests | +| `AI_QUOTA_EXCEEDED` | 429 | AI token budget exhausted for user/tenant | +| `INTERNAL_ERROR` | 500 | Unexpected server error | + +## REST Endpoints + +### Health Check + +``` +GET /health + +Response 200: +{ + "status": "ok", + "uptime": 3600, + "timestamp": "2026-06-12T16:00:00Z", + "services": { + "database": "healthy", + "redis": "healthy", + "storage": "healthy", + "cms": "healthy" + } +} +``` + +Used by Docker health checks and monitoring. + +### Auth Callback (Clerk) + +``` +POST /auth/callback +Content-Type: application/json + +Request: +{ + "code": "oauth_code_from_clerk", + "redirectUri": "https://app.investplay.app/auth/callback" +} + +Response 200: +{ + "accessToken": "eyJhbG...", + "refreshToken": "eyJhbG...", + "user": { + "id": "user_abc", + "email": "student@example.com", + "displayName": "Alex", + "locale": "en", + "onboardingComplete": false + } +} +``` + +### Auth Logout + +``` +POST /auth/logout + +Response 200: +{ + "message": "Logged out successfully" +} +``` + +Clears refresh token cookie and invalidates the session. + +### Stripe Webhook + +``` +POST /webhooks/stripe +Content-Type: application/json +Stripe-Signature: + +Handles: checkout.session.completed, invoice.paid, customer.subscription.updated +Response 200: { "received": true } +``` + +### Clerk Webhook + +``` +POST /webhooks/clerk +Content-Type: application/json +Svix-Signature: + +Handles: user.created, user.updated, user.deleted, session.created +Response 200: { "received": true } +``` + +### Resend Webhook + +``` +POST /webhooks/resend +Content-Type: application/json + +Handles: email.delivered, email.bounced, email.complained +Response 200: { "received": true } +``` + +## WebSocket Endpoints + +### Simulation Tick Stream + +``` +WebSocket: wss://api.investplay.app/simulations +Auth: { token: "" } +``` + +**Events:** + +| Event | Direction | Payload | Description | +|-------|-----------|---------|-------------| +| `join` | Client -> Server | `{ simulationId: string }` | Join a simulation room | +| `leave` | Client -> Server | `{ simulationId: string }` | Leave a simulation room | +| `tick` | Server -> Client | `{ timestamp, price, volume, indicator }` | Market price tick | +| `status` | Server -> Client | `{ status, speed, elapsed }` | Simulation status change | +| `complete` | Server -> Client | `{ finalPnl, totalTrades, roi }` | Simulation ended | + +### AI Coach Stream + +``` +WebSocket: wss://api.investplay.app/ai-coach +Auth: { token: "" } +``` + +**Events:** + +| Event | Direction | Payload | Description | +|-------|-----------|---------|-------------| +| `message` | Client -> Server | `{ content, contextId?, lessonId? }` | Send chat message | +| `response` | Server -> Client | `{ content, done, tokensUsed }` | Streaming AI response chunk | +| `error` | Server -> Client | `{ code, message }` | Error (quota exceeded, etc.) | + +### Classroom Broadcast + +``` +WebSocket: wss://api.investplay.app/classroom +Auth: { token: "" } +``` + +**Events:** + +| Event | Direction | Payload | Description | +|-------|-----------|---------|-------------| +| `join` | Client -> Server | `{ classroomId: string }` | Join classroom room | +| `broadcast` | Teacher -> Server | `{ type, payload }` | Teacher broadcasts event | +| `event` | Server -> Student | `{ type, payload, timestamp }` | Event delivered to students | + +## Rate Limiting + +### Limits + +| Endpoint | Window | Limit | Scope | +|----------|--------|-------|-------| +| `/graphql` | 60 seconds | 100 requests | Per IP address | +| `/auth/*` | 60 seconds | 20 requests | Per IP address | +| `/webhooks/*` | 60 seconds | 50 requests | Per IP address | +| AI Chat | 1 day | 20 messages (default) | Per user | +| AI Tokens | 1 month | 500,000 tokens (default) | Per tenant | + +### Rate Limit Headers + +All responses include rate limit headers: + +``` +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 95 +X-RateLimit-Reset: 1718123456 +``` + +When exceeded: + +``` +Status: 429 Too Many Requests +Retry-After: 45 +X-RateLimit-Reset: 1718123456 + +{ + "error": { + "code": "RATE_LIMITED", + "message": "Too many requests. Please wait 45 seconds.", + "retryAfter": 45 + } +} +``` + +AI-specific limits return: + +``` +Status: 429 Too Many Requests +X-AI-Quota-Type: daily | monthly +X-AI-Quota-Reset: 1718123456 + +{ + "error": { + "code": "AI_QUOTA_EXCEEDED", + "message": "Daily AI message limit reached. Resets in 4 hours.", + "quotaType": "daily", + "resetsAt": "2026-06-13T00:00:00Z" + } +} +``` + +## Pagination + +All list queries follow the Relay Connection specification: + +```graphql +type LessonConnection { + edges: [LessonEdge!]! + pageInfo: PageInfo! +} + +type LessonEdge { + node: Lesson! + cursor: String! +} + +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String +} +``` + +### Pagination Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `first` | Int | 20 | Number of items to fetch (max: 100) | +| `after` | String | null | Cursor for forward pagination | +| `last` | Int | null | Number of items backward (use with `before`) | +| `before` | String | null | Cursor for backward pagination | + +### Example with Pagination + +```graphql +query GetLessons { + lessons( + moduleId: "module_xyz", + first: 10, + after: "cursor_abc" + ) { + edges { + cursor + node { + id + title + order + estimatedMinutes + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +``` + +## Error Codes + +### Standard Error Format + +```json +{ + "error": { + "code": "ERROR_CODE", + "message": "Human-readable description", + "details": {}, + "requestId": "req_abc123" + } +} +``` + +### Global Error Codes + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `VALIDATION_ERROR` | 400 | Input validation failed | +| `UNAUTHENTICATED` | 401 | Authentication required | +| `TOKEN_EXPIRED` | 401 | Access token expired | +| `TOKEN_INVALID` | 401 | Invalid or malformed token | +| `FORBIDDEN` | 403 | Insufficient permissions | +| `NOT_FOUND` | 404 | Resource not found | +| `CONFLICT` | 409 | Resource conflict (duplicate) | +| `RATE_LIMITED` | 429 | Rate limit exceeded | +| `AI_QUOTA_EXCEEDED` | 429 | AI budget exhausted | +| `TENANT_QUOTA_EXCEEDED` | 429 | Tenant-level quota exceeded | +| `INTERNAL_ERROR` | 500 | Unexpected server error | +| `SERVICE_UNAVAILABLE` | 503 | Dependent service unavailable | + +### Validation Error Details + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Input validation failed", + "details": { + "fields": { + "email": "Invalid email format", + "password": "Password must be at least 8 characters" + } + } + } +} +``` + +## Versioning Strategy + +### API Versioning + +GraphQL does not use URL versioning. Instead: + +1. **Schema evolution** — Fields and types are added without removal. Deprecated fields are marked with `@deprecated` directive and maintained for a minimum of 90 days. +2. **Breaking changes** — Announced via Changelog and GitHub Releases. A 30-day migration window is provided. +3. **No v1/v2 URLs** — A single `/graphql` endpoint evolves over time. If a breaking change is unavoidable, a new endpoint `/graphql/v2` may be introduced and maintained alongside the original for 6 months. + +### REST Versioning + +REST endpoints (webhooks, health) are not versioned at the URL level. Breaking changes to webhook payloads would result in a new webhook event type (e.g., `invoice.paid.v2`) with the old event maintained for 90 days. + +## Related Documentation + +- [ARCHITECTURE.md](./ARCHITECTURE.md) — System architecture and multi-tenancy deep dive +- [CONTEXT.md](./CONTEXT.md) — Project context, tech stack, and development workflow +- [LOCALIZATION.md](./LOCALIZATION.md) — Locale support and i18n API details diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f660045 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,638 @@ +# InvestPlay — Architecture + +## System Overview + +```mermaid +graph TB + subgraph Clients + WEB["Web SPA (React + Vite)"] + MOBILE["Mobile (Capacitor)"] + DESKTOP["Desktop (Tauri)"] + end + + subgraph Edge + CF["Cloudflare (DNS + CDN)"] + TRAEFIK["Traefik (Reverse Proxy + SSL)"] + end + + subgraph Backend_Services + API["NestJS API Server"] + WS["Socket.io Gateway"] + WORKER["BullMQ Workers"] + GRAPHQL["Apollo GraphQL"] + end + + subgraph Data_Layer + PG[("PostgreSQL 16
Multi-schema")] + REDIS[("Redis 7
Cache + Queue")] + S3[("S3 Storage
MinIO / R2")] + end + + subgraph External + CLERK["Clerk Auth"] + STRIPE["Stripe Connect"] + RESEND["Resend Email"] + LLM["OpenAI / Gemini"] + CMS_EXTERNAL["Payload CMS"] + end + + WEB --> CF + MOBILE --> CF + DESKTOP --> CF + CF --> TRAEFIK + TRAEFIK --> API + TRAEFIK --> WS + + API --> GRAPHQL + API --> WS + API --> WORKER + + GRAPHQL --> PG + WS --> REDIS + WORKER --> REDIS + WORKER --> PG + + API --> CLERK + API --> STRIPE + API --> RESEND + API --> LLM + API --> CMS_EXTERNAL + + MOBILE -.-> CLERK +``` + +## Request Flow + +### Typical API Request + +```mermaid +sequenceDiagram + participant Client as Browser / Mobile + participant CF as Cloudflare + participant Traefik as Traefik Proxy + participant Nest as NestJS API + participant Guard as Auth Guard + participant Tenant as Tenant Interceptor + participant Resolver as GraphQL Resolver + participant Prisma as Prisma ORM + participant DB as PostgreSQL + + Client->>CF: HTTPS Request + CF->>Traefik: Proxy Request + Traefik->>Nest: Reverse Proxy + + Note over Nest: Middleware Pipeline + + Nest->>Guard: JWT Validation + Guard->>Guard: Verify RS256 Signature + Guard->>Guard: Extract tenantId from JWT + Guard-->>Nest: User + Tenant Context + + Nest->>Tenant: Set TenantContext + Tenant->>Tenant: AsyncLocalStorage.set(tenantId) + Tenant-->>Nest: Context Ready + + Nest->>Resolver: Execute Query/Mutation + Resolver->>Prisma: Query with tenant scope + Prisma->>DB: SELECT * FROM "tenant_schema"."table" + DB-->>Prisma: Results + Prisma-->>Resolver: Data + + Resolver-->>Nest: Response + Nest-->>Traefik: JSON Response + Traefik-->>CF: Proxy Response + CF-->>Client: Response +``` + +## Multi-tenancy Deep Dive + +### Approach: Schema-level Isolation + +Each tenant (institution) receives an isolated PostgreSQL schema. This provides stronger data separation than row-level security (RLS) while sharing the same database server for operational simplicity. + +### Tenant Resolution Pipeline + +1. **JWT issuance:** Auth service embeds `tenantId` claim in the JWT access token at login +2. **HTTP request arrives:** Traefik proxies to NestJS +3. **JwtAuthGuard:** Extracts `tenantId` from validated token payload +4. **TenantInterceptor:** Sets `TenantContext` in Node.js `AsyncLocalStorage` +5. **Prisma middleware:** Intercepts all queries and rewrites them to the correct schema: + ```typescript + // Conceptual — Prisma middleware applies schema filter + prisma.$extends({ + query: { + $allModels: { + async $allOperations({ model, operation, args, query }) { + const tenantId = TenantContext.get(); + if (tenantId && !args.where) args.where = {}; + args.where.tenantId = tenantId; + return query(args); + }, + }, + }, + }); + ``` +6. **Post-Schema routing:** Prisma connects to the tenant-specific schema via `schema` parameter in `DATABASE_URL` + +### Tenant Provisioning + +```mermaid +flowchart LR + A[New Institution Signs Up] --> B[Create Tenant Record] + B --> C[Provision PostgreSQL Schema] + C --> D[Run Migrations on Schema] + D --> E[Create Admin User] + E --> F[Configure Feature Flags] + F --> G[Tenant Ready] +``` + +### Shared vs Isolated Data + +| Data | Location | Isolation | +|------|----------|-----------| +| User profiles, roles | Per-tenant schema | Full | +| Lesson progress, grades | Per-tenant schema | Full | +| Virtual portfolios, trades | Per-tenant schema | Full | +| AI interaction logs | Per-tenant schema | Full | +| Curriculum content | Shared (public schema) | Read-only | +| Platform configuration | Shared (public schema) | Read-only | +| Email templates | Shared (public schema) | Read-only | +| Badges, achievements | Shared (public schema) | Read-only | + +## Authentication Flow + +```mermaid +sequenceDiagram + participant User + participant Client + participant API + participant Clerk + participant DB + + User->>Client: Click "Sign In" + Client->>Clerk: Redirect to Clerk Hosted UI + User->>Clerk: Enter credentials / Social Login + Clerk-->>Client: Authorization code + Client->>API: POST /auth/callback (code) + API->>Clerk: Verify code + Clerk-->>API: User info + external ID + API->>DB: Find or create user + API->>API: Generate JWT (RS256 signed) + Note over API: Payload: userId, tenantId, roles, schoolId + API-->>Client: { accessToken, refreshToken, user } + Client->>Client: Store tokens (memory + httpOnly cookie) + + Note over Client,API: Subsequent requests + + Client->>API: Authorization: Bearer + API->>API: Verify JWT signature + API->>API: Check expiry + API-->>Client: 200 OK | 401 Unauthorized + + Note over Client,API: Token Refresh + + Client->>API: POST /auth/refresh (refreshToken) + API->>API: Verify refresh token + API-->>Client: { accessToken, refreshToken } +``` + +### JWT Token Structure + +```json +{ + "sub": "user_2abc123", + "tenantId": "tenant_45def", + "roles": ["student", "premium"], + "schoolId": "school_78ghi", + "iat": 1718000000, + "exp": 1718000900 +} +``` + +- **Algorithm:** RS256 (asymmetric) — private key signs, public key verifies +- **Access token TTL:** 15 minutes (production) / 7 days (development) +- **Refresh token TTL:** 7 days (production) / 30 days (development) +- **Storage:** Access token in memory (Zustand store), refresh token in httpOnly cookie + +## GraphQL Schema Design Principles + +### Structure + +- **Single endpoint:** `POST /graphql` for all queries and mutations +- **Namespace by module:** `auth { ... }`, `curriculum { ... }`, `simulation { ... }` +- **Pagination:** Relay-style `Connection` pattern with `first`/`after` cursors + +### Design Rules + +1. Every query must be nullable-safe — return `null` or `[]` never throw +2. Mutations return a union type: `SuccessPayload | ErrorPayload` +3. Depth limited to 7 levels (enforced via `graphql-depth-limit`) +4. Query complexity analysis for cost-based rate limiting + +### Example Schema Excerpt + +```graphql +type Query { + me: User + lesson(id: ID!): Lesson + lessons(moduleId: ID!, first: Int, after: String): LessonConnection + portfolio(simulationId: ID!): Portfolio + leaderboard(classroomId: ID!, timeRange: TimeRange): [LeaderboardEntry!] +} + +type Mutation { + startLesson(lessonId: ID!): LessonSessionPayload + submitQuiz(lessonId: ID!, answers: [AnswerInput!]!): QuizResultPayload + executeTrade(simulationId: ID!, input: TradeInput!): TradePayload + askAICoach(message: String!, contextId: ID): AICoachPayload +} + +type User { + id: ID! + email: String! + displayName: String! + locale: String! + xp: Int! + level: Int! + streak: Int! + roles: [Role!]! + progress: UserProgress +} +``` + +## WebSocket Architecture + +### Namespaces + +| Namespace | Authentication | Purpose | +|-----------|---------------|---------| +| `/simulations` | JWT handshake | Stream historical price ticks | +| `/ai-coach` | JWT handshake | Stream AI chat responses | +| `/classroom` | JWT handshake + teacher role | Live classroom broadcasts | + +### Connection Lifecycle + +```mermaid +sequenceDiagram + participant Client + participant WS as Socket.io Gateway + participant Auth as AuthGuard + participant Redis as Redis Adapter + participant Room as Room Manager + + Client->>WS: Connect (auth: { token }) + WS->>Auth: Verify JWT + Auth-->>WS: { userId, tenantId, roles } + WS->>Redis: Register connection + WS-->>Client: Connected (socket.id) + + Client->>WS: join(simulation:sim_123) + WS->>Room: Add to room + WS-->>Client: Joined room + + Note over Client,Room: Tick streaming begins + + Client->>WS: leave(simulation:sim_123) + WS->>Room: Remove from room + WS-->>Client: Left room + + Client->>WS: Disconnect + WS->>Redis: Unregister connection +``` + +### Simulation Tick Streaming + +1. Teacher or timer starts a simulation scenario +2. BullMQ worker loads historical CSV data for the scenario +3. Worker emits ticks to Redis pub/sub channel at configured speed (1x, 2x, 5x) +4. Socket.io gateway subscribes to Redis channel and broadcasts to room members +5. Each tick: `{ timestamp, price, volume, indicator }` +6. Frontend updates charts in real-time via Recharts + +## Background Job Architecture + +### BullMQ Queues + +| Queue | Concurrency | Description | +|-------|-------------|-------------| +| `simulation-tick` | 5 | Generate and distribute simulation price ticks | +| `portfolio-valuation` | 3 | End-of-day portfolio P&L calculation | +| `email` | 10 | Send transactional emails via Resend | +| `analytics-rollup` | 2 | Nightly analytics materialized view refresh | +| `leaderboard` | 1 | Recalculate leaderboard rankings | +| `ai-audit-log` | 20 | Persist AI interaction logs asynchronously | +| `cleanup` | 1 | Soft-delete expired sessions, rotate logs | + +### Queue Architecture + +```mermaid +flowchart LR + subgraph Producers + API[NestJS API] + WORKER_SCHED["Scheduled Tasks
@nestjs/schedule"] + end + + subgraph Redis + QUEUES[(BullMQ Queues)] + end + + subgraph Consumers + QW1[Worker: simulation-tick] + QW2[Worker: portfolio-valuation] + QW3[Worker: email] + QW4[Worker: analytics-rollup] + end + + API --> QUEUES + WORKER_SCHED --> QUEUES + QUEUES --> QW1 + QUEUES --> QW2 + QUEUES --> QW3 + QUEUES --> QW4 +``` + +Each worker runs in the same NestJS process via `@nestjs/bullmq` or can be spun as a separate process for scale. + +## Content Rendering Pipeline + +```mermaid +flowchart LR + CMS[Payload CMS
Author writes lesson] + API[NestJS API
Serves content] + FRONT[React Frontend
Renders blocks] + USER[User sees content] + + CMS -->|REST API| API + API -->|GraphQL query| FRONT + FRONT -->|BlockRenderer maps types| USER + + subgraph Block Types + HERO[Hero] + TEXT[Text] + QUIZ[Quiz] + CALC[Calculator] + BUDGET[BudgetGame] + TRADE[TradeSimulation] + end + + FRONT --> HERO + FRONT --> TEXT + FRONT --> QUIZ + FRONT --> CALC + FRONT --> BUDGET + FRONT --> TRADE +``` + +### Block Renderer Component + +```typescript +// Conceptual component structure +const BLOCK_COMPONENTS = { + hero: HeroBlock, + text: TextBlock, + quiz: QuizBlock, + calculator: CalculatorBlock, + budgetGame: BudgetGameBlock, + tradeSimulation: TradeSimulationBlock, +} as const; + +function BlockRenderer({ blocks }: { blocks: ContentBlock[] }) { + return blocks.map((block) => { + const Component = BLOCK_COMPONENTS[block.type]; + return ; + }); +} +``` + +## Database Schema Relationships + +### Core Entities + +```mermaid +erDiagram + Tenant ||--o{ User : "belongs to" + Tenant ||--o{ Classroom : "contains" + Tenant ||--o{ Module : "owns" + User ||--o{ UserProgress : "tracks" + User ||--o{ QuizAttempt : "takes" + User ||--o{ Trade : "executes" + User ||--o{ UserBadge : "earns" + User ||--o{ AILog : "generates" + User ||--o{ ClassroomStudent : "enrolled in" + Classroom ||--o{ ClassroomStudent : "has" + Classroom ||--o{ Assignment : "assigns" + Module ||--o{ Lesson : "contains" + Lesson ||--o{ UserProgress : "tracked by" + Lesson ||--o{ QuizAttempt : "tested by" + Simulation ||--o{ Trade : "contains" + Simulation ||--o{ Portfolio : "tracks" + Portfolio ||--o{ Trade : "records" + Badge ||--o{ UserBadge : "awarded" +``` + +### Entity Descriptions + +| Entity | Key Fields | Relationships | +|--------|-----------|---------------| +| `tenants` | id, name, domain, branding, ai_mode, feature_flags | Parent to users, classrooms, modules | +| `users` | id, email, role, locale, tenant_id, xp, level, streak | Child of tenant; parent to progress, trades, badges | +| `classrooms` | id, name, tenant_id, teacher_id, invite_code | Child of tenant; parent to classroom_students | +| `classroom_students` | id, classroom_id, user_id, joined_at | Join table classroom <-> user | +| `modules` | id, cms_id, track, order, tenant_id | Child of tenant; parent to lessons | +| `lessons` | id, cms_id, module_id, order, estimated_minutes | Child of module; parent to progress | +| `user_progress` | id, user_id, lesson_id, status, score, completed_at | Tracks lesson completion | +| `quiz_attempts` | id, user_id, quiz_id, answers, score, timestamp | Stores quiz submissions | +| `simulations` | id, scenario, config, status, started_at | Manages simulation sessions | +| `portfolios` | id, user_id, simulation_id, cash_balance, holdings | Virtual portfolio state | +| `trades` | id, user_id, simulation_id, asset, amount, price, timestamp | Trade execution records | +| `badges` | id, name, description, icon, criteria | Badge definitions | +| `user_badges` | id, user_id, badge_id, earned_at | Awarded badges | +| `ai_logs` | id, tenant_id, user_id, tokens_used, model, timestamp | AI usage audit trail | +| `analytics_events` | id, user_id, event_type, metadata, timestamp | Behavioral telemetry | + +## Security Architecture + +### Defense in Depth + +```mermaid +flowchart LR + subgraph Layer1[Network Edge] + DDoS[DDoS Protection
Cloudflare] + WAF[WAF Rules
Cloudflare] + DNS[DNS Security
Cloudflare] + end + + subgraph Layer2[Reverse Proxy] + TLS[TLS 1.3
Traefik] + RATE[Rate Limiting
Traefik Middleware] + HEADERS[Security Headers
Helmet.js] + CORS[CORS Policy] + end + + subgraph Layer3[Application] + JWT[JWT RS256
Auth Guard] + RBAC[Role-Based Access
Roles Guard] + TENANT[Tenant Isolation
Tenant Guard] + DEPTH[Query Depth
7 Level Max] + VALID[Input Validation
Zod Schemas] + end + + subgraph Layer4[Data] + ENCRYPT[Encryption at Rest
PostgreSQL TDE] + BACKUP[Backup + Point-in-Time
Recovery] + AUDIT[Audit Logging
All Data Access] + end + + DDoS --> WAF + WAF --> DNS + DNS --> TLS + TLS --> RATE + RATE --> HEADERS + HEADERS --> CORS + CORS --> JWT + JWT --> RBAC + RBAC --> TENANT + TENANT --> DEPTH + DEPTH --> VALID + VALID --> ENCRYPT + ENCRYPT --> BACKUP + BACKUP --> AUDIT +``` + +### Security Measures by Layer + +| Layer | Measure | Implementation | +|-------|---------|---------------| +| Edge | DDoS protection | Cloudflare Always-on | +| Edge | WAF | Cloudflare WAF with OWASP ruleset | +| Edge | DNS | Cloudflare DNS with DNSSEC | +| Proxy | TLS 1.3 | Traefik auto-cert via Let's Encrypt | +| Proxy | HSTS | `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` | +| Proxy | Rate limiting | 100 req/s average, 50 burst, per-IP via Traefik middleware | +| Proxy | Security headers | Helmet.js: X-Frame-Options, X-Content-Type-Options, CSP, Permissions-Policy | +| Proxy | CORS | Whitelist per tenant domain, credentials allowed | +| App | Auth | JWT RS256, short-lived access tokens (15m), httpOnly refresh cookies | +| App | RBAC | `@Roles()` decorator guards on every protected resolver | +| App | Tenant isolation | `@TenantScoped()` guard enforces cross-tenant access prevention | +| App | GraphQL safety | Depth limit (7), query complexity analysis, alias limiting | +| App | Input validation | Zod schemas validated on every API boundary | +| App | Prompt injection | Regex/ML-based sanitization before LLM calls | +| Data | Encryption at rest | PostgreSQL TDE or EBS encryption | +| Data | Backup | Daily automated backups, 30-day retention, Point-in-Time Recovery | +| Data | Audit | All data mutations logged with actor, timestamp, diff | + +## Deployment Architecture + +### Production Topology + +```mermaid +graph TB + subgraph Internet + USER[User Browser] + end + + subgraph Contabo_VPS["Contabo VPS (Single Node)"] + subgraph Traefik + T1["Traefik v3
Port 443 (TLS)"] + T2["Port 80 Redirect
-> 443"] + end + + subgraph Docker_Services + API["api:3001
NestJS"] + WEB["web:80
nginx SPA"] + CMS["cms:3000
Payload CMS"] + PG["postgres:5432
PostgreSQL 16"] + RD["redis:6379
Redis 7"] + MINIO["minio:9000
S3 Storage"] + end + + subgraph External_Access + PA["Portainer Agent
:9001"] + end + + subgraph Volumes + V_PG["pgdata"] + V_RD["redis-data"] + V_MI["minio-data"] + V_LE["letsencrypt"] + end + end + + USER -->|HTTPS| T1 + USER -->|HTTP| T2 + + T1 -->|api.investplay.app| API + T1 -->|investplay.app| WEB + T1 -->|cms.investplay.app| CMS + + API --> PG + API --> RD + API --> MINIO + CMS --> PG + CMS --> MINIO + + PA -.->|Docker socket| T1 + + PG --> V_PG + RD --> V_RD + MINIO --> V_MI + T1 --> V_LE +``` + +### CI/CD Pipeline + +```mermaid +flowchart LR + PUSH["Push to main"] --> CI + PR["Pull Request"] --> CI + + subgraph CI[GitHub Actions CI] + LINT[lint] + TYPECHECK[typecheck] + TEST[test] + BUILD[build] + LINT --> BUILD + TYPECHECK --> BUILD + TEST --> BUILD + end + + CI -->|on main| DEPLOY_TRIGGER + + subgraph CD[GitHub Actions Deploy] + DOCKER["Docker Build & Push
api, web, cms"] + TAG["Tag: git-sha, latest"] + PUSH_GHCR["Push to GHCR"] + end + + CD -->|Portainer webhook| PORTAINER + + subgraph PROD[Contabo Server] + PORTAINER["Portainer Agent"] + PULL["docker compose pull"] + UP["docker compose up -d"] + HEALTH["Health Check"] + end + + DEPLOY_TRIGGER --> DOCKER + DOCKER --> TAG + TAG --> PUSH_GHCR +``` + +### Docker Image Tags + +| Service | Image | Tags | +|---------|-------|------| +| API | `ghcr.io/investplay/api` | `latest`, `` | +| Web | `ghcr.io/investplay/web` | `latest`, `` | +| CMS | `ghcr.io/investplay/cms` | `latest`, `` | + +Layer caching via GitHub Actions `cache-from/cache-to` with `type=gha` for each service separately. + +## Scaling Considerations + +- **Current:** Single VPS (Contabo) with Docker Compose — sufficient for MVP to thousands of users +- **Short-term (Phase 3-4):** Separate API workers per CPU core, Redis cluster for pub/sub +- **Medium-term (Phase 6-7):** Horizontal API scaling behind Traefik load balancer, read replicas for PostgreSQL, separate BullMQ worker processes +- **Long-term:** Kubernetes migration, multi-region deployment for EU data residency, database sharding + +See [CONTEXT.md](./CONTEXT.md) for the full project context, tech stack, and development workflow. diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md new file mode 100644 index 0000000..63368bb --- /dev/null +++ b/docs/CONTEXT.md @@ -0,0 +1,320 @@ +# InvestPlay — Project Context + +## Overview + +InvestPlay is a cross-platform financial literacy platform targeting users aged 16-25. It combines interactive educational content, realistic market simulations, gamified progression, and AI-powered coaching to teach investing fundamentals. The platform serves both individual learners (B2C) and educational institutions (B2B) through a multi-tenant architecture with schema-level data isolation. + +### Vision + +Make financial literacy universally accessible and engaging for the next generation of investors, starting with EU markets (Greece initial launch) and expanding globally. + +### Target Users + +| Persona | Age Range | Platform | Key Need | +|---------|-----------|----------|----------| +| Self-directed learner | 16-25 | Web, Mobile | Learn investing at own pace, earn XP, compete on leaderboards | +| Student (classroom) | 14-20 | Web | Complete assigned lessons, participate in live simulations | +| Teacher | 25-55 | Web | Create classrooms, assign curriculum, monitor progress | +| Institution admin | 30-60 | Web, Desktop | White-label platform, manage billing, view analytics | +| Parent (future) | 35-55 | Web, Mobile | Monitor child's progress, manage subscription | + +## Tech Stack + +| Layer | Technology | Purpose | +|-------|-----------|---------| +| **Monorepo** | Turborepo 2.5 + pnpm 9.15 | Orchestrate builds, share code, manage dependencies | +| **Backend** | NestJS 11 + Node.js 20 | Modular API server with DI, guards, interceptors | +| **API Protocol** | GraphQL (Apollo Server 4) | Primary CRUD — type-safe, no over-fetching | +| **Real-time** | Socket.io (NestJS WebSocket gateway) | Live simulations, leaderboards, AI streaming | +| **REST** | Express (NestJS platform) | Webhooks (Stripe, Clerk, Resend), health checks | +| **Frontend** | React 18 + TypeScript + Vite 6 | SPA with fast HMR, tree-shaking, code splitting | +| **CSS** | TailwindCSS 4 + shadcn/ui (Radix primitives) | Design tokens, accessible components, dark mode | +| **CMS** | Payload CMS 3 (self-hosted, headless) | Dynamic block-based content, i18n, media library | +| **Database** | PostgreSQL 16 + Prisma ORM 6 | ACID compliance, JSON support, multi-schema tenancy | +| **Cache/Queue** | Redis 7 + BullMQ (NestJS BullMQ module) | Rate limiting, job queues, session store, leaderboards | +| **Storage** | S3-compatible (MinIO dev / Cloudflare R2 prod) | User avatars, PDF reports, CSV market data | +| **Auth** | Clerk (B2C) / JWT with SAML SSO (B2B) | Social login, passwordless, institutional SSO | +| **Email** | Resend + React Email templates | Welcome emails, progress reports, teacher invites | +| **Billing** | Stripe Connect | B2B seat-based subscriptions, usage metering | +| **AI** | OpenAI / Gemini (via backend proxy) | Socratic AI coach with token budget enforcement | +| **Container** | Docker Compose (dev) + Portainer/Traefik (prod) | Consistent environments, auto-SSL, monitoring | +| **CI/CD** | GitHub Actions -> GHCR -> Portainer webhook | Automated testing, building, and deployment | +| **Testing** | Vitest 3 + Testing Library + Playwright | Unit, integration, and E2E coverage | +| **Desktop** | Tauri v2 (Rust-based, future) | Native Windows/macOS/Linux wrapper | +| **Mobile** | Capacitor (future) | Native iOS/Android wrapper | + +## Directory Structure + +``` +investplay/ +├── apps/ +│ ├── api/ # NestJS backend +│ │ ├── src/ +│ │ │ ├── modules/ +│ │ │ │ ├── auth/ # JWT, SSO, role guards +│ │ │ │ ├── tenant/ # Multi-tenancy, feature flags +│ │ │ │ ├── curriculum/ # CMS-backed lesson delivery +│ │ │ │ ├── simulation/ # Historical tick streaming +│ │ │ │ ├── portfolio/ # Virtual trades, P&L calc +│ │ │ │ ├── ai-coach/ # LLM proxy with cost control +│ │ │ │ ├── analytics/ # Behavioral telemetry +│ │ │ │ ├── classroom/ # Teacher dashboard, assignments +│ │ │ │ ├── gamification/ # XP, badges, streaks +│ │ │ │ └── billing/ # Stripe Connect integration +│ │ │ ├── common/ +│ │ │ │ ├── guards/ # JWT guard, tenant guard, roles guard +│ │ │ │ ├── decorators/ # @CurrentUser, @TenantId +│ │ │ │ ├── filters/ # Global exception filter +│ │ │ │ └── interceptors/ # Logging, timing, tenant injection +│ │ │ ├── config/ # Env-based config modules +│ │ │ └── main.ts # NestJS bootstrap +│ │ ├── prisma/ +│ │ │ ├── schema.prisma # Full data model +│ │ │ └── migrations/ # Prisma migration history +│ │ ├── test/ # E2E test suites +│ │ ├── Dockerfile +│ │ └── package.json +│ ├── web/ # React + Vite frontend +│ │ ├── src/ +│ │ │ ├── components/ # UI components (per feature) +│ │ │ ├── pages/ # Route pages +│ │ │ ├── hooks/ # Custom React hooks +│ │ │ ├── stores/ # Zustand stores +│ │ │ ├── lib/ # Utilities, API client +│ │ │ ├── styles/ # TailwindCSS setup +│ │ │ └── i18n/ # i18next configuration +│ │ ├── public/ # Static assets +│ │ ├── index.html +│ │ ├── vite.config.ts +│ │ ├── Dockerfile +│ │ └── package.json +│ ├── cms/ # Payload CMS +│ │ ├── src/ +│ │ │ ├── collections/ # Content models +│ │ │ ├── globals/ # Site-wide settings +│ │ │ └── payload.config.ts +│ │ ├── Dockerfile +│ │ └── package.json +│ ├── mobile/ # Capacitor (future) +│ └── desktop/ # Tauri (future) +├── packages/ +│ ├── types/ # @investplay/types +│ │ └── src/ +│ │ ├── user.ts +│ │ ├── tenant.ts +│ │ ├── curriculum.ts +│ │ ├── simulation.ts +│ │ ├── portfolio.ts +│ │ └── analytics.ts +│ ├── ui/ # @investplay/ui +│ │ └── src/ +│ │ ├── components/ # shadcn/ui primitives +│ │ └── lib/ # cn(), formatCurrency(), etc. +│ ├── i18n/ # @investplay/i18n +│ │ └── src/ +│ │ ├── locales/ +│ │ │ ├── en/ # English translations +│ │ │ │ ├── common.json +│ │ │ │ ├── finance.json +│ │ │ │ ├── gamification.json +│ │ │ │ ├── classroom.json +│ │ │ │ └── ai-coach.json +│ │ │ └── el/ # Greek translations +│ │ │ ├── common.json +│ │ │ └── ... +│ │ └── index.ts # i18next config +│ └── utils/ # @investplay/utils +│ └── src/ +│ ├── validation.ts # Zod schemas +│ ├── formatting.ts # Currency, date, number +│ ├── constants.ts # App constants +│ └── env.ts # Env validation +├── docker/ +│ ├── traefik/ +│ │ ├── traefik.yml # Static config +│ │ └── dynamic.yml # Dynamic config (middleware, TLS) +│ └── nginx/ +│ └── nginx.conf # Web SPA nginx config +├── scripts/ +│ ├── setup.sh # Initial dev setup +│ ├── healthcheck.sh # Service health verification +│ └── backup.sh # Database backup script +├── docs/ # Project documentation +├── specs/ # Detailed specifications +├── .github/ +│ └── workflows/ +│ ├── ci.yml # Continuous integration +│ ├── deploy.yml # Deployment pipeline +│ └── pr-checks.yml # PR quality gates +├── .env.example # Environment template +├── turbo.json # Turborepo pipeline config +├── pnpm-workspace.yaml # Workspace definition +├── tsconfig.base.json # Shared TS config +├── .eslintrc.js # ESLint root config +├── .prettierrc # Prettier formatting +└── package.json # Root package.json +``` + +## Architecture Patterns + +### Multi-tenancy + +**Approach:** Schema-level isolation via PostgreSQL schemas. + +- Each tenant (institution) gets its own PostgreSQL schema +- `TenantContext` stored in `AsyncLocalStorage` — resolved from JWT `tenantId` claim +- Prisma multi-schema extension for dynamic schema switching +- Global/shared tables (lookups, platform config) in `public` schema +- Connection pooling per schema via Prisma's `connection_limit` +- Tenant resolution pipeline: HTTP request -> JWT middleware -> extract tenantId -> set AsyncLocalStorage -> Prisma middleware applies schema filter + +### API Design + +- **GraphQL** is the primary API for all CRUD operations — single endpoint `/graphql` +- **REST** used only for webhooks (Stripe, Clerk, Resend) and `/health` +- **WebSocket** namespaces for real-time features: `/simulations` (tick streaming), `/ai-coach` (chat streaming), `/classroom` (live teacher broadcasts) +- Input validation via Zod schemas at every API boundary +- All GraphQL queries have depth limiting (max 7) and cost analysis + +### Content Management + +- Payload CMS serves as the single source of truth for all educational content +- Content model: Track -> Module -> Lesson -> Block (dynamic array) +- Block types: Hero, Text, Image, Video, Quiz, Calculator, BudgetGame, TradeSimulation +- Frontend renders content via a `BlockRenderer` component that maps CMS block types to React components +- CMS localization: each content collection supports locale variants + +### AI Proxy Architecture + +- All LLM requests route through the NestJS `AICoachModule` — never direct from client +- Context injection: backend appends portfolio state, lesson context, and user history to the system prompt +- Rate limiting via Redis token bucket (per-user daily, per-tenant monthly) +- BYOK (Bring Your Own Key): institutions can provide their own API key for higher quotas +- PII sanitization: names, emails, phone numbers stripped before sending to LLM +- Audit logging: every AI interaction logged with token count, latency, and model used + +### Security Architecture + +- **Authentication:** JWT RS256 (asymmetric) — access token (15m) + refresh token (7d) +- **Authorization:** Role-based guards (`@Roles(ROLE.TEACHER)`) + tenant scope enforcement +- **GraphQL safety:** Depth limiting (max 7), query cost analysis, persisted operations (future) +- **HTTP hardening:** Helmet.js headers, CORS whitelist per tenant, rate limiting (100 req/min default) +- **WebSocket:** JWT handshake authentication, room-based isolation per tenant +- **Secrets:** Zero secrets in code — all via environment variables, Docker secrets, or secret manager +- **GDPR:** Soft-deletes, PII anonymization pipeline, data export API, retention policies + +## Key Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Monorepo tool | Turborepo | Better DX than Nx for this scale; native ESM support; simpler cache config | +| Package manager | pnpm | Disk-efficient (hard links), strict dependency resolution, workspace protocol | +| Backend framework | NestJS | Modular architecture with DI, decorator-based guards, native GraphQL + WebSocket support | +| Frontend build | Vite (not Next.js) | SPA is sufficient (no SSR SEO needed); faster builds; simpler deployment (nginx static) | +| CMS | Payload CMS (self-hosted) | TypeScript-native, code-first config, block-based content, built-in i18n, no vendor lock-in | +| Database ORM | Prisma | Type-safe queries, migration-first workflow, multi-schema preview feature for tenancy | +| Auth (initial) | Clerk | Fast to integrate for B2C; supports social login, passwordless, webhooks | +| Auth (B2B future) | Auth0 / SAML SSO | Required for institutional customers with Active Directory / Google Workspace | +| API protocol | GraphQL primary | Type-safe contracts, no over-fetching, single endpoint, introspection for DX tools | +| Real-time | Socket.io | Mature, fallback transport, room support, NestJS integration | +| Background jobs | BullMQ + Redis | Persistence, delayed jobs, rate limiting, job events for monitoring | +| Styling | TailwindCSS + shadcn/ui | Utility-first, design tokens, accessible Radix primitives, small bundle | +| i18n | i18next + ICU MessageFormat | Industry standard, pluralization, context, rich text in translations | +| AI cost control | Server-side token bucket | Hard enforcement not reliant on OpenAI budget alerts; Redis is fast and atomic | +| Container registry | GHCR | Free with GitHub, tight integration with Actions, no extra credentials | +| Deployment | Docker Compose + Traefik | Simpler than k8s for current scale; Traefik auto-SSL with Let's Encrypt | +| Desktop (future) | Tauri v2 | Rust-based, 10x smaller binary than Electron, memory-efficient | +| Mobile (future) | Capacitor | Minimal wrapper, access to native APIs, shares web codebase | + +## State Management + +| State Type | Technology | Usage | +|-----------|-----------|-------| +| Server state | @tanstack/react-query | Data fetching, caching, optimistic updates, pagination | +| GraphQL state | Apollo Client (future) | When GraphQL adoption increases; for now REST-like via react-query | +| Client/auth state | Zustand | Auth session, UI preferences (theme, sidebar), gamification (XP, level) | +| Form state | React Hook Form + Zod | All forms with schema validation | +| URL state | React Router v7 | Search params, path params for routing | +| Real-time state | Socket.io client | Live simulation ticks, leaderboard updates, AI chat streaming | + +## Naming Conventions + +| Category | Convention | Example | +|----------|-----------|---------| +| Files (components) | kebab-case | `user-profile.tsx`, `simulation-chart.tsx` | +| Files (modules/classes) | PascalCase | `AuthModule.ts`, `UserService.ts` | +| Variables | camelCase | `isLoading`, `userData` | +| Functions | camelCase | `getUserById()`, `formatCurrency()` | +| React components | PascalCase | `UserProfile`, `SimulationChart` | +| Types | PascalCase | `User`, `TenantConfig` | +| Interfaces | PascalCase (no I prefix) | `UserRepository`, `AuthPayload` | +| Enums | PascalCase (singular) | `Role`, `LessonStatus`, `TenantTier` | +| Database tables | snake_case | `users`, `user_progress`, `classroom_students` | +| Database columns | snake_case | `tenant_id`, `first_name`, `created_at` | +| GraphQL types | PascalCase | `User`, `LessonProgress`, `TradeInput` | +| GraphQL fields | camelCase | `userId`, `portfolioValue`, `totalXp` | +| Environment variables | UPPER_SNAKE_CASE | `DATABASE_URL`, `JWT_SECRET` | + +## Environment Variables + +See [.env.example](../.env.example) for the complete template with all variables and descriptions. + +Key groups: +- **APP** — `NODE_ENV`, `PORT`, `APP_URL`, `API_URL`, `CORS_ORIGINS` +- **DATABASE** — `DATABASE_URL`, `DATABASE_POOL_MIN`, `DATABASE_POOL_MAX` +- **REDIS** — `REDIS_URL`, `REDIS_PASSWORD` +- **JWT** — `JWT_SECRET`, `JWT_EXPIRES_IN`, `JWT_REFRESH_EXPIRES_IN` +- **AUTH** — `CLERK_SECRET_KEY`, `CLERK_WEBHOOK_SECRET`, `AUTH_SSO_ENABLED` +- **AI** — `OPENAI_API_KEY`, `GEMINI_API_KEY`, `AI_DEFAULT_DAILY_LIMIT`, `AI_DEFAULT_MONTHLY_TOKEN_BUDGET` +- **STORAGE** — `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET` +- **EMAIL** — `RESEND_API_KEY`, `EMAIL_FROM` +- **BILLING** — `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` +- **TENANT** — `TENANT_DEFAULT_AI_MODE`, `TENANT_MAX_TOKENS_MONTHLY` +- **i18n** — `DEFAULT_LOCALE`, `SUPPORTED_LOCALES` +- **CMS** — `CMS_API_URL`, `CMS_API_KEY` +- **SECURITY** — `RATE_LIMIT_TTL`, `RATE_LIMIT_MAX`, `GRAPHQL_DEPTH_LIMIT` +- **LOGGING** — `LOG_LEVEL`, `LOG_FORMAT` + +## Testing Strategy + +| Layer | Tool | Scope | Frequency | +|-------|------|-------|-----------| +| Unit | Vitest | Backend services, utils, hooks, formatting | Every commit | +| Integration | Supertest + Vitest | API endpoints, GraphQL resolvers | Every commit | +| Component | Vitest + Testing Library | React component rendering, user interactions | Every commit | +| E2E | Playwright | Critical user flows (login, lesson, simulation) | CI / pre-release | +| Contract | GraphQL schema tests | Schema validation, resolver shape | On schema change | +| Coverage target | 80%+ | Core business logic (services, resolvers, hooks) | CI gate | + +## Development Workflow + +1. Create a feature branch from `main`: + - `git checkout -b feat/my-feature` (new feature) + - `git checkout -b fix/my-bug` (bug fix) +2. Start the development environment: `docker compose -f docker-compose.dev.yml up` +3. Develop iteratively with hot-reload enabled +4. Write tests following TDD cycle: Red -> Green -> Refactor +5. Run quality gates locally: + ```bash + pnpm lint + pnpm typecheck + pnpm test + ``` +6. Commit using conventional commits: + ``` + feat(auth): add SAML SSO login flow + fix(simulation): correct tick timestamp offset + docs(api): update websocket event reference + ``` +7. Push branch and open a Pull Request +8. CI runs automatically: lint -> typecheck -> test -> build +9. PR must pass all checks and receive code review approval +10. Squash merge to `main` (keeps history clean) +11. CI runs again on main, then auto-deploys to production + +## Related Documents + +- [ARCHITECTURE.md](./ARCHITECTURE.md) — System architecture, request flow, multi-tenancy deep dive +- [API.md](./API.md) — API reference, authentication, endpoints, error codes +- [LOCALIZATION.md](./LOCALIZATION.md) — i18n setup, adding locales, ICU formatting +- [DEVELOPMENT-ROADMAP.md](./DEVELOPMENT-ROADMAP.md) — Phase plan and progress tracking diff --git a/docs/DEVELOPMENT-ROADMAP.md b/docs/DEVELOPMENT-ROADMAP.md new file mode 100644 index 0000000..215e24a --- /dev/null +++ b/docs/DEVELOPMENT-ROADMAP.md @@ -0,0 +1,389 @@ +# InvestPlay — Development Roadmap + +> **Status:** Phase 1 — Foundation +> **Last Updated:** 2026-06-12 +> **Target:** Production-grade, multi-tenant, multi-platform financial literacy platform + +--- + +## Architecture Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Monorepo Tool | **Turborepo** | Better DX than Nx for this scale; native ESM support | +| Package Manager | **pnpm** | Disk-efficient, strict dependency resolution | +| Backend Framework | **NestJS** | Modular, DI, GraphQL native, WebSocket integrated | +| Frontend Framework | **React + Vite** | Fast HMR, modern tooling, Capacitor/Tauri compatible | +| CMS | **Payload CMS** (self-hosted) | Code-first, TypeScript-native, block-based content, i18n built-in | +| Database ORM | **Prisma** | Type-safe, migration-first, multi-schema for tenant isolation | +| Cache | **Redis** | BullMQ queues + session store + rate limiting | +| Auth | **Clerk** (initial) → **Auth0** (B2B SSO) | Fast B2C start, SAML SSO for institutions | +| i18n Framework | **next-intl / react-i18next** | ICU message format, locale-aware routing | +| CSS Framework | **TailwindCSS** + **shadcn/ui** | Design tokens, accessible components | +| Containerization | **Docker Compose** (dev) + **Portainer** (prod) | Consistent local/remote, easy management | +| CI/CD | **GitHub Actions** | Free for public repos, Docker layer caching | + +--- + +## Development Phases + +### PHASE 1 — Foundation ✅ COMPLETED (2026-06-12) +**Goal:** Running monorepo, Docker dev environment, auth, basic API, empty UI shell + +- [x] Project documentation and roadmap +- [x] Monorepo structure (Turborepo + pnpm workspaces) +- [x] Docker Compose for local dev (PostgreSQL, Redis, MinIO, Payload CMS) +- [x] Docker Compose for Contabo/Portainer production deployment +- [x] Environment variable system (.env per service, validation via Zod) +- [x] NestJS backend scaffold with core modules (75 files, 9 modules) +- [x] Prisma schema — 19 models, 13 enums, users, tenants, roles, classrooms +- [x] Auth module (JWT + SSO ready) +- [x] Tenant module (multi-tenancy, feature flags) +- [x] React + Vite frontend scaffold (55+ files, 10 pages, 14 UI primitives) +- [x] TailwindCSS + shadcn/ui design system +- [x] i18n infrastructure (EN + EL, 10 locale files, ICU MessageFormat) +- [x] Shared packages: `@investplay/types`, `@investplay/ui`, `@investplay/i18n`, `@investplay/utils` +- [x] Payload CMS scaffold with Docker support +- [x] ESLint + Prettier + Husky pre-commit hooks +- [x] CI pipeline (lint, typecheck, test, build) + deploy + PR checks +- [x] Comprehensive documentation (CONTEXT.md, ARCHITECTURE.md, API.md, LOCALIZATION.md) + +### PHASE 2 — Core Learning (MVP B2C) +**Goal:** Self-guided learner can complete lessons and earn XP + +- [ ] CMS block renderer in frontend (Hero, Text, Quiz, Calculator blocks) +- [ ] Lesson progression engine (track → module → lesson → block) +- [ ] XP and leveling system +- [ ] Badges and achievements +- [ ] Quiz/assessment engine +- [ ] User dashboard with progress visualization +- [ ] Streak tracking +- [ ] B2C auth flow (email + social) + +### PHASE 3 — Simulations & Gamification +**Goal:** Interactive simulations and mini-games work end-to-end + +- [ ] Blind Scenario engine (historical CSV → WebSocket tick stream) +- [ ] Synthetic market generator +- [ ] Portfolio management (virtual trades, ROI calculation) +- [ ] Budget Game (50/30/20 drag-and-drop) +- [ ] Debt Snowball mini-game +- [ ] Hype Trap narrative challenge +- [ ] Anti-cheat mechanisms +- [ ] Simulation leaderboards + +### PHASE 4 — AI Coach +**Goal:** Context-aware educational AI with cost controls + +- [ ] AI proxy service (NestJS → OpenAI/Gemini) +- [ ] Context injection pipeline +- [ ] Token bucket rate limiting (per user, per tenant) +- [ ] BYOK (Bring Your Own Key) mode +- [ ] Socratic prompt engineering +- [ ] Audit logging +- [ ] PII sanitization before LLM calls +- [ ] Graceful fallback when quota exhausted + +### PHASE 5 — Classroom & Institutions (B2B MVP) +**Goal:** Teachers can manage classrooms and institutions can deploy + +- [ ] Teacher dashboard +- [ ] Classroom creation with invite codes +- [ ] Assignment system +- [ ] Live "Time Machine" classroom simulation +- [ ] Cohort analytics and heatmaps +- [ ] Intervention alerts +- [ ] PDF/CSV report generation +- [ ] Institution admin panel +- [ ] White-label configuration +- [ ] Stripe Connect B2B billing +- [ ] SSO integration (SAML/OIDC) + +### PHASE 6 — Mobile & Desktop +**Goal:** Native app wrappers for iOS, Android, Windows, macOS, Linux + +- [ ] Capacitor wrapper (iOS + Android) +- [ ] Push notifications (FCM) +- [ ] Offline lesson caching +- [ ] Tauri v2 wrapper (Windows, macOS, Linux) +- [ ] Desktop-specific features (offline mode, system tray) + +### PHASE 7 — Analytics & Reporting +**Goal:** Data-driven insights for institutions and platform operators + +- [ ] Behavioral telemetry pipeline +- [ ] Aggregated materialized views (nightly rollups) +- [ ] Teacher analytics dashboard +- [ ] PDF/CSV automated report generation +- [ ] GDPR anonymization pipeline +- [ ] Opt-out mechanism for research aggregation +- [ ] "Gen Z Financial Behavior Index" report templates + +### PHASE 8 — Localization Expansion +**Goal:** Full EU language support + +- [ ] Add DE, FR, ES, IT, PT, NL locales +- [ ] CMS content localization workflow +- [ ] Locale-specific financial examples (taxes, regulations) +- [ ] RTL support readiness +- [ ] Locale-aware currency and number formatting + +### PHASE 9 — Production Hardening +**Goal:** Enterprise-grade security, performance, and reliability + +- [ ] Load testing (k6 / Artillery) +- [ ] Penetration testing +- [ ] Database backup/restore automation +- [ ] Disaster recovery plan +- [ ] Multi-region deployment (EU data residency) +- [ ] SOC 2 / ISO 27001 readiness +- [ ] SLA monitoring and alerting + +--- + +## i18n Strategy + +### Implementation +- **UI Strings:** `react-i18next` with ICU MessageFormat in JSON files per locale +- **Educational Content:** Payload CMS localized collections (each lesson has locale variants) +- **Currency/Number Formatting:** `Intl.NumberFormat` (browser-native, no extra library) +- **Date Formatting:** `Intl.DateTimeFormat` with locale-aware relative time + +### Locale File Structure +``` +packages/i18n/src/locales/ + en/ + common.json # Buttons, nav, errors + finance.json # Financial terms, instrument names + gamification.json # XP, badges, achievement names + classroom.json # Teacher dashboard, assignments + ai-coach.json # AI chat UI strings + gr/ + common.json + finance.json + ... +``` + +### Initial Languages (Phase 1) +- English (en) — Primary development language +- Greek (el) — Market entry requirement + +### Expansion Languages (Phase 8) +- German (de), French (fr), Spanish (es), Italian (it), Portuguese (pt), Dutch (nl) +- All EU official languages eventually + +--- + +## Deployment Architecture + +### Local Development (Windows) +``` +docker compose -f docker-compose.dev.yml up +``` +Services: +- `api` — NestJS backend (hot-reload via tsx watch) +- `web` — Vite React frontend (HMR on port 5173) +- `cms` — Payload CMS (port 3000) +- `postgres` — PostgreSQL 16 (port 5432) +- `redis` — Redis 7 (port 6379) +- `minio` — S3-compatible storage (ports 9000/9001) + +### Contabo Production (via Portainer) +``` +docker compose -f docker-compose.prod.yml up -d +``` +Portainer stack deployment with: +- Traefik reverse proxy (auto-SSL via Let's Encrypt) +- Multi-stage Docker builds (CI → GHCR → Portainer pull) +- Health checks on all containers +- Log rotation via Docker logging drivers +- Database backup sidecar container + +--- + +## Environment Variables + +All configuration through environment variables. No hardcoded secrets. + +### Required ENV Files +``` +.env.example # Template for all vars (committed) +.env # Local overrides (gitignored) +.env.production.example # Production template (committed) +``` + +### Key Variable Categories +| Category | Variables | +|----------|-----------| +| Database | `DATABASE_URL`, `DATABASE_POOL_MIN`, `DATABASE_POOL_MAX` | +| Redis | `REDIS_URL`, `REDIS_PASSWORD` | +| Auth | `JWT_SECRET`, `JWT_EXPIRES_IN`, `CLERK_SECRET_KEY` | +| AI | `OPENAI_API_KEY`, `GEMINI_API_KEY`, `AI_DEFAULT_DAILY_LIMIT` | +| Storage | `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET` | +| Email | `RESEND_API_KEY`, `EMAIL_FROM` | +| Billing | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | +| Tenant | `TENANT_DEFAULT_AI_MODE`, `TENANT_MAX_TOKENS_MONTHLY` | +| i18n | `DEFAULT_LOCALE`, `SUPPORTED_LOCALES` | + +--- + +## Repository Structure + +``` +investplay/ +├── apps/ +│ ├── api/ # NestJS backend +│ │ ├── src/ +│ │ │ ├── modules/ +│ │ │ │ ├── auth/ +│ │ │ │ ├── tenant/ +│ │ │ │ ├── curriculum/ +│ │ │ │ ├── simulation/ +│ │ │ │ ├── portfolio/ +│ │ │ │ ├── ai-coach/ +│ │ │ │ ├── analytics/ +│ │ │ │ ├── classroom/ +│ │ │ │ └── gamification/ +│ │ │ ├── common/ +│ │ │ │ ├── guards/ +│ │ │ │ ├── decorators/ +│ │ │ │ ├── filters/ +│ │ │ │ └── interceptors/ +│ │ │ ├── config/ +│ │ │ └── main.ts +│ │ ├── prisma/ +│ │ │ ├── schema.prisma +│ │ │ └── migrations/ +│ │ ├── test/ +│ │ ├── Dockerfile +│ │ └── package.json +│ ├── web/ # React + Vite frontend +│ │ ├── src/ +│ │ │ ├── components/ +│ │ │ ├── pages/ +│ │ │ ├── hooks/ +│ │ │ ├── stores/ +│ │ │ ├── lib/ +│ │ │ ├── styles/ +│ │ │ └── i18n/ +│ │ ├── public/ +│ │ ├── index.html +│ │ ├── vite.config.ts +│ │ ├── Dockerfile +│ │ └── package.json +│ ├── cms/ # Payload CMS +│ │ ├── src/ +│ │ │ └── collections/ +│ │ ├── package.json +│ │ └── Dockerfile +│ ├── mobile/ # Capacitor wrapper +│ └── desktop/ # Tauri v2 wrapper +├── packages/ +│ ├── types/ # @investplay/types +│ │ ├── src/ +│ │ │ ├── user.ts +│ │ │ ├── tenant.ts +│ │ │ ├── curriculum.ts +│ │ │ ├── simulation.ts +│ │ │ ├── portfolio.ts +│ │ │ └── analytics.ts +│ │ └── package.json +│ ├── ui/ # @investplay/ui +│ │ ├── src/ +│ │ │ ├── components/ +│ │ │ └── lib/ +│ │ └── package.json +│ ├── i18n/ # @investplay/i18n +│ │ ├── src/ +│ │ │ └── locales/ +│ │ └── package.json +│ └── utils/ # @investplay/utils +│ ├── src/ +│ └── package.json +├── docker/ +│ ├── docker-compose.dev.yml +│ ├── docker-compose.prod.yml +│ ├── traefik/ +│ │ ├── traefik.yml +│ │ └── dynamic.yml +│ └── nginx/ +│ └── nginx.conf +├── scripts/ +│ ├── setup.sh +│ ├── seed.ts +│ └── backup.sh +├── docs/ +│ ├── DEVELOPMENT-ROADMAP.md +│ ├── CONTEXT.md +│ ├── API.md +│ └── ARCHITECTURE.md +├── .github/ +│ └── workflows/ +│ ├── ci.yml +│ └── deploy.yml +├── .env.example +├── .env.production.example +├── turbo.json +├── pnpm-workspace.yaml +├── package.json +├── tsconfig.base.json +├── .eslintrc.js +├── .prettierrc +└── README.md +``` + +--- + +## Database Schema (Core Entities) + +``` +User — id, email, role, locale, tenantId, xp, level +Tenant — id, name, domain, branding, featureFlags, aiMode +Classroom — id, name, tenantId, teacherId, inviteCode +ClassroomStudent — id, classroomId, userId, joinedAt +Module — id, cmsId, track, order, tenantId +Lesson — id, cmsId, moduleId, order, estimatedMins +UserProgress — id, userId, lessonId, status, score, completedAt +QuizAttempt — id, userId, quizId, answers, score, timestamp +Trade — id, userId, simulationId, asset, amount, timestamp +Portfolio — id, userId, simulationId, cashBalance, holdings +Badge — id, name, description, icon, criteria +UserBadge — id, userId, badgeId, earnedAt +AILog — id, tenantId, userId, tokensUsed, timestamp +AnalyticsEvent — id, userId, eventType, metadata, timestamp +``` + +--- + +## Key Technical Decisions + +### Multi-Tenancy Strategy +**Schema-level isolation** via PostgreSQL schemas (one per tenant) for maximum data isolation. Tenant-specific connection pooling via Prisma's multi-schema preview feature. + +### API Design +- **Primary:** GraphQL for all CRUD operations (type-safe, no over-fetching) +- **Real-time:** WebSockets (Socket.io) for live simulations and leaderboards +- **Webhooks:** REST endpoints for Stripe, Resend, third-party integrations + +### AI Cost Control +Backend-side hard enforcement (not reliant on OpenAI budget alerts): +- Redis token bucket per user (daily) and per tenant (monthly) +- Hard cutoff at quota exhaustion (no grace period) +- Admin alert when approaching 80% of monthly quota + +### Security +- JWT with RS256 (asymmetric) for service-to-service auth +- GraphQL query depth limiting (max depth 7) +- Rate limiting on all public endpoints +- CORS whitelist per tenant domain +- Helmet.js for HTTP security headers +- Input validation via Zod on every endpoint +- Prompt injection defense in AI proxy + +--- + +## Tracking + +Progress tracked via this file. Each completed task gets `[x]`. +Git commits follow Conventional Commits format. +CI badges added once pipelines are running. diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md new file mode 100644 index 0000000..3120df9 --- /dev/null +++ b/docs/LOCALIZATION.md @@ -0,0 +1,377 @@ +# InvestPlay — Localization Guide + +## Overview + +InvestPlay supports multiple languages through a layered i18n architecture: + +1. **UI strings** — `i18next` with namespaced JSON files (runtime-loaded, no build step) +2. **Educational content** — Payload CMS localized collections (per-locale content variants) +3. **Financial formatting** — `Intl.NumberFormat` and `Intl.DateTimeFormat` (browser-native) +4. **Currency** — Locale-aware currency symbols and formatting + +### Currently Supported Locales + +| Locale | Code | Status | Notes | +|--------|------|--------|-------| +| English | `en` | Primary | Default fallback locale | +| Greek | `el` | Active | Market entry requirement | + +### Target Locales (Phase 8) + +German (`de`), French (`fr`), Spanish (`es`), Italian (`it`), Portuguese (`pt`), Dutch (`nl`) + +--- + +## How to Add a New Locale + +### Step 1: Define the locale in configuration + +Add the locale to the `SUPPORTED_LOCALES` environment variable: + +```env +SUPPORTED_LOCALES=en,el,de +``` + +### Step 2: Create translation JSON files + +Create a new directory under `packages/i18n/src/locales//` with the same namespace structure as existing locales: + +``` +packages/i18n/src/locales/ +├── en/ +│ ├── common.json +│ ├── finance.json +│ ├── gamification.json +│ ├── classroom.json +│ └── ai-coach.json +├── el/ +│ ├── common.json +│ ├── finance.json +│ ├── gamification.json +│ ├── classroom.json +│ └── ai-coach.json +└── de/ # New locale + ├── common.json + ├── finance.json + ├── gamification.json + ├── classroom.json + └── ai-coach.json +``` + +### Step 3: Register in the i18n package + +Edit `packages/i18n/src/index.ts` to import and register the new locale: + +```typescript +import deCommon from "./locales/de/common.json" with { type: "json" }; +import deFinance from "./locales/de/finance.json" with { type: "json" }; +import deGamification from "./locales/de/gamification.json" with { type: "json" }; +import deClassroom from "./locales/de/classroom.json" with { type: "json" }; +import deAiCoach from "./locales/de/ai-coach.json" with { type: "json" }; + +const resources = { + en: { /* existing */ }, + el: { /* existing */ }, + de: { + common: deCommon, + finance: deGamification, + gamification: deGamification, + classroom: deClassroom, + aiCoach: deAiCoach, + }, +}; +``` + +### Step 4: Add locale in the CMS + +1. Navigate to Payload Admin -> Settings -> Localization +2. Add the new locale code +3. Existing content collections will now show locale switcher tabs +4. Content editors can create locale-specific variants for each lesson, article, or block + +### Step 5: Add locale-specific financial examples + +If the new locale has different financial regulations, tax rules, or common investment products, create locale-specific example data in the CMS. This ensures learners see locally relevant content. + +### Step 6: Test the locale + +```bash +# TypeScript checking ensures all namespaces are covered +pnpm typecheck + +# Run tests with German locale +NODE_ENV=test SUPPORTED_LOCALES=en,el,de pnpm test +``` + +--- + +## CMS Localization Workflow + +### Architecture + +Payload CMS supports built-in localization at the collection level. When a collection has `localization: true`, each document stores locale-specific variants: + +```typescript +// Conceptual Payload collection config +const Lessons = { + slug: "lessons", + localization: { + locales: ["en", "el", "de"], + defaultLocale: "en", + fallback: true, // Falls back to default locale if variant missing + }, + fields: [ + { name: "title", type: "text", localized: true }, + { name: "body", type: "richText", localized: true }, + { name: "blocks", type: "blocks", localized: true }, + { name: "duration", type: "number", localized: false }, // Shared across locales + ], +}; +``` + +### Content Editor Flow + +1. Author creates a lesson in the default locale (English) +2. CMS marks the lesson as "Translation Needed" for other locales +3. Translator opens the lesson and sees all localized fields +4. Translator fills in the locale-specific content +5. CMS automatically uses the locale variant when the API is queried with the matching `Accept-Language` header + +### Frontend Rendering + +The frontend requests content with the user's preferred locale. The CMS API automatically resolves the correct variant: + +```typescript +// The CMS API respects Accept-Language header +const response = await fetch(`${CMS_API_URL}/api/lessons/${id}`, { + headers: { + "Accept-Language": userLocale, // "de", "el", etc. + "Authorization": `Bearer ${cmsApiKey}`, + }, +}); +``` + +If a locale variant is missing, Payload falls back to the default locale content. + +--- + +## ICU MessageFormat Examples + +InvestPlay uses ICU MessageFormat for all translated strings via `i18next`. This provides robust support for plurals, gender, context, and rich text. + +### Basic Interpolation + +```json +{ + "welcome": "Welcome, {name}!", + "login": "Sign in to continue" +} +``` + +```typescript +t("welcome", { name: "Alex" }); +// -> "Welcome, Alex!" +``` + +### Pluralization + +```json +{ + "xpEarned": "You earned {count} XP", + "xpEarned_plural": "You earned {count} XP", + "lessonsCompleted": "{count} lesson completed", + "lessonsCompleted_plural": "{count} lessons completed" +} +``` + +```typescript +t("xpEarned", { count: 1 }); // -> "You earned 1 XP" +t("xpEarned", { count: 5 }); // -> "You earned 5 XP" +t("lessonsCompleted", { count: 1 }); // -> "1 lesson completed" +t("lessonsCompleted", { count: 3 }); // -> "3 lessons completed" +``` + +ICU native plural syntax (also supported): + +```json +{ + "streak": "{count, plural, one {# day streak} other {# day streak}}", + "students": "{count, plural, one {# student} other {# students}}" +} +``` + +### Context and Gender + +```json +{ + "notification": "{name} {context, select, trade {bought} quiz {scored} badge {unlocked} other {completed}} {target}" +} +``` + +```typescript +t("notification", { name: "Alex", context: "trade", target: "AAPL" }); +// -> "Alex bought AAPL" + +t("notification", { name: "Maria", context: "badge", target: "Early Investor" }); +// -> "Maria unlocked Early Investor" +``` + +### Rich Text Formatting + +```json +{ + "terms": "By continuing, you agree to our Terms of Service and Privacy Policy" +} +``` + +```typescript +t("terms", { + terms: (text: string) => `${text}`, + privacy: (text: string) => `${text}`, +}); +``` + +### Currency Formatting + +Currency symbols are dynamically selected based on locale. The formatting happens in `@investplay/utils`: + +```typescript +import { formatCurrency } from "@investplay/utils"; + +formatCurrency(1234.56, "EUR", "en"); // -> "€1,234.56" +formatCurrency(1234.56, "EUR", "el"); // -> "1.234,56 €" +formatCurrency(1234.56, "USD", "en"); // -> "$1,234.56" +``` + +### Date and Time Formatting + +```typescript +import { formatDate, formatRelativeTime } from "@investplay/utils"; + +formatDate("2026-06-12", "en"); // -> "Jun 12, 2026" +formatDate("2026-06-12", "el"); // -> "12 Ιουν 2026" +formatRelativeTime("2026-06-10", "en"); // -> "2 days ago" +formatRelativeTime("2026-06-10", "el"); // -> "πριν από 2 ημέρες" +``` + +### Number Formatting + +```typescript +import { formatNumber, formatPercentage } from "@investplay/utils"; + +formatNumber(1234567.89, "en"); // -> "1,234,567.89" +formatNumber(1234567.89, "el"); // -> "1.234.567,89" +formatPercentage(0.1234, "en"); // -> "12.34%" +formatPercentage(0.1234, "el"); // -> "12,34%" +``` + +### Namespace Structure + +| Namespace | Contents | Example Keys | +|-----------|----------|-------------| +| `common` | Navigation, buttons, errors, meta | `nav.home`, `button.save`, `error.notFound` | +| `finance` | Financial terms, currencies, instruments | `term.dividend`, `currency.eur`, `instrument.stock` | +| `gamification` | XP, badges, streaks, levels | `xp.earned`, `badge.investor`, `streak.days` | +| `classroom` | Teacher dashboard, assignments, roster | `classroom.create`, `assignment.due`, `roster.student` | +| `aiCoach` | AI chat UI strings, hints, feedback | `ai.greeting`, `ai.hint`, `ai.quotaExhausted` | + +--- + +## Currency/Date/Number Formatting + +All formatting is handled browser-side using the `Intl` API via `@investplay/utils`. No server-side formatting is needed for display values. + +### Currency Formatting Configuration + +```typescript +import { CURRENCY_SYMBOLS } from "@investplay/utils"; + +CURRENCY_SYMBOLS = { + EUR: { symbol: "€", code: "EUR" }, + USD: { symbol: "$", code: "USD" }, + GBP: { symbol: "£", code: "GBP" }, +} as const; +``` + +The `formatCurrency` function uses `Intl.NumberFormat` internally: + +```typescript +new Intl.NumberFormat(locale, { + style: "currency", + currency: currencyCode, + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); +``` + +### Locale-Format Mapping + +| Locale | Decimal | Grouping | Currency Position | Date Format | +|--------|---------|----------|-------------------|-------------| +| `en` | `.` | `,` | Prefix (`$1,234.56`) | `MMM dd, yyyy` | +| `el` | `,` | `.` | Suffix (`1.234,56 €`) | `dd MMM yyyy` | +| `de` | `,` | `.` | Suffix (`1.234,56 €`) | `dd.MM.yyyy` | +| `fr` | `,` | ` ` | Suffix (`1 234,56 €`) | `dd/MM/yyyy` | +| `es` | `,` | `.` | Suffix (`1.234,56 €`) | `dd/MM/yyyy` | +| `it` | `,` | `.` | Suffix (`1.234,56 €`) | `dd/MM/yyyy` | +| `pt` | `,` | `.` | Suffix (`1.234,56 €`) | `dd/MM/yyyy` | +| `nl` | `,` | `.` | Suffix (`1.234,56 €`) | `dd-MM-yyyy` | + +### Implementation + +All formatting functions in `@investplay/utils` accept a locale parameter that defaults to the user's current locale from `i18next.language`: + +```typescript +import { useTranslation } from "@investplay/i18n"; + +function TradeRow({ trade }: { trade: Trade }) { + const { i18n } = useTranslation(); + return ( + + {formatDate(trade.executedAt, i18n.language)} + {formatCurrency(trade.amount, "EUR", i18n.language)} + {formatPercentage(trade.roi, i18n.language)} + + ); +} +``` + +--- + +## RTL Readiness Notes + +RTL (right-to-left) support for languages like Arabic (`ar`) is planned for Phase 8. The following steps have been taken to ensure RTL readiness: + +### CSS + +- TailwindCSS has built-in RTL support via the `rtl:` variant +- shadcn/ui components use logical CSS properties (`margin-inline-start` instead of `margin-left`) +- Flexbox and Grid layouts use `gap` rather than left/right margins + +### HTML + +- The `` element will have `dir="rtl"` set via the locale configuration +- React components should avoid hardcoded `left`/`right` style values + +### Icons + +- Lucide icons support `RTL` via the `mirrorInRtl` property on directional icons (arrows, chevrons) +- Example: `` + +### Future Implementation Checklist + +When adding RTL support: + +1. Update `packages/i18n/src/index.ts` to expose a `isRTL(locale: string): boolean` function +2. Create a `RTLProvider` component that sets `dir` on the document element +3. Audit all third-party libraries for RTL compatibility +4. Test with long RTL strings for layout overflow +5. Verify input fields, date pickers, and number inputs handle RTL correctly + +--- + +## Related Documentation + +- [CONTEXT.md](./CONTEXT.md) — Project context and tech stack +- [API.md](./API.md) — API endpoints and authentication +- [ARCHITECTURE.md](./ARCHITECTURE.md) — System architecture diff --git a/package.json b/package.json new file mode 100644 index 0000000..9ca79ae --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "investplay", + "version": "0.1.0", + "private": true, + "description": "InvestPlay — Financial literacy education platform", + "scripts": { + "dev": "turbo run dev", + "build": "turbo run build", + "lint": "turbo run lint", + "typecheck": "turbo run typecheck", + "test": "turbo run test", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", + "clean": "turbo run clean && rimraf node_modules .turbo" + }, + "devDependencies": { + "turbo": "^2.5.0", + "typescript": "^5.8.0", + "eslint": "^8.57.0", + "prettier": "^3.5.0", + "husky": "^9.1.0", + "lint-staged": "^15.5.0", + "@types/node": "^22.14.0", + "rimraf": "^6.0.0" + }, + "engines": { + "node": ">=20.0.0", + "pnpm": ">=9.0.0" + }, + "packageManager": "pnpm@9.15.9" +} diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 0000000..e7f48d2 --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,23 @@ +{ + "name": "@investplay/i18n", + "version": "0.1.0", + "private": true, + "description": "InvestPlay internationalization package", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "lint": "eslint \"src/**/*.ts\" --fix", + "typecheck": "tsc --noEmit", + "clean": "rimraf .turbo" + }, + "dependencies": { + "i18next": "^24.2.0", + "react-i18next": "^15.4.0", + "intl-messageformat": "^10.7.0" + }, + "devDependencies": { + "typescript": "^5.8.0", + "eslint": "^8.57.0", + "rimraf": "^6.0.0" + } +} diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts new file mode 100644 index 0000000..128ae36 --- /dev/null +++ b/packages/i18n/src/index.ts @@ -0,0 +1,53 @@ +import i18next, { type InitOptions } from "i18next"; +import { initReactI18next } from "react-i18next"; +import enCommon from "./locales/en/common.json" with { type: "json" }; +import enFinance from "./locales/en/finance.json" with { type: "json" }; +import enGamification from "./locales/en/gamification.json" with { type: "json" }; +import enClassroom from "./locales/en/classroom.json" with { type: "json" }; +import enAiCoach from "./locales/en/ai-coach.json" with { type: "json" }; +import elCommon from "./locales/el/common.json" with { type: "json" }; +import elFinance from "./locales/el/finance.json" with { type: "json" }; +import elGamification from "./locales/el/gamification.json" with { type: "json" }; +import elClassroom from "./locales/el/classroom.json" with { type: "json" }; +import elAiCoach from "./locales/el/ai-coach.json" with { type: "json" }; + +const resources = { + en: { + common: enCommon, + finance: enFinance, + gamification: enGamification, + classroom: enClassroom, + aiCoach: enAiCoach, + }, + el: { + common: elCommon, + finance: elFinance, + gamification: elGamification, + classroom: elClassroom, + aiCoach: elAiCoach, + }, +} as const; + +export type LocaleResource = typeof resources; + +const defaultOptions: InitOptions = { + resources, + fallbackLng: "en", + defaultNS: "common", + ns: ["common", "finance", "gamification", "classroom", "aiCoach"], + interpolation: { + escapeValue: false, + }, + returnNull: false, + returnEmptyString: false, +}; + +export function initI18n(options?: Partial): typeof i18next { + const instance = i18next.createInstance(); + instance.use(initReactI18next); + instance.init({ ...defaultOptions, ...options }); + return instance; +} + +export { useTranslation } from "react-i18next"; +export type { LocaleResource }; diff --git a/packages/i18n/src/locales/el/ai-coach.json b/packages/i18n/src/locales/el/ai-coach.json new file mode 100644 index 0000000..d1d33e5 --- /dev/null +++ b/packages/i18n/src/locales/el/ai-coach.json @@ -0,0 +1,34 @@ +{ + "chat": { + "placeholder": "Ρωτήστε με οτιδήποτε για τα οικονομικά...", + "greeting": "Γεια σας! Είμαι ο AI Coach σας. Μπορώ να σας βοηθήσω να κατανοήσετε οικονομικές έννοιες, να αναλύσετε το χαρτοφυλάκιό σας και να σας καθοδηγήσω σε προσομοιώσεις. Τι θα θέλατε να μάθετε σήμερα;", + "quotaExhausted": "Φτάσατε στο ημερήσιο όριο μηνυμάτων για τον AI Coach. Αναβαθμίστε το πρόγραμμά σας ή δοκιμάστε ξανά αύριο.", + "thinking": "Σκέφτομαι...", + "errorFallback": "Αντιμετωπίζω δυσκολία στην επεξεργασία του αιτήματός σας. Παρακαλώ δοκιμάστε ξανά.", + "send": "Αποστολή", + "clear": "Εκκαθάριση συνομιλίας", + "close": "Κλείσιμο συνομιλίας", + "minimize": "Ελαχιστοποίηση" + }, + "context": { + "lesson": "Αυτή τη στιγμή βρίσκεστε στο μάθημα \"{{lesson}}\". Θα προσαρμόσω τις απαντήσεις μου για να σας βοηθήσω με αυτό το θέμα.", + "simulation": "Βλέπω ότι εκτελείτε μια προσομοίωση {{simulation}}. Επιτρέψτε μου να σας βοηθήσω να λάβετε τεκμηριωμένες αποφάσεις.", + "portfolio": "Ορίστε μια γρήγορη ανάλυση της τρέχουσας κατανομής του χαρτοφυλακίου σας." + }, + "limits": { + "dailyMessages": "Ημερήσια μηνύματα: {{used}} / {{limit}}", + "tokenUsage": "Tokens που χρησιμοποιήθηκαν: {{used}} / {{limit}}", + "upgrade": "Χρειάζεστε περισσότερα; Επικοινωνήστε με τον διαχειριστή σας." + }, + "topics": { + "basics": "Οικονομικά Βασικά", + "investing": "Επενδύσεις", + "budgeting": "Προϋπολογισμός", + "debt": "Διαχείριση Χρέους", + "retirement": "Συνταξιοδοτικός Προγραμματισμός", + "taxes": "Φόροι", + "insurance": "Ασφάλειες", + "realEstate": "Ακίνητα", + "crypto": "Κρυπτονομίσματα" + } +} diff --git a/packages/i18n/src/locales/el/classroom.json b/packages/i18n/src/locales/el/classroom.json new file mode 100644 index 0000000..07e9a3a --- /dev/null +++ b/packages/i18n/src/locales/el/classroom.json @@ -0,0 +1,66 @@ +{ + "dashboard": { + "title": "Πίνακας Ελέγχου Τάξης", + "overview": "Επισκόπηση", + "totalStudents": "Σύνολο Μαθητών", + "averageScore": "Μέση Βαθμολογία", + "completionRate": "Ποσοστό Ολοκλήρωσης", + "reports": "Αναφορές", + "recentActivity": "Πρόσφατη Δραστηριότητα", + "noActivity": "Καμία πρόσφατη δραστηριότητα" + }, + "assignments": { + "title": "Εργασίες", + "create": "Δημιουργία Εργασίας", + "edit": "Επεξεργασία Εργασίας", + "delete": "Διαγραφή Εργασίας", + "dueDate": "Ημερομηνία Παράδοσης", + "requiredScore": "Απαιτούμενη Βαθμολογία", + "maxAttempts": "Μέγιστες Προσπάθειες", + "submissions": "Υποβολές", + "noAssignments": "Δεν έχουν δημιουργηθεί εργασίες ακόμα", + "status": { + "open": "Ανοικτή", + "closed": "Κλειστή", + "draft": "Πρόχειρο" + } + }, + "cohorts": { + "title": "Τμήματα", + "create": "Δημιουργία Τμήματος", + "join": "Εγγραφή σε Τάξη", + "inviteCode": "Κωδικός Πρόσκλησης", + "students": "Μαθητές", + "noCohorts": "Δεν υπάρχουν τμήματα ακόμα", + "emptyClass": "Δεν υπάρχουν μαθητές σε αυτή την τάξη ακόμα" + }, + "reports": { + "title": "Αναφορές", + "generate": "Δημιουργία Αναφοράς", + "export": "Εξαγωγή", + "compositeScore": "Σύνθετη Βαθμολογία", + "roi": "Απόδοση Επένδυσης", + "riskManagement": "Διαχείριση Κινδύνου", + "esgAlignment": "ESG Συμμόρφωση", + "quizScore": "Βαθμολογία Κουίζ", + "lessonCompletion": "Ολοκλήρωση Μαθημάτων", + "studentProgress": "Πρόοδος Μαθητή", + "classAverage": "Μέσος Όρος Τάξης" + }, + "teacher": { + "actions": { + "createAssignment": "Νέα Εργασία", + "viewSubmissions": "Προβολή Υποβολών", + "gradeAssignment": "Βαθμολόγηση Εργασίας", + "manageStudents": "Διαχείριση Μαθητών", + "sendAnnouncement": "Αποστολή Ανακοίνωσης", + "viewReports": "Προβολή Αναφορών" + }, + "announcement": { + "title": "Ανακοίνωση", + "placeholder": "Γράψτε μια ανακοίνωση για την τάξη σας...", + "send": "Αποστολή Ανακοίνωσης", + "sent": "Η ανακοίνωση στάλθηκε με επιτυχία" + } + } +} diff --git a/packages/i18n/src/locales/el/common.json b/packages/i18n/src/locales/el/common.json new file mode 100644 index 0000000..fb0f156 --- /dev/null +++ b/packages/i18n/src/locales/el/common.json @@ -0,0 +1,103 @@ +{ + "nav": { + "dashboard": "Πίνακας Ελέγχου", + "learn": "Μάθηση", + "play": "Παιχνίδι", + "practice": "Εξάσκηση", + "teach": "Διδασκαλία", + "classroom": "Τάξη", + "leaderboard": "Βαθμολογία", + "profile": "Προφίλ", + "settings": "Ρυθμίσεις", + "admin": "Διαχείριση" + }, + "actions": { + "next": "Επόμενο", + "back": "Πίσω", + "submit": "Υποβολή", + "cancel": "Ακύρωση", + "save": "Αποθήκευση", + "delete": "Διαγραφή", + "edit": "Επεξεργασία", + "view": "Προβολή", + "confirm": "Επιβεβαίωση", + "retry": "Δοκιμή ξανά", + "close": "Κλείσιμο", + "continue": "Συνέχεια", + "start": "Έναρξη", + "finish": "Ολοκλήρωση", + "skip": "Παράλειψη", + "search": "Αναζήτηση", + "filter": "Φίλτρο", + "sort": "Ταξινόμηση", + "refresh": "Ανανέωση", + "download": "Λήψη", + "upload": "Μεταφόρτωση", + "share": "Κοινοποίηση", + "copy": "Αντιγραφή", + "undo": "Αναίρεση", + "redo": "Επανάληψη" + }, + "errors": { + "generic": "Κάτι πήγε στραβά. Παρακαλώ δοκιμάστε ξανά.", + "network": "Σφάλμα δικτύου. Ελέγξτε τη σύνδεσή σας.", + "unauthorized": "Πρέπει να συνδεθείτε για να δείτε αυτή τη σελίδα.", + "forbidden": "Δεν έχετε άδεια να εκτελέσετε αυτή την ενέργεια.", + "notFound": "Η σελίδα που ζητήσατε δεν υπάρχει.", + "validation": "Ελέγξτε τα στοιχεία σας και δοκιμάστε ξανά.", + "timeout": "Το αίτημα έληξε. Παρακαλώ δοκιμάστε ξανά.", + "serverError": "Σφάλμα διακομιστή. Η ομάδα μας έχει ειδοποιηθεί.", + "rateLimited": "Πάρα πολλά αιτήματα. Παρακαλώ περιμένετε λίγο.", + "sessionExpired": "Η συνεδρία σας έληξε. Παρακαλώ συνδεθείτε ξανά." + }, + "auth": { + "login": "Σύνδεση", + "register": "Δημιουργία Λογαριασμού", + "logout": "Αποσύνδεση", + "forgotPassword": "Ξεχάσατε τον Κωδικό;", + "resetPassword": "Επαναφορά Κωδικού", + "changePassword": "Αλλαγή Κωδικού", + "email": "Διεύθυνση email", + "password": "Κωδικός", + "confirmPassword": "Επιβεβαίωση κωδικού", + "rememberMe": "Να με θυμάσαι", + "noAccount": "Δεν έχετε λογαριασμό;", + "hasAccount": "Έχετε ήδη λογαριασμό;", + "signUp": "Εγγραφή", + "signIn": "Σύνδεση", + "welcomeBack": "Καλώς ήρθατε πίσω", + "createAccount": "Δημιουργήστε τον λογαριασμό σας", + "termsAgree": "Συμφωνώ με τους Όρους Χρήσης και την Πολιτική Απορρήτου" + }, + "empty": { + "noData": "Δεν υπάρχουν διαθέσιμα δεδομένα", + "noResults": "Δεν βρέθηκαν αποτελέσματα", + "comingSoon": "Σύντομα διαθέσιμο", + "noLessons": "Δεν υπάρχουν μαθήματα ακόμα", + "noSimulations": "Δεν υπάρχουν διαθέσιμες προσομοιώσεις", + "noAssignments": "Δεν υπάρχουν εργασίες ακόμα", + "noStudents": "Δεν είναι εγγεγραμμένοι μαθητές", + "noMessages": "Δεν υπάρχουν μηνύματα ακόμα" + }, + "time": { + "today": "Σήμερα", + "yesterday": "Χθες", + "thisWeek": "Αυτή την εβδομάδα", + "thisMonth": "Αυτό τον μήνα", + "minutes": "{{count}} λεπτό", + "minutes_plural": "{{count}} λεπτά", + "hours": "{{count}} ώρα", + "hours_plural": "{{count}} ώρες", + "days": "{{count}} ημέρα", + "days_plural": "{{count}} ημέρες" + }, + "theme": { + "light": "Φωτεινό", + "dark": "Σκοτεινό", + "system": "Συστήματος" + }, + "language": { + "en": "English", + "el": "Ελληνικά" + } +} diff --git a/packages/i18n/src/locales/el/finance.json b/packages/i18n/src/locales/el/finance.json new file mode 100644 index 0000000..8c56c41 --- /dev/null +++ b/packages/i18n/src/locales/el/finance.json @@ -0,0 +1,105 @@ +{ + "assetClasses": { + "stocks": "Μετοχές", + "bonds": "Ομόλογα", + "etfs": "ETF", + "mutualFunds": "Αμοιβαία Κεφάλαια", + "crypto": "Κρυπτονομίσματα", + "realEstate": "Ακίνητα", + "commodities": "Εμπορεύματα", + "cash": "Μετρητά", + "derivatives": "Παράγωγα", + "forex": "Συνάλλαγμα" + }, + "investmentTypes": { + "growth": "Επένδυση Ανάπτυξης", + "value": "Επένδυση Αξίας", + "dividend": "Επένδυση Μερισμάτων", + "index": "Επένδυση Δείκτη", + "esg": "ESG Επένδυση", + "dayTrading": "Ημερήσια Διαπραγμάτευση", + "swing": "Swing Trading", + "dca": "Μέσος Όρος Κόστους" + }, + "banking": { + "savingsAccount": "Λογαριασμός Ταμιευτηρίου", + "checkingAccount": "Τρεχούμενος Λογαριασμός", + "fixedDeposit": "Προθεσμιακή Κατάθεση", + "interestRate": "Επιτόκιο", + "apr": "Επιτόκιο (APR)", + "apy": "Ετήσια Απόδοση (APY)", + "compoundInterest": "Ανατοκισμός", + "principal": "Κεφάλαιο", + "maturity": "Λήξη" + }, + "budgeting": { + "income": "Εισόδημα", + "expenses": "Έξοδα", + "savings": "Αποταμίευση", + "investments": "Επενδύσεις", + "needs": "Ανάγκες", + "wants": "Επιθυμίες", + "emergencyFund": "Ταμείο Έκτακτης Ανάγκης", + "trackExpenses": "Παρακολούθηση Εξόδων", + "setBudget": "Ορισμός Προϋπολογισμού", + "reviewSpending": "Ανασκόπηση Δαπανών" + }, + "debt": { + "creditCard": "Πιστωτική Κάρτα", + "studentLoan": "Φοιτητικό Δάνειο", + "mortgage": "Στεγαστικό Δάνειο", + "personalLoan": "Προσωπικό Δάνειο", + "interestRate": "Επιτόκιο", + "minimumPayment": "Ελάχιστη Πληρωμή", + "balance": "Υπόλοιπο", + "debtFree": "Χωρίς Χρέη", + "consolidation": "Συγχώνευση Χρεών", + "snowball": "Μέθοδος Χιονόμπαλας", + "avalanche": "Μέθοδος Χιονοστιβάδας" + }, + "inflation": { + "inflationRate": "Ποσοστό Πληθωρισμού", + "purchasingPower": "Αγοραστική Δύναμη", + "cpi": "Δείκτης Τιμών Καταναλωτή", + "realReturn": "Πραγματική Απόδοση", + "nominalReturn": "Ονομαστική Απόδοση", + "hedge": "Αντιστάθμιση Πληθωρισμού" + }, + "risk": { + "low": "Χαμηλού Κινδύνου", + "medium": "Μέτριου Κινδύνου", + "high": "Υψηλού Κινδύνου", + "volatility": "Μεταβλητότητα", + "diversification": "Διαφοροποίηση", + "correlation": "Συσχέτιση", + "beta": "Beta", + "alpha": "Alpha", + "sharpeRatio": "Δείκτης Sharpe", + "maxDrawdown": "Μέγιστη Υποχώρηση", + "riskTolerance": "Ανοχή Κινδύνου" + }, + "portfolio": { + "allocation": "Κατανομή Περιουσιακών Στοιχείων", + "rebalance": "Εξισορρόπηση", + "performance": "Απόδοση", + "returns": "Αποδόσεις", + "gain": "Κέρδος", + "loss": "Ζημία", + "totalValue": "Συνολική Αξία", + "profitLoss": "Κέρδη / Ζημίες", + "roe": "Απόδοση Επένδυσης", + "annualizedReturn": "Ετήσια Απόδοση" + }, + "market": { + "bullMarket": "Αγορά Ανοδική (Bull Market)", + "bearMarket": "Πτωτική Αγορά (Bear Market)", + "correction": "Διόρθωση", + "crash": "Κατάρρευση Αγοράς", + "recession": "Ύφεση", + "recovery": "Ανάκαμψη", + "volatility": "Μεταβλητότητα Αγοράς", + "liquidity": "Ρευστότητα", + "spread": "Διαφορά Αγοράς-Πώλησης", + "volume": "Όγκος Συναλλαγών" + } +} diff --git a/packages/i18n/src/locales/el/gamification.json b/packages/i18n/src/locales/el/gamification.json new file mode 100644 index 0000000..a08af17 --- /dev/null +++ b/packages/i18n/src/locales/el/gamification.json @@ -0,0 +1,62 @@ +{ + "xp": { + "label": "ΠΠ", + "earned": "Κερδίσατε {{count}} ΠΠ", + "needed": "{{count}} ΠΠ για το επόμενο επίπεδο", + "reason": { + "lessonCompleted": "Ολοκλήρωση μαθήματος", + "quizPassed": "Επιτυχία κουίζ", + "streakMaintained": "Διατήρηση σερί", + "challengeCompleted": "Ολοκλήρωση πρόκλησης", + "simulationCompleted": "Ολοκλήρωση προσομοίωσης", + "diversification": "Διαφοροποίηση χαρτοφυλακίου", + "riskManagement": "Σωστή διαχείριση κινδύνου", + "hypeAvoided": "Αποφυγή διαφημιστικής εκστρατείας", + "debtPaidOff": "Εξόφληση χρέους" + } + }, + "level": { + "label": "Επίπεδο", + "current": "Επίπεδο {{level}}", + "progress": "Επίπεδο {{current}} — {{progress}}% για το Επίπεδο {{next}}" + }, + "badges": { + "title": "Σήματα", + "unlocked": "Ξεκλείδωμα Σήματος!", + "locked": "Κλειδωμένο", + "progress": "{{count}} από {{total}} σήματα", + "categories": { + "milestone": "Ορόσημα", + "skill": "Δεξιότητες", + "behavior": "Συμπεριφορές", + "special": "Ειδικά" + } + }, + "achievements": { + "title": "Επιτεύγματα", + "recent": "Πρόσφατα Επιτεύγματα", + "all": "Όλα τα Επιτεύγματα", + "empty": "Ολοκληρώστε μαθήματα για να κερδίσετε επιτεύγματα" + }, + "streaks": { + "label": "Σερί", + "current": "Σερί {{count}} ημερών", + "longest": "Μεγαλύτερο: {{count}} ημέρες", + "days": "{{count}} ημέρα", + "days_plural": "{{count}} ημέρες", + "maintain": "Ολοκληρώστε ένα μάθημα σήμερα για να διατηρήσετε το σερί σας!", + "lost": "Το σερί χάθηκε. Ξεκινήστε ξανά αύριο!" + }, + "leaderboard": { + "title": "Βαθμολογία", + "rank": "Θέση", + "player": "Παίκτης", + "xp": "ΠΠ", + "level": "Επίπεδο", + "badges": "Σήματα", + "yourRank": "Η Θέση σας", + "empty": "Δεν υπάρχουν ακόμα κατατάξεις. Ξεκινήστε να μαθαίνετε!", + "classroom": "Βαθμολογία Τάξης", + "global": "Γενική Βαθμολογία" + } +} diff --git a/packages/i18n/src/locales/en/ai-coach.json b/packages/i18n/src/locales/en/ai-coach.json new file mode 100644 index 0000000..3fbd435 --- /dev/null +++ b/packages/i18n/src/locales/en/ai-coach.json @@ -0,0 +1,34 @@ +{ + "chat": { + "placeholder": "Ask me anything about finance...", + "greeting": "Hi! I'm your AI Coach. I can help you understand financial concepts, analyze your portfolio, and guide you through simulations. What would you like to learn today?", + "quotaExhausted": "You've reached your daily limit for AI Coach messages. Upgrade your plan or try again tomorrow.", + "thinking": "Thinking...", + "errorFallback": "I'm having trouble processing your request. Please try again.", + "send": "Send", + "clear": "Clear chat", + "close": "Close chat", + "minimize": "Minimize" + }, + "context": { + "lesson": "You are currently on lesson \"{{lesson}}\". I'll tailor my responses to help you with this topic.", + "simulation": "I can see you're running a {{simulation}} simulation. Let me help you make informed decisions.", + "portfolio": "Here's a quick analysis of your current portfolio allocation." + }, + "limits": { + "dailyMessages": "Daily messages: {{used}} / {{limit}}", + "tokenUsage": "Tokens used: {{used}} / {{limit}}", + "upgrade": "Need more? Contact your administrator." + }, + "topics": { + "basics": "Financial Basics", + "investing": "Investing", + "budgeting": "Budgeting", + "debt": "Debt Management", + "retirement": "Retirement Planning", + "taxes": "Taxes", + "insurance": "Insurance", + "realEstate": "Real Estate", + "crypto": "Cryptocurrency" + } +} diff --git a/packages/i18n/src/locales/en/classroom.json b/packages/i18n/src/locales/en/classroom.json new file mode 100644 index 0000000..db43da5 --- /dev/null +++ b/packages/i18n/src/locales/en/classroom.json @@ -0,0 +1,66 @@ +{ + "dashboard": { + "title": "Classroom Dashboard", + "overview": "Overview", + "totalStudents": "Total Students", + "averageScore": "Average Score", + "completionRate": "Completion Rate", + "reports": "Reports", + "recentActivity": "Recent Activity", + "noActivity": "No recent activity" + }, + "assignments": { + "title": "Assignments", + "create": "Create Assignment", + "edit": "Edit Assignment", + "delete": "Delete Assignment", + "dueDate": "Due Date", + "requiredScore": "Required Score", + "maxAttempts": "Max Attempts", + "submissions": "Submissions", + "noAssignments": "No assignments created yet", + "status": { + "open": "Open", + "closed": "Closed", + "draft": "Draft" + } + }, + "cohorts": { + "title": "Cohorts", + "create": "Create Cohort", + "join": "Join Class", + "inviteCode": "Invite Code", + "students": "Students", + "noCohorts": "No cohorts yet", + "emptyClass": "No students in this class yet" + }, + "reports": { + "title": "Reports", + "generate": "Generate Report", + "export": "Export", + "compositeScore": "Composite Score", + "roi": "ROI", + "riskManagement": "Risk Management", + "esgAlignment": "ESG Alignment", + "quizScore": "Quiz Score", + "lessonCompletion": "Lesson Completion", + "studentProgress": "Student Progress", + "classAverage": "Class Average" + }, + "teacher": { + "actions": { + "createAssignment": "New Assignment", + "viewSubmissions": "View Submissions", + "gradeAssignment": "Grade Assignment", + "manageStudents": "Manage Students", + "sendAnnouncement": "Send Announcement", + "viewReports": "View Reports" + }, + "announcement": { + "title": "Announcement", + "placeholder": "Write an announcement to your class...", + "send": "Send Announcement", + "sent": "Announcement sent successfully" + } + } +} diff --git a/packages/i18n/src/locales/en/common.json b/packages/i18n/src/locales/en/common.json new file mode 100644 index 0000000..9f8e153 --- /dev/null +++ b/packages/i18n/src/locales/en/common.json @@ -0,0 +1,103 @@ +{ + "nav": { + "dashboard": "Dashboard", + "learn": "Learn", + "play": "Play", + "practice": "Practice", + "teach": "Teach", + "classroom": "Classroom", + "leaderboard": "Leaderboard", + "profile": "Profile", + "settings": "Settings", + "admin": "Admin" + }, + "actions": { + "next": "Next", + "back": "Back", + "submit": "Submit", + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "view": "View", + "confirm": "Confirm", + "retry": "Retry", + "close": "Close", + "continue": "Continue", + "start": "Start", + "finish": "Finish", + "skip": "Skip", + "search": "Search", + "filter": "Filter", + "sort": "Sort", + "refresh": "Refresh", + "download": "Download", + "upload": "Upload", + "share": "Share", + "copy": "Copy", + "undo": "Undo", + "redo": "Redo" + }, + "errors": { + "generic": "Something went wrong. Please try again.", + "network": "Network error. Please check your connection.", + "unauthorized": "You need to sign in to access this page.", + "forbidden": "You do not have permission to perform this action.", + "notFound": "The page you are looking for does not exist.", + "validation": "Please check your input and try again.", + "timeout": "The request timed out. Please try again.", + "serverError": "Server error. Our team has been notified.", + "rateLimited": "Too many requests. Please wait a moment.", + "sessionExpired": "Your session has expired. Please sign in again." + }, + "auth": { + "login": "Sign In", + "register": "Create Account", + "logout": "Sign Out", + "forgotPassword": "Forgot Password?", + "resetPassword": "Reset Password", + "changePassword": "Change Password", + "email": "Email address", + "password": "Password", + "confirmPassword": "Confirm password", + "rememberMe": "Remember me", + "noAccount": "Don't have an account?", + "hasAccount": "Already have an account?", + "signUp": "Sign Up", + "signIn": "Sign In", + "welcomeBack": "Welcome back", + "createAccount": "Create your account", + "termsAgree": "I agree to the Terms of Service and Privacy Policy" + }, + "empty": { + "noData": "No data available", + "noResults": "No results found", + "comingSoon": "Coming soon", + "noLessons": "No lessons available yet", + "noSimulations": "No simulations available", + "noAssignments": "No assignments yet", + "noStudents": "No students enrolled", + "noMessages": "No messages yet" + }, + "time": { + "today": "Today", + "yesterday": "Yesterday", + "thisWeek": "This week", + "thisMonth": "This month", + "minutes": "{{count}} min", + "minutes_plural": "{{count}} mins", + "hours": "{{count}} hour", + "hours_plural": "{{count}} hours", + "days": "{{count}} day", + "days_plural": "{{count}} days" + }, + "theme": { + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "language": { + "en": "English", + "el": "Ελληνικά" + } +} diff --git a/packages/i18n/src/locales/en/finance.json b/packages/i18n/src/locales/en/finance.json new file mode 100644 index 0000000..5d0e0d1 --- /dev/null +++ b/packages/i18n/src/locales/en/finance.json @@ -0,0 +1,105 @@ +{ + "assetClasses": { + "stocks": "Stocks", + "bonds": "Bonds", + "etfs": "ETFs", + "mutualFunds": "Mutual Funds", + "crypto": "Cryptocurrency", + "realEstate": "Real Estate", + "commodities": "Commodities", + "cash": "Cash", + "derivatives": "Derivatives", + "forex": "Foreign Exchange" + }, + "investmentTypes": { + "growth": "Growth Investing", + "value": "Value Investing", + "dividend": "Dividend Investing", + "index": "Index Investing", + "esg": "ESG Investing", + "dayTrading": "Day Trading", + "swing": "Swing Trading", + "dca": "Dollar-Cost Averaging" + }, + "banking": { + "savingsAccount": "Savings Account", + "checkingAccount": "Checking Account", + "fixedDeposit": "Fixed Deposit", + "interestRate": "Interest Rate", + "apr": "APR", + "apy": "APY", + "compoundInterest": "Compound Interest", + "principal": "Principal", + "maturity": "Maturity" + }, + "budgeting": { + "income": "Income", + "expenses": "Expenses", + "savings": "Savings", + "investments": "Investments", + "needs": "Needs", + "wants": "Wants", + "emergencyFund": "Emergency Fund", + "trackExpenses": "Track Expenses", + "setBudget": "Set Budget", + "reviewSpending": "Review Spending" + }, + "debt": { + "creditCard": "Credit Card", + "studentLoan": "Student Loan", + "mortgage": "Mortgage", + "personalLoan": "Personal Loan", + "interestRate": "Interest Rate", + "minimumPayment": "Minimum Payment", + "balance": "Balance", + "debtFree": "Debt Free", + "consolidation": "Debt Consolidation", + "snowball": "Debt Snowball", + "avalanche": "Debt Avalanche" + }, + "inflation": { + "inflationRate": "Inflation Rate", + "purchasingPower": "Purchasing Power", + "cpi": "Consumer Price Index", + "realReturn": "Real Return", + "nominalReturn": "Nominal Return", + "hedge": "Inflation Hedge" + }, + "risk": { + "low": "Low Risk", + "medium": "Medium Risk", + "high": "High Risk", + "volatility": "Volatility", + "diversification": "Diversification", + "correlation": "Correlation", + "beta": "Beta", + "alpha": "Alpha", + "sharpeRatio": "Sharpe Ratio", + "maxDrawdown": "Max Drawdown", + "riskTolerance": "Risk Tolerance" + }, + "portfolio": { + "allocation": "Asset Allocation", + "rebalance": "Rebalance", + "performance": "Performance", + "returns": "Returns", + "gain": "Gain", + "loss": "Loss", + "totalValue": "Total Value", + "profitLoss": "Profit / Loss", + "roe": "Return on Investment", + "annualizedReturn": "Annualized Return" + }, + "market": { + "bullMarket": "Bull Market", + "bearMarket": "Bear Market", + "correction": "Correction", + "crash": "Market Crash", + "recession": "Recession", + "recovery": "Recovery", + "volatility": "Market Volatility", + "liquidity": "Liquidity", + "spread": "Bid-Ask Spread", + "volume": "Trading Volume" + } +} diff --git a/packages/i18n/src/locales/en/gamification.json b/packages/i18n/src/locales/en/gamification.json new file mode 100644 index 0000000..4fa61be --- /dev/null +++ b/packages/i18n/src/locales/en/gamification.json @@ -0,0 +1,62 @@ +{ + "xp": { + "label": "XP", + "earned": "{{count}} XP earned", + "needed": "{{count}} XP to next level", + "reason": { + "lessonCompleted": "Lesson completed", + "quizPassed": "Quiz passed", + "streakMaintained": "Streak maintained", + "challengeCompleted": "Challenge completed", + "simulationCompleted": "Simulation completed", + "diversification": "Portfolio diversified", + "riskManagement": "Good risk management", + "hypeAvoided": "Avoided market hype", + "debtPaidOff": "Debt paid off" + } + }, + "level": { + "label": "Level", + "current": "Level {{level}}", + "progress": "Level {{current}} — {{progress}}% to Level {{next}}" + }, + "badges": { + "title": "Badges", + "unlocked": "Badge Unlocked!", + "locked": "Locked", + "progress": "{{count}} of {{total}} badges", + "categories": { + "milestone": "Milestones", + "skill": "Skills", + "behavior": "Behaviors", + "special": "Special" + } + }, + "achievements": { + "title": "Achievements", + "recent": "Recent Achievements", + "all": "All Achievements", + "empty": "Complete lessons to earn achievements" + }, + "streaks": { + "label": "Streak", + "current": "{{count}}-day streak", + "longest": "Longest: {{count}} days", + "days": "{{count}} day", + "days_plural": "{{count}} days", + "maintain": "Complete a lesson today to keep your streak!", + "lost": "Streak lost. Start again tomorrow!" + }, + "leaderboard": { + "title": "Leaderboard", + "rank": "Rank", + "player": "Player", + "xp": "XP", + "level": "Level", + "badges": "Badges", + "yourRank": "Your Rank", + "empty": "No rankings yet. Start learning!", + "classroom": "Classroom Leaderboard", + "global": "Global Leaderboard" + } +} diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json new file mode 100644 index 0000000..40a7c6a --- /dev/null +++ b/packages/i18n/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "resolveJsonModule": true, + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["src/**/*.ts", "src/**/*.json"], + "exclude": ["node_modules", "dist", ".turbo"] +} diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000..993da90 --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,18 @@ +{ + "name": "@investplay/types", + "version": "0.1.0", + "private": true, + "description": "InvestPlay shared type definitions", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "lint": "eslint \"src/**/*.ts\" --fix", + "typecheck": "tsc --noEmit", + "clean": "rimraf .turbo" + }, + "devDependencies": { + "typescript": "^5.8.0", + "eslint": "^8.57.0", + "rimraf": "^6.0.0" + } +} diff --git a/packages/types/src/ai.ts b/packages/types/src/ai.ts new file mode 100644 index 0000000..1df1507 --- /dev/null +++ b/packages/types/src/ai.ts @@ -0,0 +1,38 @@ +export interface AICoachMessage { + id: string; + sessionId: string; + role: "user" | "assistant" | "system"; + content: string; + metadata: { + tokensUsed: number; + processingTimeMs: number; + model: string; + } | null; + createdAt: string; +} + +export interface AICoachContext { + userId: string; + lessonId: string | null; + simulationType: string | null; + portfolio: Record | null; + recentDecisions: string[]; + userLevel: number; + preferredLocale: string; +} + +export interface AIModeConfig { + mode: "MANAGED" | "BYOK" | "DISABLED"; + apiEndpoint: string | null; + modelName: string | null; + maxTokensPerMessage: number; + temperature: number; +} + +export interface AITokenBudget { + tenantId: string; + monthlyLimit: number; + usedThisMonth: number; + remaining: number; + resetDate: string; +} diff --git a/packages/types/src/analytics.ts b/packages/types/src/analytics.ts new file mode 100644 index 0000000..86687e8 --- /dev/null +++ b/packages/types/src/analytics.ts @@ -0,0 +1,22 @@ +export enum AnalyticsEventType { + LESSON_STARTED = "LESSON_STARTED", + LESSON_COMPLETED = "LESSON_COMPLETED", + QUIZ_ATTEMPTED = "QUIZ_ATTEMPTED", + SIMULATION_JOINED = "SIMULATION_JOINED", + TRADE_EXECUTED = "TRADE_EXECUTED", + RISK_WARNING_SHOWN = "RISK_WARNING_SHOWN", + RISK_WARNING_IGNORED = "RISK_WARNING_IGNORED", + PANIC_SELL_DETECTED = "PANIC_SELL_DETECTED", + HYPE_TRAP_TRIGGERED = "HYPE_TRAP_TRIGGERED", + AI_COACH_CONSULTED = "AI_COACH_CONSULTED", +} + +export interface AnalyticsEvent { + id: string; + eventType: AnalyticsEventType; + userId: string; + tenantId: string; + sessionId: string | null; + payload: Record; + timestamp: string; +} diff --git a/packages/types/src/classroom.ts b/packages/types/src/classroom.ts new file mode 100644 index 0000000..1b8db73 --- /dev/null +++ b/packages/types/src/classroom.ts @@ -0,0 +1,76 @@ +export interface Classroom { + id: string; + name: string; + description: string | null; + tenantId: string; + teacherId: string; + inviteCode: string; + students: ClassroomStudent[]; + assignments: Assignment[]; + createdAt: string; + updatedAt: string; +} + +export interface ClassroomStudent { + id: string; + classroomId: string; + userId: string; + joinedAt: string; + studentName: string; + studentEmail: string; +} + +export interface Assignment { + id: string; + classroomId: string; + lessonId: string; + title: string; + description: string | null; + dueDate: string | null; + requiredScore: number; + maxAttempts: number; + submissions: AssignmentSubmission[]; + createdAt: string; + updatedAt: string; +} + +export interface AssignmentSubmission { + id: string; + assignmentId: string; + userId: string; + score: number | null; + completed: boolean; + attempts: number; + submittedAt: string | null; + updatedAt: string; +} + +export interface TeacherReport { + classroomId: string; + classroomName: string; + totalStudents: number; + averageScore: number; + completionRate: number; + studentReports: StudentReport[]; + generatedAt: string; +} + +export interface StudentReport { + userId: string; + studentName: string; + compositeScore: CompositeScore; + lessonsCompleted: number; + averageQuizScore: number; + simulationsPlayed: number; + totalXp: number; + lastActivity: string | null; +} + +export interface CompositeScore { + overall: number; + roi: number; + riskManagement: number; + esgAlignment: number; + quizScore: number; + lessonCompletion: number; +} diff --git a/packages/types/src/curriculum.ts b/packages/types/src/curriculum.ts new file mode 100644 index 0000000..e163669 --- /dev/null +++ b/packages/types/src/curriculum.ts @@ -0,0 +1,70 @@ +export enum BlockType { + HERO = "HERO", + TEXT = "TEXT", + MEDIA = "MEDIA", + QUIZ = "QUIZ", + CALCULATOR = "CALCULATOR", + BUDGET_GAME = "BUDGET_GAME", + BLIND_SIMULATION = "BLIND_SIMULATION", + DRAG_DROP = "DRAG_DROP", + REFLECTION = "REFLECTION", + TEACHER_NOTE = "TEACHER_NOTE", + ASSIGNMENT = "ASSIGNMENT", + SUMMARY = "SUMMARY", +} + +export enum CurriculumTrack { + SURVIVAL_BASICS = "SURVIVAL_BASICS", + CREDIT_DEBT = "CREDIT_DEBT", + WEALTH_BUILDER = "WEALTH_BUILDER", + ANTI_HYPE = "ANTI_HYPE", + MACROECONOMICS = "MACROECONOMICS", +} + +export interface LessonBlock { + id: string; + type: BlockType; + order: number; + content: Record; + config: Record | null; +} + +export interface Lesson { + id: string; + slug: string; + title: string; + description: string; + track: CurriculumTrack; + moduleId: string; + order: number; + estimatedMinutes: number; + xpReward: number; + blocks: LessonBlock[]; + tags: string[]; + prerequisites: string[]; + createdAt: string; + updatedAt: string; +} + +export interface Module { + id: string; + slug: string; + title: string; + description: string; + track: CurriculumTrack; + order: number; + lessons: Lesson[]; + createdAt: string; + updatedAt: string; +} + +export interface UserProgress { + userId: string; + lessonId: string; + completed: boolean; + score: number | null; + timeSpentSeconds: number; + attempts: number; + startedAt: string; + completedAt: string | null; +} diff --git a/packages/types/src/gamification.ts b/packages/types/src/gamification.ts new file mode 100644 index 0000000..a27d000 --- /dev/null +++ b/packages/types/src/gamification.ts @@ -0,0 +1,60 @@ +export enum XPReason { + LESSON_COMPLETED = "LESSON_COMPLETED", + QUIZ_PASSED = "QUIZ_PASSED", + STREAK_MAINTAINED = "STREAK_MAINTAINED", + CHALLENGE_COMPLETED = "CHALLENGE_COMPLETED", + SIMULATION_COMPLETED = "SIMULATION_COMPLETED", + DIVERSIFICATION = "DIVERSIFICATION", + RISK_MANAGEMENT = "RISK_MANAGEMENT", + HYPE_AVOIDED = "HYPE_AVOIDED", + DEBT_PAID_OFF = "DEBT_PAID_OFF", +} + +export interface Badge { + id: string; + slug: string; + name: string; + description: string; + iconUrl: string; + category: "milestone" | "skill" | "behavior" | "special"; + requiredXp: number | null; + createdAt: string; +} + +export interface Achievement { + id: string; + badgeId: string; + userId: string; + unlockedAt: string; + badge: Badge; +} + +export interface UserBadge { + id: string; + userId: string; + badgeId: string; + badge: Badge; + earnedAt: string; + visible: boolean; +} + +export interface LeaderboardEntry { + userId: string; + rank: number; + firstName: string; + lastName: string; + avatarUrl: string | null; + xp: number; + level: number; + badges: number; + tenantId: string; +} + +export interface XPEvent { + userId: string; + amount: number; + reason: XPReason; + referenceId: string; + referenceType: "lesson" | "quiz" | "simulation" | "challenge" | "streak"; + createdAt: string; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 0000000..43e28c4 --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1,40 @@ +export type { User, UserRole } from "./user.js"; +export type { Tenant, TenantFeatureFlags, AIMode } from "./tenant.js"; +export type { + BlockType, + CurriculumTrack, + LessonBlock, + Lesson, + Module, + UserProgress, +} from "./curriculum.js"; +export type { + SimulationType, + SimulationTick, + Portfolio, + Trade, + SimulationSession, +} from "./simulation.js"; +export type { + Badge, + Achievement, + UserBadge, + LeaderboardEntry, + XPEvent, + XPReason, +} from "./gamification.js"; +export type { + Classroom, + ClassroomStudent, + Assignment, + AssignmentSubmission, + TeacherReport, + CompositeScore, +} from "./classroom.js"; +export type { AnalyticsEvent, AnalyticsEventType } from "./analytics.js"; +export type { + AICoachMessage, + AICoachContext, + AIModeConfig, + AITokenBudget, +} from "./ai.js"; diff --git a/packages/types/src/simulation.ts b/packages/types/src/simulation.ts new file mode 100644 index 0000000..0edaf7c --- /dev/null +++ b/packages/types/src/simulation.ts @@ -0,0 +1,91 @@ +export enum SimulationType { + BUDGET_CHALLENGE = "BUDGET_CHALLENGE", + EMERGENCY_FUND = "EMERGENCY_FUND", + COST_OF_LIVING = "COST_OF_LIVING", + BLIND_ASSET = "BLIND_ASSET", + MARKET_CRASH = "MARKET_CRASH", + RENT_VS_TRANSPORT = "RENT_VS_TRANSPORT", + SAVINGS_HABIT = "SAVINGS_HABIT", + ESG_PORTFOLIO = "ESG_PORTFOLIO", + INTEREST_APY = "INTEREST_APY", + DEBT_REPAYMENT = "DEBT_REPAYMENT", +} + +export interface SimulationTick { + tick: number; + label: string; + values: Record; + events: SimulationEvent[]; +} + +export interface SimulationEvent { + type: string; + description: string; + impact: Record; + choice?: { + options: SimulationChoice[]; + chosenOptionId: string | null; + }; +} + +export interface SimulationChoice { + id: string; + label: string; + description: string; + impacts: Record; + isTrap?: boolean; + trapReveal?: string; +} + +export interface Portfolio { + cash: number; + investments: Investment[]; + debt: Debt[]; + monthlyIncome: number; + monthlyExpenses: number; +} + +export interface Investment { + id: string; + name: string; + type: "stock" | "bond" | "etf" | "crypto" | "real_estate" | "commodity" | "cash"; + amount: number; + value: number; + risk: "low" | "medium" | "high"; + esgScore: number | null; +} + +export interface Debt { + id: string; + name: string; + type: "credit_card" | "student_loan" | "mortgage" | "personal_loan"; + principal: number; + interestRate: number; + minimumPayment: number; +} + +export interface Trade { + id: string; + sessionId: string; + assetName: string; + type: "buy" | "sell"; + quantity: number; + price: number; + totalValue: number; + timestamp: string; + reason: string; +} + +export interface SimulationSession { + id: string; + userId: string; + simulationType: SimulationType; + ticks: SimulationTick[]; + portfolio: Portfolio; + trades: Trade[]; + score: number; + decisions: number; + completed: boolean; + startedAt: string; + completedAt: string | null; +} diff --git a/packages/types/src/tenant.ts b/packages/types/src/tenant.ts new file mode 100644 index 0000000..dee9dfb --- /dev/null +++ b/packages/types/src/tenant.ts @@ -0,0 +1,32 @@ +export enum AIMode { + MANAGED = "MANAGED", + BYOK = "BYOK", + DISABLED = "DISABLED", +} + +export interface TenantFeatureFlags { + aiCoach: boolean; + classroom: boolean; + simulations: boolean; + gamification: boolean; + tenantDashboard: boolean; + researchProgram: boolean; +} + +export interface Tenant { + id: string; + name: string; + domain: string | null; + branding: { + logoUrl: string | null; + primaryColor: string | null; + accentColor: string | null; + faviconUrl: string | null; + } | null; + features: TenantFeatureFlags; + aiMode: AIMode; + monthlyTokenBudget: number; + defaultLocale: string; + excludeFromResearch: boolean; + createdAt: string; +} diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts new file mode 100644 index 0000000..9e90d5b --- /dev/null +++ b/packages/types/src/user.ts @@ -0,0 +1,28 @@ +export enum UserRole { + STUDENT = "STUDENT", + TEACHER = "TEACHER", + TENANT_ADMIN = "TENANT_ADMIN", + PLATFORM_ADMIN = "PLATFORM_ADMIN", + CONTENT_MANAGER = "CONTENT_MANAGER", + RESEARCHER = "RESEARCHER", +} + +export interface User { + id: string; + email: string; + firstName: string; + lastName: string; + role: UserRole; + locale: string; + tenantId: string; + xp: number; + level: number; + streaks: { + current: number; + longest: number; + lastActivityDate: string | null; + }; + avatarUrl: string | null; + createdAt: string; + updatedAt: string; +} diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 0000000..d585e42 --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", ".turbo"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..9b9bb28 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,41 @@ +{ + "name": "@investplay/ui", + "version": "0.1.0", + "private": true, + "description": "InvestPlay shared UI component library", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "lint": "eslint \"src/**/*.{ts,tsx}\" --fix", + "typecheck": "tsc --noEmit", + "clean": "rimraf .turbo" + }, + "peerDependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0", + "tailwindcss": "^4.1.0" + }, + "dependencies": { + "clsx": "^2.1.0", + "tailwind-merge": "^3.2.0", + "class-variance-authority": "^0.7.1", + "@radix-ui/react-dialog": "^1.1.0", + "@radix-ui/react-dropdown-menu": "^2.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.0", + "@radix-ui/react-popover": "^1.1.0", + "@radix-ui/react-select": "^2.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-avatar": "^1.1.0", + "lucide-react": "^0.490.0" + }, + "devDependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0", + "typescript": "^5.8.0", + "eslint": "^8.57.0", + "rimraf": "^6.0.0" + } +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000..cfdb537 --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1 @@ +export { cn, formatCurrency, formatXP, formatPercentage } from "./lib/utils.js"; diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts new file mode 100644 index 0000000..b136114 --- /dev/null +++ b/packages/ui/src/lib/utils.ts @@ -0,0 +1,50 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} + +export function formatCurrency( + amount: number, + currency = "EUR", + locale = "en", +): string { + try { + return new Intl.NumberFormat(locale, { + style: "currency", + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); + } catch { + return `${currency} ${amount.toFixed(2)}`; + } +} + +export function formatXP(xp: number, locale = "en"): string { + try { + return new Intl.NumberFormat(locale, { + notation: "compact", + maximumFractionDigits: 1, + }).format(xp); + } catch { + return `${xp}`; + } +} + +export function formatPercentage( + value: number, + locale = "en", + decimals = 1, +): string { + try { + return new Intl.NumberFormat(locale, { + style: "percent", + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value / 100); + } catch { + return `${value.toFixed(decimals)}%`; + } +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 0000000..0d2609f --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "./dist", + "rootDir": "./src", + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist", ".turbo"] +} diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 0000000..e3c0c46 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,23 @@ +{ + "name": "@investplay/utils", + "version": "0.1.0", + "private": true, + "description": "InvestPlay shared utility functions", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "lint": "eslint \"src/**/*.ts\" --fix", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rimraf .turbo" + }, + "dependencies": { + "zod": "^3.24.0" + }, + "devDependencies": { + "typescript": "^5.8.0", + "vitest": "^3.1.0", + "eslint": "^8.57.0", + "rimraf": "^6.0.0" + } +} diff --git a/packages/utils/src/constants.ts b/packages/utils/src/constants.ts new file mode 100644 index 0000000..b2516fd --- /dev/null +++ b/packages/utils/src/constants.ts @@ -0,0 +1,34 @@ +export const SUPPORTED_LOCALES = ["en", "el"] as const; +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; + +export const DEFAULT_LOCALE: SupportedLocale = "en"; + +export const CURRENCY_SYMBOLS: Record = { + EUR: "€", + USD: "$", + GBP: "£", + JPY: "¥", + CNY: "¥", + BTC: "₿", + ETH: "Ξ", +}; + +export const AGE_BRACKETS = [ + { min: 13, max: 15, label: "13-15" }, + { min: 16, max: 18, label: "16-18" }, + { min: 19, max: 24, label: "19-24" }, + { min: 25, max: 35, label: "25-35" }, + { min: 36, max: 50, label: "36-50" }, + { min: 51, max: 99, label: "51+" }, +] as const; + +export const LESSON_TIME_MIN = 5; +export const LESSON_TIME_MAX = 45; + +export const MAX_DAILY_AI_MESSAGES = 50; + +export const MAX_MONTHLY_TOKENS = { + managed: 1_000_000, + byok: 10_000_000, + disabled: 0, +} as const; diff --git a/packages/utils/src/env.ts b/packages/utils/src/env.ts new file mode 100644 index 0000000..88b1914 --- /dev/null +++ b/packages/utils/src/env.ts @@ -0,0 +1,52 @@ +/** + * Parse a string environment variable into a boolean. + * Accepts: "true", "1", "yes" (case-insensitive) → true; everything else → false. + */ +export function parseEnvBoolean(value: string | undefined, defaultValue = false): boolean { + if (value === undefined || value === null) return defaultValue; + const normalized = value.trim().toLowerCase(); + return normalized === "true" || normalized === "1" || normalized === "yes"; +} + +/** + * Parse a string environment variable into a number with optional bounds. + */ +export function parseEnvNumber( + value: string | undefined, + defaultValue: number, + options?: { min?: number; max?: number }, +): number { + if (value === undefined || value === null || value.trim() === "") return defaultValue; + const parsed = Number(value); + if (Number.isNaN(parsed)) return defaultValue; + if (options?.min !== undefined && parsed < options.min) return options.min; + if (options?.max !== undefined && parsed > options.max) return options.max; + return parsed; +} + +/** + * Parse a string environment variable into one of the allowed enum values. + * Returns the default value if the input doesn't match any allowed value. + */ +export function parseEnvEnum( + value: string | undefined, + allowedValues: readonly T[], + defaultValue: T, +): T { + if (value === undefined || value === null) return defaultValue; + const normalized = value.trim() as T; + if (allowedValues.includes(normalized)) return normalized; + return defaultValue; +} + +/** + * Parse a comma-separated string environment variable into an array of strings. + * Trims each entry and filters out empty strings. + */ +export function parseEnvArray(value: string | undefined, defaultValue: string[] = []): string[] { + if (value === undefined || value === null || value.trim() === "") return defaultValue; + return value + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} diff --git a/packages/utils/src/formatting.ts b/packages/utils/src/formatting.ts new file mode 100644 index 0000000..3ee1bb8 --- /dev/null +++ b/packages/utils/src/formatting.ts @@ -0,0 +1,95 @@ +export function formatCurrency( + amount: number, + locale = "en", + currency = "EUR", +): string { + try { + return new Intl.NumberFormat(locale, { + style: "currency", + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); + } catch { + return `${currency} ${amount.toFixed(2)}`; + } +} + +export function formatDate( + date: Date | string, + locale = "en", + options?: Intl.DateTimeFormatOptions, +): string { + const d = typeof date === "string" ? new Date(date) : date; + try { + return new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "short", + day: "numeric", + ...options, + }).format(d); + } catch { + return d.toLocaleDateString(); + } +} + +export function formatRelativeTime( + date: Date | string, + locale = "en", +): string { + const d = typeof date === "string" ? new Date(date) : date; + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + + try { + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + + if (diffSeconds < 60) return rtf.format(-diffSeconds, "second"); + if (diffMinutes < 60) return rtf.format(-diffMinutes, "minute"); + if (diffHours < 24) return rtf.format(-diffHours, "hour"); + if (diffDays < 30) return rtf.format(-diffDays, "day"); + return formatDate(d, locale); + } catch { + if (diffDays > 0) return `${diffDays}d ago`; + if (diffHours > 0) return `${diffHours}h ago`; + if (diffMinutes > 0) return `${diffMinutes}m ago`; + return "just now"; + } +} + +export function formatNumber( + value: number, + locale = "en", + options?: Intl.NumberFormatOptions, +): string { + try { + return new Intl.NumberFormat(locale, options).format(value); + } catch { + return value.toLocaleString(); + } +} + +export function formatPercentage( + value: number, + locale = "en", + decimals = 1, +): string { + try { + return new Intl.NumberFormat(locale, { + style: "percent", + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value / 100); + } catch { + return `${value.toFixed(decimals)}%`; + } +} + +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.slice(0, maxLength).trimEnd() + "..."; +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 0000000..34ed8fb --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,26 @@ +export { + emailSchema, + passwordSchema, + tenantNameSchema, + localeSchema, + dailyMessageLimitSchema, + simulationTradeSchema, +} from "./validation.js"; +export { + formatCurrency, + formatDate, + formatRelativeTime, + formatNumber, + formatPercentage, + truncateText, +} from "./formatting.js"; +export { + SUPPORTED_LOCALES, + DEFAULT_LOCALE, + CURRENCY_SYMBOLS, + AGE_BRACKETS, + LESSON_TIME_MIN, + LESSON_TIME_MAX, + MAX_DAILY_AI_MESSAGES, + MAX_MONTHLY_TOKENS, +} from "./constants.js"; diff --git a/packages/utils/src/validation.ts b/packages/utils/src/validation.ts new file mode 100644 index 0000000..127c95e --- /dev/null +++ b/packages/utils/src/validation.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +export const emailSchema = z + .string() + .email("Invalid email address") + .max(255, "Email must be under 255 characters") + .transform((v) => v.toLowerCase().trim()); + +export const passwordSchema = z + .string() + .min(8, "Password must be at least 8 characters") + .max(128, "Password must be under 128 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number") + .regex( + /[^A-Za-z0-9]/, + "Password must contain at least one special character", + ); + +export const tenantNameSchema = z + .string() + .min(2, "Tenant name must be at least 2 characters") + .max(100, "Tenant name must be under 100 characters") + .regex( + /^[a-zA-Z0-9\s\-'&.]+$/, + "Tenant name contains invalid characters", + ); + +export const localeSchema = z.enum(["en", "el"]); + +export const dailyMessageLimitSchema = z + .number() + .int() + .min(1, "Minimum 1 message per day") + .max(500, "Maximum 500 messages per day"); + +export const simulationTradeSchema = z.object({ + assetName: z.string().min(1, "Asset name is required"), + type: z.enum(["buy", "sell"]), + quantity: z.number().positive("Quantity must be positive"), + price: z.number().positive("Price must be positive"), + reason: z.string().max(500, "Reason must be under 500 characters").optional(), +}); diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 0000000..12a4b78 --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", ".turbo"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100644 index 0000000..aa7cf56 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ─── Configuration ────────────────────────────────────────────── +BACKUP_DIR="${BACKUP_DIR:-./backups}" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +BACKUP_PATH="${BACKUP_DIR}/${TIMESTAMP}" +RETENTION_DAILY=7 +RETENTION_WEEKLY=4 + +# S3-compatible backup bucket (optional) +S3_ENDPOINT="${S3_BACKUP_ENDPOINT:-}" +S3_BUCKET="${S3_BACKUP_BUCKET:-}" +S3_ACCESS_KEY="${S3_BACKUP_ACCESS_KEY:-}" +S3_SECRET_KEY="${S3_BACKUP_SECRET_KEY:-}" + +# ─── Color output ────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; } + +cleanup() { + local exit_code=$? + if [ $exit_code -ne 0 ]; then + error "Backup script failed with exit code ${exit_code}" + fi + exit $exit_code +} +trap cleanup EXIT + +# ─── Ensure backup directory ─────────────────────────────────── +mkdir -p "${BACKUP_PATH}" + +# ─── 1. PostgreSQL backup ─────────────────────────────────────── +info "Backing up PostgreSQL..." +POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-investplay-postgres}" +POSTGRES_USER="${POSTGRES_USER:-investplay}" +POSTGRES_DB="${POSTGRES_DB:-investplay}" + +if docker ps --format '{{.Names}}' | grep -q "^${POSTGRES_CONTAINER}$"; then + docker exec "${POSTGRES_CONTAINER}" pg_dump -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \ + --no-owner --no-acl --compress=9 \ + > "${BACKUP_PATH}/postgres_${TIMESTAMP}.sql.gz" 2>/dev/null || { + warn "pg_dump failed — database may be temporarily unavailable" + } + ok "PostgreSQL backup saved" +else + warn "PostgreSQL container '${POSTGRES_CONTAINER}' not running — skipping" +fi + +# ─── 2. Redis backup ──────────────────────────────────────────── +info "Backing up Redis..." +REDIS_CONTAINER="${REDIS_CONTAINER:-investplay-redis}" + +if docker ps --format '{{.Names}}' | grep -q "^${REDIS_CONTAINER}$"; then + docker exec "${REDIS_CONTAINER}" redis-cli SAVE >/dev/null 2>&1 || { + warn "Redis SAVE command failed" + } + # Copy the dump file from the container + docker cp "${REDIS_CONTAINER}:/data/dump.rdb" "${BACKUP_PATH}/redis_${TIMESTAMP}.rdb" 2>/dev/null || { + warn "Failed to copy Redis dump" + } + ok "Redis backup saved" +else + warn "Redis container '${REDIS_CONTAINER}' not running — skipping" +fi + +# ─── 3. MinIO backup ──────────────────────────────────────────── +info "Backing up MinIO..." +MINIO_CONTAINER="${MINIO_CONTAINER:-investplay-minio}" +MINIO_ALIAS="${MINIO_ALIAS:-investplay}" +MINIO_USER="${MINIO_ROOT_USER:-minioadmin}" +MINIO_PASS="${MINIO_ROOT_PASSWORD:-}" + +if docker ps --format '{{.Names}}' | grep -q "^${MINIO_CONTAINER}$"; then + # Configure mc alias inside the container + docker exec "${MINIO_CONTAINER}" mc alias set "${MINIO_ALIAS}" \ + http://localhost:9000 "${MINIO_USER}" "${MINIO_PASS}" >/dev/null 2>&1 + + # Mirror all buckets to local backup directory + mkdir -p "${BACKUP_PATH}/minio" + docker exec "${MINIO_CONTAINER}" mc mirror --overwrite \ + "${MINIO_ALIAS}/" "/tmp/minio-backup" >/dev/null 2>&1 || { + warn "MinIO mc mirror failed" + } + docker cp "${MINIO_CONTAINER}:/tmp/minio-backup/." "${BACKUP_PATH}/minio/" 2>/dev/null || true + docker exec "${MINIO_CONTAINER}" rm -rf /tmp/minio-backup 2>/dev/null || true + ok "MinIO backup saved" +else + warn "MinIO container '${MINIO_CONTAINER}' not running — skipping" +fi + +# ─── 4. Upload to S3-compatible backup bucket (optional) ──────── +if [ -n "${S3_ENDPOINT}" ] && [ -n "${S3_BUCKET}" ]; then + info "Uploading backup to S3-compatible storage..." + + if command -v mc &>/dev/null; then + mc alias set backup-target "${S3_ENDPOINT}" "${S3_ACCESS_KEY}" "${S3_SECRET_KEY}" >/dev/null 2>&1 + mc mirror "${BACKUP_PATH}" "backup-target/${S3_BUCKET}/investplay/${TIMESTAMP}" >/dev/null 2>&1 && { + ok "Backup uploaded to S3" + } || { + warn "S3 upload failed" + } + elif command -v aws &>/dev/null; then + AWS_ACCESS_KEY_ID="${S3_ACCESS_KEY}" AWS_SECRET_ACCESS_KEY="${S3_SECRET_KEY}" \ + aws s3 cp --endpoint-url="${S3_ENDPOINT}" --recursive \ + "${BACKUP_PATH}" "s3://${S3_BUCKET}/investplay/${TIMESTAMP}/" >/dev/null 2>&1 && { + ok "Backup uploaded to S3" + } || { + warn "S3 upload failed" + } + else + warn "Neither 'mc' nor 'aws' CLI found — skipping S3 upload" + fi +fi + +# ─── 5. Cleanup old backups ───────────────────────────────────── +info "Cleaning up old backups..." +total_backups=$(ls -1d "${BACKUP_DIR}/"20*/ 2>/dev/null | wc -l) + +# Keep last N daily backups +ls -1d "${BACKUP_DIR}/"20*/ 2>/dev/null | sort -r | tail -n +$((RETENTION_DAILY + 1)) | while read -r dir; do + rm -rf "${dir}" + info "Removed old backup: $(basename "${dir}")" +done + +# Keep last N weekly backups (every Sunday) +if [ "$(date +%u)" -eq 7 ]; then + ls -1d "${BACKUP_DIR}/"20*/ 2>/dev/null | sort -r | tail -n +$((RETENTION_WEEKLY + 1)) | while read -r dir; do + rm -rf "${dir}" + info "Removed weekly backup: $(basename "${dir}")" + done +fi + +ok "Cleanup complete" + +# ─── Summary ──────────────────────────────────────────────────── +backup_size=$(du -sh "${BACKUP_PATH}" | cut -f1) +echo "" +echo -e "${GREEN}══════════════════════════════════════════════════════════${NC}" +echo -e "${GREEN} Backup completed successfully${NC}" +echo -e "${GREEN}══════════════════════════════════════════════════════════${NC}" +echo "" +echo -e " ${CYAN}Location:${NC} ${BACKUP_PATH}" +echo -e " ${CYAN}Size:${NC} ${backup_size}" +echo -e " ${CYAN}Date:${NC} $(date)" +echo "" diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh new file mode 100644 index 0000000..8fa176a --- /dev/null +++ b/scripts/healthcheck.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ─── Color output ────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; } +header() { echo -e "\n${YELLOW}─── $* ───${NC}"; } + +EXIT_CODE=0 + +# ─── Docker service health ────────────────────────────────────── +header "Docker Container Status" + +containers=$(docker ps --format "{{.Names}}" 2>/dev/null || true) +if [ -z "${containers}" ]; then + warn "No running Docker containers found" +else + while IFS= read -r name; do + status=$(docker inspect --format='{{.State.Status}}' "${name}" 2>/dev/null || echo "unknown") + health=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "${name}" 2>/dev/null || echo "none") + + if [ "${status}" = "running" ]; then + if [ "${health}" = "healthy" ]; then + ok "${name} — running, healthy" + elif [ "${health}" = "unhealthy" ]; then + error "${name} — running but UNHEALTHY" + EXIT_CODE=1 + else + warn "${name} — running (no health check)" + fi + else + error "${name} — ${status}" + EXIT_CODE=1 + fi + done <<< "${containers}" +fi + +# ─── HTTP health endpoints ─────────────────────────────────────── +header "HTTP Health Endpoints" + +check_endpoint() { + local name="$1" + local url="$2" + local expected_code="${3:-200}" + + if command -v curl &>/dev/null; then + http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "${url}" 2>/dev/null || echo "000") + elif command -v wget &>/dev/null; then + http_code=$(wget --server-response --spider --timeout=5 "${url}" 2>&1 | awk '/HTTP\/1.1/{print $2}' | tail -1 || echo "000") + else + warn "Neither curl nor wget available — skipping HTTP checks" + return + fi + + if [ "${http_code}" = "${expected_code}" ] || [ "${http_code}" = "000" ]; then + if [ "${http_code}" = "000" ]; then + warn "${name} — could not connect to ${url}" + else + ok "${name} — ${url} returned ${http_code}" + fi + else + error "${name} — ${url} returned ${http_code} (expected ${expected_code})" + EXIT_CODE=1 + fi +} + +check_endpoint "API" "http://localhost:3001/health" +check_endpoint "Web" "http://localhost:80/" +check_endpoint "CMS" "http://localhost:3000/health" +check_endpoint "MinIO (API)" "http://localhost:9000/minio/health/live" +check_endpoint "MinIO (Console)" "http://localhost:9001/" + +# ─── Port check ───────────────────────────────────────────────── +header "Port Availability" + +check_port() { + local name="$1" + local port="$2" + + if command -v nc &>/dev/null; then + if nc -z localhost "${port}" 2>/dev/null; then + ok "${name} — port ${port} is open" + else + error "${name} — port ${port} is CLOSED" + EXIT_CODE=1 + fi + else + # Fallback: try connecting with /dev/tcp (bash built-in) + if timeout 1 bash -c "echo >/dev/tcp/localhost/${port}" 2>/dev/null; then + ok "${name} — port ${port} is open" + else + error "${name} — port ${port} is CLOSED" + EXIT_CODE=1 + fi + fi +} + +check_port "PostgreSQL" 5432 +check_port "Redis" 6379 + +# ─── Summary ──────────────────────────────────────────────────── +echo "" +if [ "${EXIT_CODE}" -eq 0 ]; then + echo -e "${GREEN}══════════════════════════════════════════════════════════${NC}" + echo -e "${GREEN} All services are healthy!${NC}" + echo -e "${GREEN}══════════════════════════════════════════════════════════${NC}" +else + echo -e "${RED}══════════════════════════════════════════════════════════${NC}" + echo -e "${RED} Some services have issues (exit code: ${EXIT_CODE})${NC}" + echo -e "${RED}══════════════════════════════════════════════════════════${NC}" +fi + +exit "${EXIT_CODE}" diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100644 index 0000000..cf61494 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ─── Color output ────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; } + +# ─── Prerequisites ────────────────────────────────────────────── +info "Checking prerequisites..." + +command -v docker >/dev/null 2>&1 || { error "Docker is required but not installed."; exit 1; } +ok "Docker $(docker --version)" + +docker compose version >/dev/null 2>&1 || { error "Docker Compose is required but not installed."; exit 1; } +ok "Docker Compose $(docker compose version --short)" + +command -v node >/dev/null 2>&1 || { error "Node.js is required but not installed."; exit 1; } +ok "Node.js $(node --version)" + +command -v pnpm >/dev/null 2>&1 || { error "pnpm is required but not installed. Install it: npm install -g pnpm"; exit 1; } +ok "pnpm $(pnpm --version)" + +# ─── Project root ─────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${PROJECT_ROOT}" + +# ─── Environment file ─────────────────────────────────────────── +if [ ! -f .env ]; then + if [ -f .env.example ]; then + cp .env.example .env + info "Created .env from .env.example — please review and update credentials." + else + warn "No .env.example found. Creating minimal .env..." + cat > .env <<-EOF +POSTGRES_USER=investplay +POSTGRES_PASSWORD=investplay_dev_pass +POSTGRES_DB=investplay +DATABASE_URL=postgresql://investplay:investplay_dev_pass@localhost:5432/investplay +REDIS_URL=redis://localhost:6379 +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin_dev +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_USE_SSL=false +JWT_SECRET=change-me-in-production +EOF + ok "Created minimal .env" + fi +else + ok ".env already exists" +fi + +# ─── Start infrastructure services ────────────────────────────── +info "Starting infrastructure (PostgreSQL, Redis, MinIO)..." +docker compose -f docker-compose.dev.yml up -d postgres redis minio + +info "Waiting for PostgreSQL to become healthy..." +until docker compose -f docker-compose.dev.yml exec -T postgres pg_isready -U "${POSTGRES_USER:-investplay}" 2>/dev/null; do + sleep 2 +done +ok "PostgreSQL is ready" + +info "Waiting for Redis to become healthy..." +until docker compose -f docker-compose.dev.yml exec -T redis redis-cli ping 2>/dev/null | grep -q PONG; do + sleep 1 +done +ok "Redis is ready" + +info "Waiting for MinIO to become healthy..." +until docker compose -f docker-compose.dev.yml exec -T minio mc ready local 2>/dev/null; do + sleep 2 +done +ok "MinIO is ready" + +# ─── Install dependencies ─────────────────────────────────────── +info "Installing pnpm dependencies..." +pnpm install +ok "Dependencies installed" + +# ─── Generate Prisma client ───────────────────────────────────── +info "Running Prisma migrations..." +if [ -f apps/api/prisma/schema.prisma ]; then + pnpm --filter @investplay/api exec prisma generate + pnpm --filter @investplay/api exec prisma migrate dev --name init 2>/dev/null || true + ok "Prisma client generated and migrations applied" +else + warn "No Prisma schema found at apps/api/prisma/schema.prisma — skipping migrations" +fi + +# ─── Seed database ───────────────────────────────────────────── +if [ -f apps/api/prisma/seed.ts ] || [ -f apps/api/prisma/seed.js ]; then + info "Seeding database..." + pnpm --filter @investplay/api exec prisma db seed 2>/dev/null || true + ok "Database seeded" +else + info "No seed script found — skipping" +fi + +# ─── Start remaining services ────────────────────────────────── +info "Starting API, Web, and CMS..." +docker compose -f docker-compose.dev.yml up -d api web cms +ok "All services started" + +# ─── Summary ──────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}══════════════════════════════════════════════════════════${NC}" +echo -e "${GREEN} InvestPlay development environment is ready!${NC}" +echo -e "${GREEN}══════════════════════════════════════════════════════════${NC}" +echo "" +echo -e " ${CYAN}Web App:${NC} http://localhost:5173" +echo -e " ${CYAN}API:${NC} http://localhost:3001" +echo -e " ${CYAN}CMS:${NC} http://localhost:3000" +echo -e " ${CYAN}MinIO Console:${NC} http://localhost:9001" +echo -e " ${CYAN}PostgreSQL:${NC} localhost:5432" +echo -e " ${CYAN}Redis:${NC} localhost:6379" +echo "" +echo -e " ${YELLOW}Run 'docker compose -f docker-compose.dev.yml logs -f' to tail logs${NC}" +echo "" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..3b74ef1 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "paths": { + "@investplay/types": ["./packages/types/src"], + "@investplay/types/*": ["./packages/types/src/*"], + "@investplay/ui": ["./packages/ui/src"], + "@investplay/ui/*": ["./packages/ui/src/*"], + "@investplay/i18n": ["./packages/i18n/src"], + "@investplay/i18n/*": ["./packages/i18n/src/*"], + "@investplay/utils": ["./packages/utils/src"], + "@investplay/utils/*": ["./packages/utils/src/*"] + } + }, + "exclude": ["node_modules", ".turbo", "dist", "build"] +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..0342f20 --- /dev/null +++ b/turbo.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": ["**/.env.*local"], + "pipeline": { + "build": { + "dependsOn": ["^build"], + "outputs": [".next/**", "dist/**", "build/**"], + "env": ["NODE_ENV"] + }, + "dev": { + "dependsOn": ["^build"], + "cache": false, + "persistent": true + }, + "lint": { + "dependsOn": ["^build"] + }, + "typecheck": { + "dependsOn": ["^build"] + }, + "test": { + "dependsOn": ["^build"], + "outputs": [] + }, + "clean": { + "cache": false + } + } +}