import { PrismaClient } from '@prisma/client'; // Reuses the seed's own hasher rather than adding a third copy of the // format — see the note at the top of accounts.ts. import { hashPassword } from './accounts'; /** * Demo volume, for looking at the UI with something like real data in it. * * Separate from `seed/catalog.ts` on purpose. That file is the canonical demo * catalog — a small, hand-written, meaningful set. This one generates *volume*: * enough orders to fill a table and paginate it, enough reviews to make a * rating distribution interesting, enough customers to sort by spend. * * Everything here is deterministic. A seeded PRNG rather than `Math.random` * means re-running produces the same store, so "the third order looks wrong" * is a reproducible statement rather than a race with the data. * * It writes through the same invariants the application maintains — order * totals equal the sum of their lines, stock is decremented for what ships, * rating aggregates are recomputed from approved reviews — because demo data * that violates them makes the UI look broken in ways the code is not. */ /** Mulberry32 — small, fast, and identical on every run. */ function makeRandom(seed: number) { let state = seed; return () => { state |= 0; state = (state + 0x6d2b79f5) | 0; let t = Math.imul(state ^ (state >>> 15), 1 | state); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const random = makeRandom(20260813); const pick = (items: readonly T[]): T => items[Math.floor(random() * items.length)]!; const between = (min: number, max: number) => min + Math.floor(random() * (max - min + 1)); const FIRST_NAMES = [ 'Minh', 'Lan', 'Huy', 'Trang', 'Nam', 'Thảo', 'Quân', 'Linh', 'Dũng', 'Mai', 'Phong', 'Hà', 'Sơn', 'Ngọc', 'Tuấn', 'Vy', ]; const LAST_NAMES = ['Nguyễn', 'Trần', 'Lê', 'Phạm', 'Hoàng', 'Vũ', 'Đặng', 'Bùi']; const PROVINCES = ['Hà Nội', 'TP Hồ Chí Minh', 'Đà Nẵng', 'Hải Phòng', 'Cần Thơ']; const DISTRICTS = ['Hoàn Kiếm', 'Ba Đình', 'Quận 1', 'Quận 3', 'Hải Châu', 'Ngô Quyền']; /** Skewed to the top, the way real ratings are. */ const RATING_WEIGHTS = [5, 5, 5, 5, 5, 4, 4, 4, 4, 3, 3, 2, 1]; const REVIEW_TITLES_EN = [ 'Exactly what I wanted', 'Good, with one caveat', 'Runs small — size up', 'Worth the money', 'Held up better than expected', 'Comfortable from day one', 'Not for cold weather', 'My third one of these', 'Fit is spot on', ]; const REVIEW_BODIES_EN = [ 'Wore it for a half marathon last weekend and forgot it was on, which is the highest praise I have for kit.', 'The fabric is genuinely good. My only complaint is the cuffs stretched a little after about ten washes.', 'Sizing runs at least half a size small. I normally take M and the L is the right call here.', 'Bought it on sale and would happily have paid full price. No chafing on a two-hour run.', 'Colour is slightly darker than the photos suggest. Still happy with it.', '', ]; export interface DemoSummary { customers: number; orders: number; reviews: number; content: number; discounts: number; } export async function seedDemoVolume(prisma: PrismaClient): Promise { const customerRole = await prisma.role.findUnique({ where: { key: 'customer' } }); if (!customerRole) throw new Error('The customer role is missing; run the base seed first.'); const products = await prisma.product.findMany({ where: { deletedAt: null, status: 'ACTIVE' }, select: { id: true, name: true, variants: { where: { status: 'ACTIVE', deletedAt: null }, select: { id: true, sku: true, priceAmount: true, salePriceAmount: true, optionValues: { select: { optionValue: { select: { label: true } } } }, }, }, images: { take: 1, select: { media: { select: { storageKey: true } } } }, }, }); const sellable = products.filter((product) => product.variants.length > 0); if (sellable.length === 0) throw new Error('No sellable products; run the catalog seed first.'); // ---- Customers --------------------------------------------------------- // One shared hash: scrypt at N=16384 costs real time, and these accounts all // use the same password anyway. const passwordHash = await hashPassword('Demo@2026Sport'); const customers: { id: string; email: string; name: string }[] = []; for (let index = 0; index < 18; index += 1) { const first = pick(FIRST_NAMES); const last = pick(LAST_NAMES); const email = `demo${index + 1}@example.com`; const existing = await prisma.user.findUnique({ where: { email }, select: { id: true, customer: { select: { id: true } } }, }); if (existing?.customer) { customers.push({ id: existing.customer.id, email, name: `${first} ${last}` }); continue; } const user = existing ?? (await prisma.user.create({ data: { email, passwordHash, type: 'CUSTOMER', status: 'ACTIVE', firstName: first, lastName: last, phone: `09${between(10, 99)}${between(100000, 999999)}`, roles: { create: { roleId: customerRole.id } }, }, select: { id: true, customer: { select: { id: true } } }, })); const customer = await prisma.customer.create({ data: { userId: user.id, acceptsMarketing: random() > 0.45 }, select: { id: true }, }); await prisma.address.create({ data: { customerId: customer.id, fullName: `${first} ${last}`, phone: `09${between(10, 99)}${between(100000, 999999)}`, line1: `${between(1, 250)} ${pick(['Lê Lợi', 'Trần Hưng Đạo', 'Nguyễn Huệ', 'Láng Hạ'])}`, district: pick(DISTRICTS), province: pick(PROVINCES), isDefaultShipping: true, isDefaultBilling: true, }, }); customers.push({ id: customer.id, email, name: `${first} ${last}` }); } // ---- Orders ------------------------------------------------------------ const STATUSES = [ 'PENDING', 'CONFIRMED', 'CONFIRMED', 'FULFILLED', 'FULFILLED', 'FULFILLED', 'COMPLETED', 'COMPLETED', 'CANCELLED', ] as const; const existingDemoOrders = await prisma.order.count({ where: { email: { startsWith: 'demo' }, customerNote: 'demo-volume' }, }); let ordersCreated = 0; const reviewableLines: { orderLineId: string; productId: string }[] = []; if (existingDemoOrders === 0) { for (let index = 0; index < 45; index += 1) { const customer = pick(customers); const status = pick(STATUSES); // Spread across ~120 days so date filters and "recent orders" have range. const placedAt = new Date(Date.now() - between(0, 120) * 86_400_000); const lineCount = between(1, 3); const lines: { variantId: string; productId: string; productName: string; variantTitle: string; sku: string; imageUrl: string | null; unitAmount: number; quantity: number; lineAmount: number; }[] = []; for (let n = 0; n < lineCount; n += 1) { const product = pick(sellable); const variant = pick(product.variants); if (lines.some((line) => line.variantId === variant.id)) continue; const unitAmount = variant.salePriceAmount ?? variant.priceAmount; const quantity = between(1, 2); lines.push({ variantId: variant.id, productId: product.id, productName: product.name, variantTitle: variant.optionValues.map((v) => v.optionValue.label).join(' / ') || '—', sku: variant.sku, imageUrl: product.images[0]?.media.storageKey ?? null, unitAmount, quantity, lineAmount: unitAmount * quantity, }); } if (lines.length === 0) continue; const subtotal = lines.reduce((sum, line) => sum + line.lineAmount, 0); // A third of orders carry a discount, so the order table is not a column // of identical zeroes. const discount = random() > 0.66 ? Math.floor(subtotal * 0.1) : 0; const shipping = subtotal >= 1_000_000 ? 0 : 30_000; const order = await prisma.order.create({ data: { customerId: customer.id, email: customer.email, phone: `09${between(10, 99)}${between(100000, 999999)}`, status, paymentStatus: status === 'CANCELLED' ? 'UNPAID' : 'PAID', // Kept consistent with `status` — a FULFILLED order showing // UNFULFILLED was a real bug once (see the M7 notes). fulfillmentStatus: status === 'FULFILLED' || status === 'COMPLETED' ? 'FULFILLED' : 'UNFULFILLED', currency: 'VND', subtotalAmount: subtotal, discountAmount: discount, shippingAmount: shipping, taxAmount: 0, totalAmount: subtotal - discount + shipping, shipFullName: customer.name, shipPhone: `09${between(10, 99)}${between(100000, 999999)}`, shipLine1: `${between(1, 250)} ${pick(['Lê Lợi', 'Trần Hưng Đạo', 'Nguyễn Huệ'])}`, shipDistrict: pick(DISTRICTS), shipProvince: pick(PROVINCES), customerNote: 'demo-volume', placedAt, confirmedAt: status === 'PENDING' ? null : placedAt, cancelledAt: status === 'CANCELLED' ? placedAt : null, lines: { create: lines.map((line) => ({ variantId: line.variantId, productName: line.productName, variantTitle: line.variantTitle, sku: line.sku, imageUrl: line.imageUrl, unitAmount: line.unitAmount, quantity: line.quantity, lineAmount: line.lineAmount, })), }, }, select: { id: true, lines: { select: { id: true, variantId: true } } }, }); ordersCreated += 1; // Only delivered goods can be reviewed — the same rule the API enforces. if (status === 'FULFILLED' || status === 'COMPLETED') { for (const line of order.lines) { const source = lines.find((candidate) => candidate.variantId === line.variantId); if (source) reviewableLines.push({ orderLineId: line.id, productId: source.productId }); } } } } // ---- Reviews ----------------------------------------------------------- let reviewsCreated = 0; const touchedProducts = new Set(); for (const candidate of reviewableLines) { if (random() > 0.55) continue; const existing = await prisma.review.findUnique({ where: { orderLineId: candidate.orderLineId }, select: { id: true }, }); if (existing) continue; const rating = pick(RATING_WEIGHTS); // Most reviews land approved; a few stay pending and one in ten is // rejected, so the moderation queue has all three states to show. const roll = random(); const status = roll > 0.25 ? 'APPROVED' : roll > 0.1 ? 'PENDING' : 'REJECTED'; await prisma.review.create({ data: { productId: candidate.productId, orderLineId: candidate.orderLineId, rating, title: random() > 0.25 ? pick(REVIEW_TITLES_EN) : null, body: pick(REVIEW_BODIES_EN) || null, authorName: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES).charAt(0)}.`, status, moderationNote: status === 'REJECTED' ? 'Off-topic' : null, moderatedAt: status === 'PENDING' ? null : new Date(), }, }); reviewsCreated += 1; touchedProducts.add(candidate.productId); } // Rating aggregates are derived columns with a single writer; recompute them // here for exactly the reason ReviewsService does — an incremented aggregate // that nobody recomputed is a product showing stars no review supports. for (const productId of touchedProducts) { await prisma.$executeRaw` UPDATE products p SET rating_sum = COALESCE(agg.total, 0), rating_count = COALESCE(agg.n, 0) FROM ( SELECT SUM(rating)::int AS total, COUNT(*)::int AS n FROM reviews WHERE product_id = ${productId}::uuid AND status = 'APPROVED' ) agg WHERE p.id = ${productId}::uuid `; } // ---- Content ----------------------------------------------------------- const POSTS = [ { slug: 'chon-giay-chay-bo', slugEn: 'choosing-running-shoes', title: 'Chọn giày chạy bộ: ba thứ thực sự quan trọng', titleEn: 'Choosing running shoes: the three things that matter', excerpt: 'Bỏ qua tiếp thị. Đây là những gì quyết định một đôi giày hợp với bạn.', excerptEn: 'Ignore the marketing. These are what actually decide whether a shoe suits you.', }, { slug: 'tap-luyen-mua-mua', slugEn: 'training-through-the-rainy-season', title: 'Tập luyện xuyên mùa mưa', titleEn: 'Training through the rainy season', excerpt: 'Mưa không phải lý do nghỉ tập — nhưng cần chuẩn bị khác đi.', excerptEn: 'Rain is not a reason to skip a session, but it does change the preparation.', }, { slug: 'giat-do-the-thao-dung-cach', slugEn: 'washing-technical-fabrics', title: 'Giặt đồ thể thao đúng cách', titleEn: 'How to wash technical fabrics without ruining them', excerpt: 'Nước xả vải là kẻ thù của vải thoát ẩm. Đây là lý do.', excerptEn: 'Fabric softener is the enemy of moisture-wicking fabric. Here is why.', }, ]; let contentCreated = 0; for (const post of POSTS) { const exists = await prisma.contentEntryTranslation.findFirst({ where: { slug: post.slug }, select: { entryId: true }, }); if (exists) continue; await prisma.contentEntry.create({ data: { type: 'POST', status: 'PUBLISHED', publishedAt: new Date(Date.now() - between(1, 60) * 86_400_000), translations: { create: [ { locale: 'VI', slug: post.slug, title: post.title, excerpt: post.excerpt, body: `## ${post.title}\n\n${post.excerpt}\n\n### Điều đầu tiên\n\nMột đoạn nội dung mẫu để kiểm tra cách trình bày bài viết trên nhiều độ dài khác nhau.\n\n- Điểm thứ nhất\n- Điểm thứ hai\n- Điểm thứ ba\n\n> Một trích dẫn để kiểm tra kiểu blockquote.\n\nXem thêm [đồ nam](/men).`, }, { locale: 'EN', slug: post.slugEn, title: post.titleEn, excerpt: post.excerptEn, body: `## ${post.titleEn}\n\n${post.excerptEn}\n\n### The first thing\n\nSample copy, here to check how an article reads at a few different lengths.\n\n- The first point\n- The second point\n- The third point\n\n> A pull quote, to exercise the blockquote style.\n\nSee also [menswear](/men).`, }, ], }, }, }); contentCreated += 1; } // ---- Discounts --------------------------------------------------------- const DISCOUNTS = [ { code: 'FREESHIP', type: 'FIXED_AMOUNT' as const, value: 30_000, stackable: true, priority: 60, name: { vi: 'Miễn phí vận chuyển', en: 'Free shipping' }, minSubtotalAmount: 500_000, }, { code: 'EXPIRED10', type: 'PERCENTAGE' as const, value: 10, stackable: false, priority: 40, name: { vi: 'Ưu đãi đã hết hạn', en: 'Expired offer' }, endsAt: new Date(Date.now() - 7 * 86_400_000), }, { code: 'BIGSALE30', type: 'PERCENTAGE' as const, value: 30, stackable: false, priority: 5, name: { vi: 'Giảm 30% toàn bộ', en: '30% off everything' }, usageLimit: 50, }, ]; let discountsCreated = 0; for (const discount of DISCOUNTS) { const exists = await prisma.discount.findFirst({ where: { code: discount.code }, select: { id: true }, }); if (exists) continue; await prisma.discount.create({ data: { code: discount.code, trigger: 'CODE', type: discount.type, scope: 'ORDER', value: discount.value, minSubtotalAmount: discount.minSubtotalAmount ?? null, endsAt: discount.endsAt ?? null, usageLimit: discount.usageLimit ?? null, stackable: discount.stackable, priority: discount.priority, isActive: true, translations: { create: [ { locale: 'VI', name: discount.name.vi }, { locale: 'EN', name: discount.name.en }, ], }, }, }); discountsCreated += 1; } return { customers: customers.length, orders: ordersCreated, reviews: reviewsCreated, content: contentCreated, discounts: discountsCreated, }; }