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
573 lines
17 KiB
Plaintext
573 lines
17 KiB
Plaintext
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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")
|
||
}
|