add more data
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user