Files
web_sport/apps/api/prisma/seed/catalog.ts
T

656 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
// Persisted, not just used to build the seed SKUs: adding a colourway to
// a seeded product later has to extend the same SKU family, and without
// this the write path falls back to the slug.
skuPrefix: seed.skuPrefix,
},
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,
skuPrefix: seed.skuPrefix,
},
});
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)),
},
});
}