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

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

37
apps/api/Dockerfile Normal file
View File

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

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

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

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

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

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

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

View File

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

35
apps/cms/Dockerfile Normal file
View File

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

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

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

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

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

20
apps/web/Dockerfile Normal file
View File

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

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

@@ -0,0 +1,10 @@
FROM node:20-alpine
RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY apps/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"]

20
apps/web/index.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="InvestPlay — Learn financial literacy through interactive simulations and games" />
<meta name="theme-color" content="#2563eb" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<title>InvestPlay</title>
</head>
<body class="bg-surface-50 text-surface-900 dark:bg-surface-950 dark:text-surface-100 antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

125
apps/web/nginx.conf Normal file
View File

@@ -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;
}
}

65
apps/web/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
};

80
apps/web/src/App.tsx Normal file
View File

@@ -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 (
<div className="flex min-h-[60vh] items-center justify-center">
<LoadingSpinner size="lg" />
</div>
);
}
function AppRoutes() {
return (
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route element={<PublicLayout />}>
<Route path="/" element={<HomePage />} />
</Route>
<Route element={<AuthenticatedLayout />}>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/learn" element={<LearnPage />} />
<Route path="/learn/:moduleId" element={<ModulePage />} />
<Route path="/learn/:moduleId/:lessonId" element={<LessonPage />} />
<Route path="/play" element={<PlayPage />} />
<Route path="/play/:challengeId" element={<ChallengePage />} />
<Route path="/practice" element={<PracticePage />} />
<Route path="/practice/portfolio" element={<PortfolioPage />} />
<Route path="/teach" element={<TeacherLayout />}>
<Route index element={<div className="text-muted-foreground">Teacher overview</div>} />
<Route path="overview" element={<div className="text-muted-foreground">Overview</div>} />
<Route path="classrooms" element={<div className="text-muted-foreground">Classrooms</div>} />
<Route path="assignments" element={<div className="text-muted-foreground">Assignments</div>} />
<Route path="reports" element={<div className="text-muted-foreground">Reports</div>} />
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</ErrorBoundary>
);
}
export default function App() {
const { locale } = useUIStore();
if (i18nInstance.language !== locale) {
void i18nInstance.changeLanguage(locale);
}
return (
<I18nextProvider i18n={i18nInstance}>
<AppRoutes />
</I18nextProvider>
);
}

View File

@@ -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<string, { correct: boolean; answer: string | string[] }>;
}
export function BlockRenderer({ block, onQuizAnswer, quizResults }: BlockRendererProps) {
switch (block.type) {
case BlockType.HERO:
return <HeroBlock content={block.content} />;
case BlockType.TEXT:
case BlockType.SUMMARY:
return <TextBlock content={block.content} />;
case BlockType.QUIZ:
return (
<QuizBlock
content={block.content}
blockId={block.id}
result={quizResults?.[block.id]}
onAnswer={onQuizAnswer}
/>
);
case BlockType.CALCULATOR:
return <CalculatorBlock content={block.content} />;
case BlockType.REFLECTION:
return (
<div className="rounded-xl border border-accent/20 bg-accent/5 p-6">
<h3 className="mb-2 font-semibold text-accent">Reflection</h3>
<p className="text-muted-foreground">
{block.content.prompt as string ?? "Take a moment to reflect on what you've learned."}
</p>
</div>
);
case BlockType.TEACHER_NOTE:
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-6 dark:border-amber-800 dark:bg-amber-950">
<p className="text-sm text-amber-800 dark:text-amber-200">
{block.content.note as string ?? "Teacher note"}
</p>
</div>
);
default:
return (
<div className="rounded-xl border border-border p-6">
<p className="text-sm text-muted-foreground">
Unsupported block type: {block.type}
</p>
</div>
);
}
}

View File

@@ -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<string, unknown>;
}
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<number | null>(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 (
<div className="rounded-xl border border-border p-6">
<h3 className="mb-4 text-lg font-semibold">
{content.title as string ?? "Financial Calculator"}
</h3>
<div className="grid gap-4 sm:grid-cols-3">
<Input
label="Principal Amount"
type="number"
value={principal}
onChange={(e) => setPrincipal(e.target.value)}
/>
<Input
label="Interest Rate (%)"
type="number"
value={rate}
onChange={(e) => setRate(e.target.value)}
/>
<Input
label="Time Period (years)"
type="number"
value={years}
onChange={(e) => setYears(e.target.value)}
/>
</div>
<Button onClick={calculate} className="mt-4">
{t("actions.calculate") ?? "Calculate"}
</Button>
{result !== null && (
<div className="mt-4 rounded-lg bg-accent/10 p-4">
<p className="text-sm text-muted-foreground">Result</p>
<p className="text-2xl font-bold text-accent">
{formatCurrency(result)}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
interface HeroBlockProps {
content: Record<string, unknown>;
}
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 (
<div className="relative overflow-hidden rounded-xl bg-gradient-to-br from-primary/10 via-accent/5 to-primary/5 p-8 md:p-12">
<div className="relative z-10 max-w-2xl">
{title && (
<h1 className="text-3xl font-bold tracking-tight md:text-4xl">
{title}
</h1>
)}
{description && (
<p className="mt-4 text-lg text-muted-foreground leading-relaxed">
{description}
</p>
)}
</div>
{imageUrl && (
<img
src={imageUrl}
alt={title ?? ""}
className="absolute right-0 top-0 h-full w-1/3 object-cover opacity-20"
/>
)}
</div>
);
}

View File

@@ -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 (
<div className={cn("w-full", className)}>
<div
className={cn(
"w-full overflow-hidden rounded-full bg-secondary",
sizeMap[size],
)}
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={max}
aria-label={`Progress: ${Math.round(percentage)}%`}
>
<div
className={cn(
"h-full rounded-full transition-all duration-700 ease-out",
variantMap[variant],
)}
style={{ width: `${percentage}%` }}
/>
</div>
{showLabel && (
<span className="mt-1 block text-xs text-muted-foreground">
{Math.round(percentage)}%
</span>
)}
</div>
);
}

View File

@@ -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<string, unknown>;
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<string | null>(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 (
<div
className={cn(
"rounded-xl border p-6",
isAnswered
? isCorrect
? "border-accent/30 bg-accent/5"
: "border-destructive/30 bg-destructive/5"
: "border-border",
)}
>
<div className="mb-4 flex items-center gap-2">
<Badge variant="outline">
{type === "true-false" ? "True / False" : t("actions.submit")}
</Badge>
{isAnswered && (
<Badge variant={isCorrect ? "success" : "destructive"}>
{isCorrect ? t("actions.confirm") : t("actions.cancel")}
</Badge>
)}
</div>
<p className="mb-4 text-lg font-medium">{question}</p>
<div className="space-y-2">
{options?.map((option) => {
const isSelected = selected === option;
const isOptionCorrect = isCorrectAnswer(option);
return (
<button
key={option}
onClick={() => !isAnswered && setSelected(option)}
disabled={isAnswered}
className={cn(
"flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left text-sm transition-all",
isAnswered && isOptionCorrect && "border-accent bg-accent/10 text-accent",
isAnswered && isSelected && !isOptionCorrect && "border-destructive bg-destructive/10 text-destructive",
!isAnswered && isSelected && "border-primary bg-primary/5",
!isAnswered && !isSelected && "border-border hover:border-primary/50 hover:bg-secondary",
isAnswered && "cursor-default",
)}
aria-pressed={isSelected}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-current text-xs font-medium">
{isAnswered && isOptionCorrect ? (
<CheckCircle className="h-4 w-4 text-accent" />
) : isAnswered && isSelected && !isOptionCorrect ? (
<XCircle className="h-4 w-4 text-destructive" />
) : isSelected ? (
<span className="h-2.5 w-2.5 rounded-full bg-primary" />
) : null}
</span>
{option}
</button>
);
})}
</div>
{!isAnswered && (
<Button
onClick={handleSubmit}
disabled={!selected}
className="mt-4"
>
{t("actions.submit")}
</Button>
)}
{isAnswered && explanation && (
<div className="mt-4 rounded-lg bg-secondary p-4">
<p className="text-sm font-medium mb-1">Explanation</p>
<p className="text-sm text-muted-foreground">{explanation}</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,31 @@
interface TextBlockProps {
content: Record<string, unknown>;
}
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<string, string> = {
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 (
<Tag className={className[variant] ?? className.body}>
{text}
</Tag>
);
}

View File

@@ -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<CurriculumTrack, LucideIcon> = {
SURVIVAL_BASICS: ShieldAlert,
CREDIT_DEBT: CreditCard,
WEALTH_BUILDER: TrendingUp,
ANTI_HYPE: ShieldAlert,
MACROECONOMICS: Globe,
};
const trackGradients: Record<CurriculumTrack, string> = {
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<string, string> = {
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 (
<button
onClick={onClick}
className={cn(
"group relative w-full overflow-hidden rounded-xl border border-border bg-card p-6 text-left transition-all hover:shadow-md hover:border-primary/50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={`${title}${progress}% complete`}
>
<div
className={cn(
"absolute inset-0 bg-gradient-to-br opacity-0 transition-opacity group-hover:opacity-100",
trackGradients[track],
)}
/>
<div className="relative z-10">
<div className="mb-4 flex items-center justify-between">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<Icon className="h-6 w-6 text-primary" />
</div>
<Badge className={cn("text-xs", difficultyColors[difficulty])}>
{difficulty}
</Badge>
</div>
<h3 className="mb-1 text-lg font-semibold">{title}</h3>
<p className="mb-4 text-sm text-muted-foreground line-clamp-2">
{description}
</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground mb-3">
<span>{moduleCount} modules</span>
<span>~{estimatedHours}h</span>
</div>
<ProgressBar value={progress} size="sm" variant="accent" />
<span className="mt-1 block text-xs font-medium text-accent">
{formatPercentage(progress)} complete
</span>
</div>
</button>
);
}

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