Files
2026-08-13 23:20:23 +07:00

498 lines
17 KiB
TypeScript

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 = <T>(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<DemoSummary> {
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<string>();
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,
};
}