498 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}
|