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

View File

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

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

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