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
834 lines
24 KiB
TypeScript
834 lines
24 KiB
TypeScript
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();
|
||
});
|