feat: complete Phase 1 foundation scaffold
Some checks failed
CI / Lint (push) Has been cancelled
CI / TypeCheck (push) Has been cancelled
CI / Test (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/api/Dockerfile name:api]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/cms/Dockerfile name:cms]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/web/Dockerfile name:web]) (push) Has been cancelled
CI / Build (push) Has been cancelled
Deploy / Deploy (push) Has been cancelled
Some checks failed
CI / Lint (push) Has been cancelled
CI / TypeCheck (push) Has been cancelled
CI / Test (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/api/Dockerfile name:api]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/cms/Dockerfile name:cms]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/web/Dockerfile name:web]) (push) Has been cancelled
CI / Build (push) Has been cancelled
Deploy / Deploy (push) Has been cancelled
- Monorepo: Turborepo + pnpm workspaces with 7 apps/packages - Backend: NestJS scaffold with 9 modules (auth, tenant, curriculum, simulation, portfolio, ai-coach, analytics, classroom, gamification) - Frontend: React 18 + Vite + TailwindCSS + shadcn/ui with 10 pages, 14 UI primitives, 3 Zustand stores - Database: Prisma schema with 19 models, 13 enums, seed script, multi-tenant ready - Docker: Dev, production, and Portainer Compose files with Traefik reverse proxy - Configuration: .env.example (47 vars), Zod validation, frontend-safe env exposure - i18n: English + Greek locale files (10 files), ICU MessageFormat, react-i18next - Shared packages: @investplay/types, @investplay/ui, @investplay/i18n, @investplay/utils - CI/CD: GitHub Actions (lint, typecheck, test, build, deploy, PR checks) - Documentation: CONTEXT.md, ARCHITECTURE.md (10 Mermaid diagrams), API.md, LOCALIZATION.md, DEVELOPMENT-ROADMAP.md - Infrastructure: Dockerfiles (dev + prod), nginx configs, backup/healthcheck scripts - Security: JWT auth guards, role-based access, rate limiting, Helmet, CORS, PII sanitization
This commit is contained in:
37
apps/api/Dockerfile
Normal file
37
apps/api/Dockerfile
Normal 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
12
apps/api/Dockerfile.dev
Normal 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
8
apps/api/nest-cli.json
Normal 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
74
apps/api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
0
apps/api/prisma/migrations/.gitkeep
Normal file
0
apps/api/prisma/migrations/.gitkeep
Normal file
572
apps/api/prisma/schema.prisma
Normal file
572
apps/api/prisma/schema.prisma
Normal 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
833
apps/api/prisma/seed.ts
Normal 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();
|
||||
});
|
||||
63
apps/api/src/app.controller.ts
Normal file
63
apps/api/src/app.controller.ts
Normal 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
116
apps/api/src/app.module.ts
Normal 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 {}
|
||||
14
apps/api/src/common/decorators/current-tenant.decorator.ts
Normal file
14
apps/api/src/common/decorators/current-tenant.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
14
apps/api/src/common/decorators/current-user.decorator.ts
Normal file
14
apps/api/src/common/decorators/current-user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
4
apps/api/src/common/decorators/index.ts
Normal file
4
apps/api/src/common/decorators/index.ts
Normal 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";
|
||||
4
apps/api/src/common/decorators/public.decorator.ts
Normal file
4
apps/api/src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const IS_PUBLIC_KEY = "isPublic";
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
5
apps/api/src/common/decorators/roles.decorator.ts
Normal file
5
apps/api/src/common/decorators/roles.decorator.ts
Normal 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);
|
||||
115
apps/api/src/common/filters/http-exception.filter.ts
Normal file
115
apps/api/src/common/filters/http-exception.filter.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
37
apps/api/src/common/guards/gql-jwt.guard.ts
Normal file
37
apps/api/src/common/guards/gql-jwt.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
37
apps/api/src/common/guards/gql-roles.guard.ts
Normal file
37
apps/api/src/common/guards/gql-roles.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
apps/api/src/common/guards/index.ts
Normal file
5
apps/api/src/common/guards/index.ts
Normal 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";
|
||||
31
apps/api/src/common/guards/jwt-auth.guard.ts
Normal file
31
apps/api/src/common/guards/jwt-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
36
apps/api/src/common/guards/roles.guard.ts
Normal file
36
apps/api/src/common/guards/roles.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
29
apps/api/src/common/guards/tenant.guard.ts
Normal file
29
apps/api/src/common/guards/tenant.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
60
apps/api/src/common/interceptors/logging.interceptor.ts
Normal file
60
apps/api/src/common/interceptors/logging.interceptor.ts
Normal 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}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
32
apps/api/src/common/interceptors/transform.interceptor.ts
Normal file
32
apps/api/src/common/interceptors/transform.interceptor.ts
Normal 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(),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/common/prisma/prisma.module.ts
Normal file
9
apps/api/src/common/prisma/prisma.module.ts
Normal 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 {}
|
||||
54
apps/api/src/common/prisma/prisma.service.ts
Normal file
54
apps/api/src/common/prisma/prisma.service.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
17
apps/api/src/common/queue/queue.module.ts
Normal file
17
apps/api/src/common/queue/queue.module.ts
Normal 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 {}
|
||||
106
apps/api/src/common/queue/queue.service.ts
Normal file
106
apps/api/src/common/queue/queue.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/api/src/common/redis/redis.module.ts
Normal file
9
apps/api/src/common/redis/redis.module.ts
Normal 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 {}
|
||||
122
apps/api/src/common/redis/redis.service.ts
Normal file
122
apps/api/src/common/redis/redis.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
12
apps/api/src/common/types/context.ts
Normal file
12
apps/api/src/common/types/context.ts
Normal 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;
|
||||
}
|
||||
46
apps/api/src/common/types/pagination.ts
Normal file
46
apps/api/src/common/types/pagination.ts
Normal 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;
|
||||
}
|
||||
144
apps/api/src/config/env.config.ts
Normal file
144
apps/api/src/config/env.config.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
144
apps/api/src/config/env.validation.ts
Normal file
144
apps/api/src/config/env.validation.ts
Normal 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;
|
||||
}
|
||||
20
apps/api/src/config/index.ts
Normal file
20
apps/api/src/config/index.ts
Normal 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
77
apps/api/src/main.ts
Normal 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);
|
||||
});
|
||||
90
apps/api/src/modules/ai-coach/ai-coach.gateway.ts
Normal file
90
apps/api/src/modules/ai-coach/ai-coach.gateway.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/ai-coach/ai-coach.module.ts
Normal file
10
apps/api/src/modules/ai-coach/ai-coach.module.ts
Normal 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 {}
|
||||
36
apps/api/src/modules/ai-coach/ai-coach.resolver.ts
Normal file
36
apps/api/src/modules/ai-coach/ai-coach.resolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
92
apps/api/src/modules/ai-coach/ai-coach.service.ts
Normal file
92
apps/api/src/modules/ai-coach/ai-coach.service.ts
Normal 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?";
|
||||
}
|
||||
}
|
||||
21
apps/api/src/modules/ai-coach/dto/ai-message.input.ts
Normal file
21
apps/api/src/modules/ai-coach/dto/ai-message.input.ts
Normal 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;
|
||||
}
|
||||
9
apps/api/src/modules/analytics/analytics.module.ts
Normal file
9
apps/api/src/modules/analytics/analytics.module.ts
Normal 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 {}
|
||||
56
apps/api/src/modules/analytics/analytics.resolver.ts
Normal file
56
apps/api/src/modules/analytics/analytics.resolver.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
114
apps/api/src/modules/analytics/analytics.service.ts
Normal file
114
apps/api/src/modules/analytics/analytics.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
26
apps/api/src/modules/auth/auth.controller.ts
Normal file
26
apps/api/src/modules/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
27
apps/api/src/modules/auth/auth.module.ts
Normal file
27
apps/api/src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
53
apps/api/src/modules/auth/auth.resolver.ts
Normal file
53
apps/api/src/modules/auth/auth.resolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
202
apps/api/src/modules/auth/auth.service.ts
Normal file
202
apps/api/src/modules/auth/auth.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
14
apps/api/src/modules/auth/dto/login.input.ts
Normal file
14
apps/api/src/modules/auth/dto/login.input.ts
Normal 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;
|
||||
}
|
||||
34
apps/api/src/modules/auth/dto/register.input.ts
Normal file
34
apps/api/src/modules/auth/dto/register.input.ts
Normal 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;
|
||||
}
|
||||
13
apps/api/src/modules/auth/dto/token-response.dto.ts
Normal file
13
apps/api/src/modules/auth/dto/token-response.dto.ts
Normal 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;
|
||||
}
|
||||
55
apps/api/src/modules/auth/entities/user.entity.ts
Normal file
55
apps/api/src/modules/auth/entities/user.entity.ts
Normal 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;
|
||||
}
|
||||
45
apps/api/src/modules/auth/jwt-refresh.strategy.ts
Normal file
45
apps/api/src/modules/auth/jwt-refresh.strategy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
35
apps/api/src/modules/auth/jwt.strategy.ts
Normal file
35
apps/api/src/modules/auth/jwt.strategy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
apps/api/src/modules/classroom/classroom.module.ts
Normal file
9
apps/api/src/modules/classroom/classroom.module.ts
Normal 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 {}
|
||||
67
apps/api/src/modules/classroom/classroom.resolver.ts
Normal file
67
apps/api/src/modules/classroom/classroom.resolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
159
apps/api/src/modules/classroom/classroom.service.ts
Normal file
159
apps/api/src/modules/classroom/classroom.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
apps/api/src/modules/curriculum/curriculum.module.ts
Normal file
9
apps/api/src/modules/curriculum/curriculum.module.ts
Normal 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 {}
|
||||
47
apps/api/src/modules/curriculum/curriculum.resolver.ts
Normal file
47
apps/api/src/modules/curriculum/curriculum.resolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
144
apps/api/src/modules/curriculum/curriculum.service.ts
Normal file
144
apps/api/src/modules/curriculum/curriculum.service.ts
Normal 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(),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
30
apps/api/src/modules/curriculum/dto/submit-quiz.input.ts
Normal file
30
apps/api/src/modules/curriculum/dto/submit-quiz.input.ts
Normal 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;
|
||||
}
|
||||
64
apps/api/src/modules/curriculum/entities/lesson.entity.ts
Normal file
64
apps/api/src/modules/curriculum/entities/lesson.entity.ts
Normal 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;
|
||||
}
|
||||
32
apps/api/src/modules/curriculum/entities/module.entity.ts
Normal file
32
apps/api/src/modules/curriculum/entities/module.entity.ts
Normal 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;
|
||||
}
|
||||
9
apps/api/src/modules/gamification/gamification.module.ts
Normal file
9
apps/api/src/modules/gamification/gamification.module.ts
Normal 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 {}
|
||||
40
apps/api/src/modules/gamification/gamification.resolver.ts
Normal file
40
apps/api/src/modules/gamification/gamification.resolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
129
apps/api/src/modules/gamification/gamification.service.ts
Normal file
129
apps/api/src/modules/gamification/gamification.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
apps/api/src/modules/portfolio/portfolio.module.ts
Normal file
9
apps/api/src/modules/portfolio/portfolio.module.ts
Normal 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 {}
|
||||
26
apps/api/src/modules/portfolio/portfolio.resolver.ts
Normal file
26
apps/api/src/modules/portfolio/portfolio.resolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
95
apps/api/src/modules/portfolio/portfolio.service.ts
Normal file
95
apps/api/src/modules/portfolio/portfolio.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
21
apps/api/src/modules/simulation/dto/create-session.input.ts
Normal file
21
apps/api/src/modules/simulation/dto/create-session.input.ts
Normal 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;
|
||||
}
|
||||
34
apps/api/src/modules/simulation/dto/execute-trade.input.ts
Normal file
34
apps/api/src/modules/simulation/dto/execute-trade.input.ts
Normal 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;
|
||||
}
|
||||
84
apps/api/src/modules/simulation/simulation.gateway.ts
Normal file
84
apps/api/src/modules/simulation/simulation.gateway.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/simulation/simulation.module.ts
Normal file
10
apps/api/src/modules/simulation/simulation.module.ts
Normal 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 {}
|
||||
39
apps/api/src/modules/simulation/simulation.resolver.ts
Normal file
39
apps/api/src/modules/simulation/simulation.resolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
162
apps/api/src/modules/simulation/simulation.service.ts
Normal file
162
apps/api/src/modules/simulation/simulation.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
21
apps/api/src/modules/tenant/dto/create-tenant.input.ts
Normal file
21
apps/api/src/modules/tenant/dto/create-tenant.input.ts
Normal 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;
|
||||
}
|
||||
42
apps/api/src/modules/tenant/dto/update-tenant.input.ts
Normal file
42
apps/api/src/modules/tenant/dto/update-tenant.input.ts
Normal 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;
|
||||
}
|
||||
70
apps/api/src/modules/tenant/entities/tenant.entity.ts
Normal file
70
apps/api/src/modules/tenant/entities/tenant.entity.ts
Normal 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;
|
||||
}
|
||||
19
apps/api/src/modules/tenant/tenant-context.service.ts
Normal file
19
apps/api/src/modules/tenant/tenant-context.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
32
apps/api/src/modules/tenant/tenant.middleware.ts
Normal file
32
apps/api/src/modules/tenant/tenant.middleware.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
15
apps/api/src/modules/tenant/tenant.module.ts
Normal file
15
apps/api/src/modules/tenant/tenant.module.ts
Normal 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("*");
|
||||
}
|
||||
}
|
||||
65
apps/api/src/modules/tenant/tenant.resolver.ts
Normal file
65
apps/api/src/modules/tenant/tenant.resolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
166
apps/api/src/modules/tenant/tenant.service.ts
Normal file
166
apps/api/src/modules/tenant/tenant.service.ts
Normal 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
22
apps/api/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user