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

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

View File

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