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 { 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 { 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 { 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> { const ids = new Map(); 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> { const ids = new Map(); // 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> { const ids = new Map(); 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; locationId: string; }, ): Promise { 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 { 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(); 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(); 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, ): Promise { 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; sizeValueIds: Map; locationId: string; }, ): Promise { 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 { 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)), }, }); }