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:
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