656 lines
21 KiB
TypeScript
656 lines
21 KiB
TypeScript
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)),
|
||
},
|
||
});
|
||
}
|