This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3e5d38ec18
commit 3d6b0e0d4e
145 changed files with 7817 additions and 801 deletions
+650
View File
@@ -0,0 +1,650 @@
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import type { PrismaClient } from '@prisma/client';
import Redis from 'ioredis';
import {
BRANDS,
CATEGORIES,
COLLECTIONS,
COLOURS,
PRODUCTS,
type Localised,
type ProductSeed,
} from './data';
import { blurDataUrl, hexToRgb, solidPng } from './png';
/**
* Demo catalog seed. Development and staging only — never production.
*
* Idempotent by natural key (slug / SKU / path), so re-running updates rather
* than duplicating. Deterministic throughout: same input, same stock numbers,
* same images. A seed that produces different data on each run makes "is this
* a bug or just the seed?" unanswerable.
*/
const IMAGE_WIDTH = 1200;
const IMAGE_HEIGHT = 1500;
type Locale = 'VI' | 'EN';
const LOCALES: Locale[] = ['VI', 'EN'];
function localeValue(value: Localised, locale: Locale): string {
return locale === 'VI' ? value.vi : value.en;
}
/**
* Deterministic PRNG (mulberry32) seeded from a string. Stock levels vary
* across variants — as they do in reality — but never between runs.
*/
function seededRandom(seed: string): () => number {
let h = 1779033703 ^ seed.length;
for (let i = 0; i < seed.length; i += 1) {
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
let state = h >>> 0;
return () => {
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;
};
}
export async function seedCatalog(prisma: PrismaClient): Promise<void> {
const storage = createStorageClient();
const brandIds = await seedBrands(prisma);
const categoryIds = await seedCategories(prisma);
const collectionIds = await seedCollections(prisma);
const location = await prisma.inventoryLocation.findUniqueOrThrow({ where: { code: 'MAIN' } });
for (const product of PRODUCTS) {
await seedProduct(prisma, storage, product, {
brandId: brandIds.get(product.brand),
categoryId: categoryIds.get(product.categoryPath),
collectionIds,
locationId: location.id,
});
}
const dropped = await invalidateCatalogCache();
console.log(
`Catalog: ${BRANDS.length} brands, ${CATEGORIES.length} categories, ` +
`${COLLECTIONS.length} collections, ${PRODUCTS.length} products` +
`${dropped === null ? '' : `, ${dropped} cache keys dropped`}.`,
);
}
/**
* Drops the catalog read cache.
*
* Without this, re-seeding leaves Redis serving the previous catalog for up to
* 15 minutes — which reads as "my changes did nothing" and burns an afternoon.
* Failure is non-fatal: an unreachable cache must never fail a seed.
*/
async function invalidateCatalogCache(): Promise<number | null> {
const url = process.env['REDIS_URL'];
if (!url) return null;
const prefix = process.env['REDIS_KEY_PREFIX'] ?? 'sport:';
const redis = new Redis(url, { maxRetriesPerRequest: 1, lazyConnect: true });
try {
await redis.connect();
// SCAN, never KEYS: KEYS blocks the whole server, and this same helper will
// eventually run against production during a catalog import.
let cursor = '0';
let removed = 0;
do {
const [next, keys] = await redis.scan(cursor, 'MATCH', `${prefix}catalog:*`, 'COUNT', 500);
cursor = next;
if (keys.length > 0) {
// Keys from SCAN already carry the prefix; `del` must not re-add it.
removed += await redis.del(...keys);
}
} while (cursor !== '0');
return removed;
} catch {
return null;
} finally {
redis.disconnect();
}
}
// ---------------------------------------------------------------------------
function createStorageClient(): S3Client {
return new S3Client({
endpoint: process.env['STORAGE_ENDPOINT'] ?? 'http://localhost:9000',
region: process.env['STORAGE_REGION'] ?? 'auto',
forcePathStyle: (process.env['STORAGE_FORCE_PATH_STYLE'] ?? 'true') === 'true',
credentials: {
accessKeyId: process.env['STORAGE_ACCESS_KEY_ID'] ?? 'sportminio',
secretAccessKey: process.env['STORAGE_SECRET_ACCESS_KEY'] ?? 'sportminio',
},
});
}
/**
* Uploads a generated placeholder and records the metadata row.
*
* Bytes to object storage, key to PostgreSQL — the same split real uploads use
* (ADR-0009). The key is derived from the colourway so re-running overwrites
* the same object instead of littering the bucket.
*/
async function upsertMedia(
prisma: PrismaClient,
storage: S3Client,
storageKey: string,
hex: string,
altText: string,
): Promise<string> {
const rgb = hexToRgb(hex);
const png = solidPng(IMAGE_WIDTH, IMAGE_HEIGHT, rgb);
await storage.send(
new PutObjectCommand({
Bucket: process.env['STORAGE_BUCKET'] ?? 'sport-media',
Key: storageKey,
Body: png,
ContentType: 'image/png',
CacheControl: 'public, max-age=31536000, immutable',
}),
);
const media = await prisma.mediaAsset.upsert({
where: { storageKey },
update: { altText, sizeBytes: png.length },
create: {
kind: 'IMAGE',
storageKey,
mimeType: 'image/png',
sizeBytes: png.length,
width: IMAGE_WIDTH,
height: IMAGE_HEIGHT,
blurDataUrl: blurDataUrl(rgb),
altText,
},
});
return media.id;
}
async function seedBrands(prisma: PrismaClient): Promise<Map<string, string>> {
const ids = new Map<string, string>();
for (const brand of BRANDS) {
const row = await prisma.brand.upsert({
where: { slug: brand.key },
update: { name: brand.name.en, description: brand.description.en },
create: { slug: brand.key, name: brand.name.en, description: brand.description.en },
});
ids.set(brand.key, row.id);
for (const locale of LOCALES) {
await prisma.brandTranslation.upsert({
where: { brandId_locale: { brandId: row.id, locale } },
update: {
name: localeValue(brand.name, locale),
slug: localeValue(brand.slug, locale),
description: localeValue(brand.description, locale),
},
create: {
brandId: row.id,
locale,
name: localeValue(brand.name, locale),
slug: localeValue(brand.slug, locale),
description: localeValue(brand.description, locale),
},
});
}
}
return ids;
}
async function seedCategories(prisma: PrismaClient): Promise<Map<string, string>> {
const ids = new Map<string, string>();
// CATEGORIES is ordered parents-first, so a parent id is always known by the
// time its children are processed.
for (const category of CATEGORIES) {
const depth = category.path.split('/').length - 1;
const parentId = category.parentPath ? ids.get(category.parentPath) : null;
const row = await prisma.category.upsert({
where: { path: category.path },
update: { name: category.name.en, position: category.position, parentId, depth },
create: {
path: category.path,
slug: category.slug.en,
name: category.name.en,
position: category.position,
parentId,
depth,
},
});
ids.set(category.path, row.id);
for (const locale of LOCALES) {
await prisma.categoryTranslation.upsert({
where: { categoryId_locale: { categoryId: row.id, locale } },
update: {
name: localeValue(category.name, locale),
slug: localeValue(category.slug, locale),
},
create: {
categoryId: row.id,
locale,
name: localeValue(category.name, locale),
slug: localeValue(category.slug, locale),
},
});
}
}
return ids;
}
async function seedCollections(prisma: PrismaClient): Promise<Map<string, string>> {
const ids = new Map<string, string>();
for (const collection of COLLECTIONS) {
const row = await prisma.collection.upsert({
where: { slug: collection.key },
update: { name: collection.name.en, position: collection.position },
create: {
slug: collection.key,
name: collection.name.en,
description: collection.description.en,
position: collection.position,
type: 'MANUAL',
},
});
ids.set(collection.key, row.id);
for (const locale of LOCALES) {
await prisma.collectionTranslation.upsert({
where: { collectionId_locale: { collectionId: row.id, locale } },
update: {
name: localeValue(collection.name, locale),
slug: localeValue(collection.slug, locale),
description: localeValue(collection.description, locale),
},
create: {
collectionId: row.id,
locale,
name: localeValue(collection.name, locale),
slug: localeValue(collection.slug, locale),
description: localeValue(collection.description, locale),
},
});
}
}
return ids;
}
async function seedProduct(
prisma: PrismaClient,
storage: S3Client,
seed: ProductSeed,
refs: {
brandId: string | undefined;
categoryId: string | undefined;
collectionIds: Map<string, string>;
locationId: string;
},
): Promise<void> {
const product = await prisma.product.upsert({
where: { slug: seed.key },
update: {
name: seed.name.en,
status: 'ACTIVE',
publishedAt: new Date('2026-01-15T00:00:00Z'),
brandId: refs.brandId ?? null,
primaryCategoryId: refs.categoryId ?? null,
genderTargets: seed.genders,
sportTypes: seed.sports,
},
create: {
slug: seed.key,
name: seed.name.en,
description: seed.description.en,
shortDescription: seed.shortDescription.en,
status: 'ACTIVE',
publishedAt: new Date('2026-01-15T00:00:00Z'),
brandId: refs.brandId ?? null,
primaryCategoryId: refs.categoryId ?? null,
genderTargets: seed.genders,
sportTypes: seed.sports,
},
});
for (const locale of LOCALES) {
// An empty string in the seed means "not translated" — store null so the
// API's field-level fallback kicks in rather than rendering a blank.
const description = localeValue(seed.description, locale) || null;
await prisma.productTranslation.upsert({
where: { productId_locale: { productId: product.id, locale } },
update: {
name: localeValue(seed.name, locale),
slug: localeValue(seed.slug, locale),
shortDescription: localeValue(seed.shortDescription, locale),
description,
},
create: {
productId: product.id,
locale,
name: localeValue(seed.name, locale),
slug: localeValue(seed.slug, locale),
shortDescription: localeValue(seed.shortDescription, locale),
description,
},
});
}
await seedAttributes(prisma, product.id, seed);
await prisma.productCollection.deleteMany({ where: { productId: product.id } });
for (const key of seed.collections) {
const collectionId = refs.collectionIds.get(key);
if (collectionId) {
await prisma.productCollection.create({ data: { productId: product.id, collectionId } });
}
}
const { colourValueIds, sizeValueIds, colourOptionId, sizeOptionId } = await seedOptions(
prisma,
product.id,
seed,
);
await seedImages(prisma, storage, product.id, seed, colourValueIds);
await seedVariants(prisma, product.id, seed, {
colourOptionId,
sizeOptionId,
colourValueIds,
sizeValueIds,
locationId: refs.locationId,
});
await recomputePricing(prisma, product.id);
}
async function seedAttributes(
prisma: PrismaClient,
productId: string,
seed: ProductSeed,
): Promise<void> {
for (const [index, attribute] of seed.attributes.entries()) {
const row = await prisma.productAttribute.upsert({
where: { productId_key: { productId, key: attribute.key } },
update: { label: attribute.label.en, value: attribute.value.en, position: index },
create: {
productId,
key: attribute.key,
label: attribute.label.en,
value: attribute.value.en,
position: index,
},
});
for (const locale of LOCALES) {
await prisma.productAttributeTranslation.upsert({
where: { attributeId_locale: { attributeId: row.id, locale } },
update: {
label: localeValue(attribute.label, locale),
value: localeValue(attribute.value, locale),
},
create: {
attributeId: row.id,
locale,
label: localeValue(attribute.label, locale),
value: localeValue(attribute.value, locale),
},
});
}
}
}
/**
* Creates the Colour and Size axes and their values.
*
* This is the shape the whole catalog model rests on: options define the axes,
* values define the allowed points, and the variant table below resolves every
* combination into a sellable unit.
*/
async function seedOptions(prisma: PrismaClient, productId: string, seed: ProductSeed) {
const colourOption = await prisma.productOption.upsert({
where: { productId_key: { productId, key: 'colour' } },
update: { name: 'Colour', position: 0 },
create: { productId, key: 'colour', name: 'Colour', position: 0 },
});
const sizeOption = await prisma.productOption.upsert({
where: { productId_key: { productId, key: 'size' } },
update: { name: 'Size', position: 1 },
create: { productId, key: 'size', name: 'Size', position: 1 },
});
for (const [optionId, names] of [
[colourOption.id, { vi: 'Màu sắc', en: 'Colour' }],
[sizeOption.id, { vi: 'Kích cỡ', en: 'Size' }],
] as const) {
for (const locale of LOCALES) {
await prisma.productOptionTranslation.upsert({
where: { optionId_locale: { optionId, locale } },
update: { name: localeValue(names, locale) },
create: { optionId, locale, name: localeValue(names, locale) },
});
}
}
const colourValueIds = new Map<string, string>();
for (const [index, colourKey] of seed.colours.entries()) {
const colour = COLOURS[colourKey];
if (!colour) continue;
const value = await prisma.productOptionValue.upsert({
where: { optionId_value: { optionId: colourOption.id, value: colour.value } },
update: { label: colour.label.en, position: index, swatchHex: colour.hex },
create: {
optionId: colourOption.id,
value: colour.value,
label: colour.label.en,
position: index,
swatchHex: colour.hex,
},
});
colourValueIds.set(colour.value, value.id);
for (const locale of LOCALES) {
await prisma.productOptionValueTranslation.upsert({
where: { optionValueId_locale: { optionValueId: value.id, locale } },
update: { label: localeValue(colour.label, locale) },
create: { optionValueId: value.id, locale, label: localeValue(colour.label, locale) },
});
}
}
// Sizes get NO translation rows on purpose: "M" is "M" in both languages.
// The API falls back to the base label, which is exactly the intended
// behaviour and keeps this from becoming busywork for every new product.
const sizeValueIds = new Map<string, string>();
for (const [index, size] of seed.sizes.entries()) {
const value = await prisma.productOptionValue.upsert({
where: { optionId_value: { optionId: sizeOption.id, value: size.toLowerCase() } },
update: { label: size, position: index },
create: { optionId: sizeOption.id, value: size.toLowerCase(), label: size, position: index },
});
sizeValueIds.set(size, value.id);
}
return {
colourOptionId: colourOption.id,
sizeOptionId: sizeOption.id,
colourValueIds,
sizeValueIds,
};
}
async function seedImages(
prisma: PrismaClient,
storage: S3Client,
productId: string,
seed: ProductSeed,
colourValueIds: Map<string, string>,
): Promise<void> {
for (const [index, colourKey] of seed.colours.entries()) {
const colour = COLOURS[colourKey];
const optionValueId = colour ? colourValueIds.get(colour.value) : undefined;
if (!colour || !optionValueId) continue;
// Two images per colourway: the card's default and its hover state.
for (const [variantIndex, suffix] of ['a', 'b'].entries()) {
const storageKey = `products/seed/${seed.key}-${colour.value}-${suffix}.png`;
const mediaId = await upsertMedia(
prisma,
storage,
storageKey,
variantIndex === 0 ? colour.hex : shade(colour.hex),
`${seed.name.en} — ${colour.label.en}`,
);
await prisma.productImage.upsert({
where: { productId_mediaId_optionValueId: { productId, mediaId, optionValueId } },
update: { position: index * 2 + variantIndex },
create: { productId, mediaId, optionValueId, position: index * 2 + variantIndex },
});
}
}
}
/** Slightly darker sibling colour, so the hover image is visibly different. */
function shade(hex: string): string {
const { r, g, b } = hexToRgb(hex);
const darken = (value: number) => Math.max(0, Math.round(value * 0.78));
return `#${[darken(r), darken(g), darken(b)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
}
/**
* The heart of the model: one variant per colour × size, each with its own SKU,
* price and stock row.
*/
async function seedVariants(
prisma: PrismaClient,
productId: string,
seed: ProductSeed,
refs: {
colourOptionId: string;
sizeOptionId: string;
colourValueIds: Map<string, string>;
sizeValueIds: Map<string, string>;
locationId: string;
},
): Promise<void> {
const random = seededRandom(seed.key);
let position = 0;
for (const colourKey of seed.colours) {
const colour = COLOURS[colourKey];
const colourValueId = colour ? refs.colourValueIds.get(colour.value) : undefined;
if (!colour || !colourValueId) continue;
for (const size of seed.sizes) {
const sizeValueId = refs.sizeValueIds.get(size);
if (!sizeValueId) continue;
const sku = `${seed.skuPrefix}-${colour.value.toUpperCase()}-${size.toUpperCase()}`;
const variant = await prisma.productVariant.upsert({
where: { sku },
update: {
title: `${colour.label.en} / ${size}`,
priceAmount: seed.price,
salePriceAmount: seed.salePrice ?? null,
compareAtAmount: seed.compareAt ?? null,
position,
},
create: {
productId,
sku,
title: `${colour.label.en} / ${size}`,
currency: 'VND',
priceAmount: seed.price,
salePriceAmount: seed.salePrice ?? null,
compareAtAmount: seed.compareAt ?? null,
weightGrams: seed.weightGrams,
position,
status: 'ACTIVE',
},
});
// Composite PK (variantId, optionId) makes "two colours on one variant"
// impossible at the database level.
for (const [optionId, optionValueId] of [
[refs.colourOptionId, colourValueId],
[refs.sizeOptionId, sizeValueId],
] as const) {
await prisma.productVariantOptionValue.upsert({
where: { variantId_optionId: { variantId: variant.id, optionId } },
update: { optionValueId },
create: { variantId: variant.id, optionId, optionValueId },
});
}
// A realistic spread: mostly healthy, some low, a few genuinely sold out
// so the out-of-stock UI is exercised without being contrived.
const roll = random();
const onHand =
roll < 0.08 ? 0 : roll < 0.2 ? 1 + Math.floor(random() * 4) : 8 + Math.floor(random() * 40);
await prisma.stockLevel.upsert({
where: { variantId_locationId: { variantId: variant.id, locationId: refs.locationId } },
update: { onHand },
create: { variantId: variant.id, locationId: refs.locationId, onHand, reserved: 0 },
});
position += 1;
}
}
}
/**
* Mirrors ProductsRepository.recomputePricing.
*
* Duplicated here rather than imported because the seed runs through `tsx`
* outside the Nest container — instantiating the DI graph to compute four
* columns would be the more fragile choice.
*/
async function recomputePricing(prisma: PrismaClient, productId: string): Promise<void> {
const variants = await prisma.productVariant.findMany({
where: { productId, status: 'ACTIVE', deletedAt: null },
select: {
priceAmount: true,
salePriceAmount: true,
stockLevels: { select: { onHand: true, reserved: true } },
},
});
if (variants.length === 0) return;
const effective = variants.map((v) => v.salePriceAmount ?? v.priceAmount);
await prisma.product.update({
where: { id: productId },
data: {
minPriceAmount: Math.min(...effective),
maxPriceAmount: Math.max(...effective),
isOnSale: variants.some((v) => v.salePriceAmount !== null),
inStock: variants.some((v) => v.stockLevels.some((l) => l.onHand - l.reserved > 0)),
},
});
}