feat: complete Phase 1 foundation scaffold
Some checks failed
CI / Lint (push) Has been cancelled
CI / TypeCheck (push) Has been cancelled
CI / Test (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/api/Dockerfile name:api]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/cms/Dockerfile name:cms]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/web/Dockerfile name:web]) (push) Has been cancelled
CI / Build (push) Has been cancelled
Deploy / Deploy (push) Has been cancelled

- Monorepo: Turborepo + pnpm workspaces with 7 apps/packages
- Backend: NestJS scaffold with 9 modules (auth, tenant, curriculum, simulation, portfolio, ai-coach, analytics, classroom, gamification)
- Frontend: React 18 + Vite + TailwindCSS + shadcn/ui with 10 pages, 14 UI primitives, 3 Zustand stores
- Database: Prisma schema with 19 models, 13 enums, seed script, multi-tenant ready
- Docker: Dev, production, and Portainer Compose files with Traefik reverse proxy
- Configuration: .env.example (47 vars), Zod validation, frontend-safe env exposure
- i18n: English + Greek locale files (10 files), ICU MessageFormat, react-i18next
- Shared packages: @investplay/types, @investplay/ui, @investplay/i18n, @investplay/utils
- CI/CD: GitHub Actions (lint, typecheck, test, build, deploy, PR checks)
- Documentation: CONTEXT.md, ARCHITECTURE.md (10 Mermaid diagrams), API.md, LOCALIZATION.md, DEVELOPMENT-ROADMAP.md
- Infrastructure: Dockerfiles (dev + prod), nginx configs, backup/healthcheck scripts
- Security: JWT auth guards, role-based access, rate limiting, Helmet, CORS, PII sanitization
This commit is contained in:
Lefteris Notas
2026-06-12 19:32:57 +03:00
parent fa71c60cfc
commit e75f913f1c
226 changed files with 17547 additions and 0 deletions

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

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