/** * AI Software Factory — seed script (TASK-0006 + TASK-0011 contracted fixture). * * Creates the demo surface that integration/E2E tests rely on: * - 2 verified users: demo@factory.local / admin@factory.local * - a non-personal workspace `demo` (owned by the demo user) * - exactly 2 personal workspaces (one per user, slugs `personal-<4 random>`) * - 2 teams in the demo ws (unique slugs, fractional positions) * - 6 pages: "Getting started" + 2 direct children + 3 record pages * - 1 database "Tasks" (5 properties, 3 records, 1 TABLE view) * - 1 comment on "Getting started" (by the admin user) * - 1 favorite: demo user → "Getting started" * * Then runs `verifySeedData()` (prisma/verify.ts) and fails the process on * any broken invariant. In non-production it prints the demo credentials * block + the (random) personal-workspace slugs. * * Idempotent: safe to re-run (upserts by fixed ids / natural keys; personal * slugs are found by owner, never hardcoded). * * Run: pnpm db:seed (tsx prisma/seed.ts) */ import { PrismaClient, type Prisma } from "@prisma/client"; import { verifySeedData } from "./verify.js"; // Dev-only seed guard — never seed production from this file (TASK-0005 env // gate; re-verified by TASK-0011 criterion 3). if (process.env.NODE_ENV === "production") { console.error("Refusing to run the seed with NODE_ENV=production."); console.error("Use `prisma migrate deploy` + a production provisioning flow instead."); process.exit(65); } const prisma = new PrismaClient(); // NOTE: for dev only — real bcrypt hashes (cost 12, matching the API's // BCRYPT_COST) of the two demo passwords. Never seed production from here. const DEMO = { email: "demo@factory.local", password: "Factory!2345", hash: "$2a$12$yH0Assa5Rt/r8djzcfCHkuDRc6HAog5c8W2ifi773VzP3ia37dka2" }; const ADMIN = { email: "admin@factory.local", password: "Admin!2345", hash: "$2a$12$I5cmf2E4tDqJzrHp4NYgxer0QCAZ5BMuK.3HhQdz8I2v3Gqm3dABW" }; const rand4 = (): string => { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let out = ""; for (let i = 0; i < 4; i += 1) out += chars.charAt(Math.floor(Math.random() * chars.length)); return out; }; async function upsertUser(creds: typeof DEMO, name: string) { return prisma.user.upsert({ where: { email: creds.email }, update: {}, create: { email: creds.email, name, displayName: name, passwordHash: creds.hash, emailVerification: "VERIFIED", emailVerifiedAt: new Date(), status: "ACTIVE" } }); } /** Personal workspace is `personal-<4 random chars>` — never hardcoded; on * re-run we adopt the owner's existing personal workspace (idempotency). */ async function ensurePersonalWorkspace(userId: string, ownerName: string) { const existing = await prisma.workspace.findFirst({ where: { ownerId: userId, isPersonal: true } }); if (existing) return existing; return prisma.workspace.create({ data: { name: `${ownerName} (personal)`, slug: `personal-${rand4()}`, isPersonal: true, ownerId: userId, members: { create: { userId, role: "OWNER", joinedAt: new Date() } } } }); } async function ensureOwnerMember(workspaceId: string, userId: string) { await prisma.workspaceMember.upsert({ where: { workspaceId_userId: { workspaceId, userId } }, update: {}, create: { workspaceId, userId, role: "OWNER", joinedAt: new Date() } }); } async function main() { console.log("🌱 Seeding AI Software Factory (TASK-0011 contracted fixture)…"); // ── Users ───────────────────────────────────────────────────────────────── const demoUser = await upsertUser(DEMO, "Demo User"); const adminUser = await upsertUser(ADMIN, "Demo Admin"); console.log(` users: ${demoUser.email}, ${adminUser.email}`); // ── Personal workspaces (one per user, random slugs) ────────────────────── const demoPersonal = await ensurePersonalWorkspace(demoUser.id, "Demo User"); const adminPersonal = await ensurePersonalWorkspace(adminUser.id, "Demo Admin"); // ── Demo workspace `demo` (non-personal, owned by the demo user) ───────── const demoWs = await prisma.workspace.upsert({ where: { slug: "demo" }, update: {}, create: { name: "Demo", slug: "demo", isPersonal: false, ownerId: demoUser.id, description: "Contracted demo workspace (seed fixture)" } }); await ensureOwnerMember(demoWs.id, demoUser.id); // Admin participates (authored the seeded comment) as a MEMBER. await prisma.workspaceMember.upsert({ where: { workspaceId_userId: { workspaceId: demoWs.id, userId: adminUser.id } }, update: {}, create: { workspaceId: demoWs.id, userId: adminUser.id, role: "MEMBER", joinedAt: new Date() } }); // ── Teams (exactly 2, unique slugs, valid fractional positions) ────────── const teams = ( [ { name: "Engineering", slug: "engineering", position: "a0" }, { name: "Product", slug: "product", position: "a1" } ] as const ).map((t) => prisma.team.upsert({ where: { workspaceId_slug: { workspaceId: demoWs.id, slug: t.slug } }, update: {}, create: { workspaceId: demoWs.id, name: t.name, slug: t.slug, position: t.position } }) ); const [engineering] = await Promise.all(teams); // ── Pages (6 total = 3 content + 3 record) ──────────────────────────────── // Root level: "Getting started" + the 3 record pages (siblings, a0..a3). const gettingStarted = await prisma.page.upsert({ where: { id: "demo-page-gs" }, update: {}, create: { id: "demo-page-gs", workspaceId: demoWs.id, teamId: engineering?.id, title: "Getting started", icon: "🚀", position: "a0" } }); const childA = await prisma.page.upsert({ where: { id: "demo-page-child1" }, update: {}, create: { id: "demo-page-child1", workspaceId: demoWs.id, parentId: gettingStarted.id, title: "Why AI Software Factory", position: "a0" } }); const childB = await prisma.page.upsert({ where: { id: "demo-page-child2" }, update: {}, create: { id: "demo-page-child2", workspaceId: demoWs.id, parentId: gettingStarted.id, title: "Try it: build your first database", position: "a1" } }); // ── Database "Tasks" (exactly 1 database in the demo ws) ───────────────── const tasksDb = await prisma.database.upsert({ where: { id: "demo-db-tasks" }, update: {}, create: { id: "demo-db-tasks", workspaceId: demoWs.id, title: "Tasks", icon: "✅" } }); const taskProps = [ { name: "Task", type: "TITLE", isTitle: true, position: "a0", config: {} as Prisma.InputJsonValue }, { name: "Status", type: "SELECT", position: "a1", config: { options: [ { id: "opt-todo", name: "To Do", color: "gray" }, { id: "opt-doing", name: "In Progress", color: "blue" }, { id: "opt-done", name: "Done", color: "green" } ] } as Prisma.InputJsonValue }, { name: "Priority", type: "SELECT", position: "a2", config: { options: [ { id: "opt-low", name: "Low", color: "green" }, { id: "opt-med", name: "Medium", color: "orange" }, { id: "opt-high", name: "High", color: "red" } ] } as Prisma.InputJsonValue }, { name: "Due date", type: "DATE", position: "a3", config: {} as Prisma.InputJsonValue }, { name: "Assignee", type: "PERSON", position: "a4", config: {} as Prisma.InputJsonValue } ]; for (const p of taskProps) { await prisma.databaseProperty.upsert({ where: { databaseId_name: { databaseId: tasksDb.id, name: p.name } }, update: {}, create: { databaseId: tasksDb.id, ...p } }); } const statusProp = await prisma.databaseProperty.findFirstOrThrow({ where: { databaseId: tasksDb.id, name: "Status" } }); const priorityProp = await prisma.databaseProperty.findFirstOrThrow({ where: { databaseId: tasksDb.id, name: "Priority" } }); await prisma.databaseView.upsert({ where: { id: "demo-view-tasks-table" }, update: {}, create: { id: "demo-view-tasks-table", databaseId: tasksDb.id, name: "Table", type: "TABLE", position: "a0" } }); // ── Records (3, each 1:1 with a page — record=page invariant) ───────────── const taskTitles = ["Ship the login page", "Wire up the search pipeline", "Publish the API reference"]; for (let i = 0; i < 3; i += 1) { const recordId = `demo-rec-${i}`; const pageId = `demo-rec-page-${i}`; const title = taskTitles[i]!; // The record's page (sibling of "Getting started" at root level) MUST be // created before the record — `databaseRecords.pageId` is an FK to Page, // so on a fresh DB the page row must exist first. (Recorded regression: // the prior order created the record before its page and violated // `database_records_pageId_fkey` on a clean seed.) await prisma.page.upsert({ where: { id: pageId }, update: {}, create: { id: pageId, workspaceId: demoWs.id, title, icon: "📄", position: `a${i + 1}` } }); const record = await prisma.databaseRecord.upsert({ where: { id: recordId }, update: {}, create: { id: recordId, workspaceId: demoWs.id, databaseId: tasksDb.id, pageId, position: `a${i + 1}`, createdBy: demoUser.id } }); const value = (propertyId: string, v: Prisma.InputJsonValue) => prisma.recordProperty.upsert({ where: { recordId_propertyId: { recordId, propertyId } }, update: { value: v }, create: { recordId, propertyId, value: v } }); await value(statusProp.id, { optionId: i === 0 ? "opt-doing" : "opt-todo" }); await value(priorityProp.id, { optionId: i === 0 ? "opt-high" : "opt-med" }); void record; } // ── Comment on "Getting started" (by the admin user) ────────────────────── const existingComment = await prisma.comment.findFirst({ where: { pageId: gettingStarted.id, createdBy: adminUser.id } }); if (existingComment) { await prisma.comment.update({ where: { id: existingComment.id }, data: { body: "Welcome to the AI Software Factory demo — this comment is part of the contracted seed fixture." } }); } else { await prisma.comment.create({ data: { workspaceId: demoWs.id, teamId: engineering?.id, pageId: gettingStarted.id, body: "Welcome to the AI Software Factory demo — this comment is part of the contracted seed fixture.", createdBy: adminUser.id } }); } // ── T-143: built-in template catalog (idempotent; also runs on workspace // creation at runtime — this back-fills existing dev/test DBs). Relative // import: the root seed can't resolve the workspace package name. const { seedBuiltinTemplatesForWorkspace } = await import("../packages/shared/src/builtin-templates.js"); const seededWs = await prisma.workspace.findMany({ where: { deletedAt: null }, select: { id: true, ownerId: true } }); for (const ws of seededWs) { await seedBuiltinTemplatesForWorkspace(prisma, ws.id, ws.ownerId); } // ── Favorite: demo user → "Getting started" ─────────────────────────────── await prisma.pageFavorite.upsert({ where: { workspaceId_userId_pageId: { workspaceId: demoWs.id, userId: demoUser.id, pageId: gettingStarted.id } }, update: {}, create: { workspaceId: demoWs.id, userId: demoUser.id, pageId: gettingStarted.id, position: "a0" } }); // ── Verify the contracted fixture (fails the process on any violation) ──── await verifySeedData(prisma); // ── Credentials block (non-production only) ─────────────────────────────── console.log("\n🔑 Demo credentials (dev only — never for production):"); console.log(` login: ${DEMO.email} / ${DEMO.password}`); console.log(` admin: ${ADMIN.email} / ${ADMIN.password}`); console.log(" Personal workspace slugs (random — E2E resolves them via API):"); console.log(` ${demoPersonal.slug} (demo) · ${adminPersonal.slug} (admin)`); void childA; void childB; } main() .catch((e) => { console.error("❌ Seed failed:", e); process.exit(1); }) .finally(() => prisma.$disconnect());