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:
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();
|
||||
});
|
||||
Reference in New Issue
Block a user