Files
InvestPlay/apps/api/prisma/seed.ts
Lefteris Notas e75f913f1c
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
feat: complete Phase 1 foundation scaffold
- 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
2026-06-12 19:32:57 +03:00

834 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
});