Fix API Error before M5

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent d5cacc1208
commit 7688657d3c
13 changed files with 315 additions and 36 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "products" ADD COLUMN "sku_prefix" VARCHAR(24);
+8
View File
@@ -476,6 +476,14 @@ model Product {
metaDescription String? @map("meta_description")
metadata Json?
/// Seeds generated variant SKUs, e.g. "VEL-NOC" -> "VEL-NOC-BLACK-M".
///
/// Stored rather than re-derived, because adding a colourway a year later
/// must extend the same SKU family. Falling back to the slug produced
/// "AO-GIO-CHAY-DEM-NOCTURNE-BLACK-XL" sitting next to "VEL-NOC-BLACK-M" on
/// the same product.
skuPrefix String? @map("sku_prefix") @db.VarChar(24)
/// ---- Read-model projection, derived from this product's variants --------
///
/// Denormalised deliberately. Sorting a listing by price and rendering a
@@ -79,10 +79,16 @@ export class RedisService implements OnModuleDestroy {
const value = await factory();
try {
await this.set(key, value, ttlSeconds);
} catch (error) {
this.logger.warn(`Cache write failed for ${key}: ${messageOf(error)}`);
// A null is never served back — the read above treats it as a miss — so
// writing it only fills Redis with entries that can never hit. A flood of
// requests for slugs that do not exist was enough to grow memory
// indefinitely for no benefit.
if (value !== null && value !== undefined) {
try {
await this.set(key, value, ttlSeconds);
} catch (error) {
this.logger.warn(`Cache write failed for ${key}: ${messageOf(error)}`);
}
}
return value;
@@ -139,6 +139,8 @@ export class ProductsAdminService {
primaryCategoryId: input.primaryCategoryId ?? null,
genderTargets: input.genderTargets,
sportTypes: input.sportTypes,
// Remembered so a colourway added later joins the same SKU family.
skuPrefix: input.skuPrefix?.trim() || null,
},
select: { id: true },
});
@@ -169,7 +171,7 @@ export class ProductsAdminService {
): Promise<AdminProductDetail> {
const existing = await this.prisma.product.findFirst({
where: { id, deletedAt: null },
select: { id: true, slug: true, status: true },
select: { id: true, slug: true, status: true, skuPrefix: true },
});
if (!existing) throw AppException.notFound('Product');
@@ -192,28 +194,35 @@ export class ProductsAdminService {
...(input.primaryCategoryId === undefined
? {}
: { primaryCategoryId: input.primaryCategoryId ?? null }),
...(input.genderTargets ? { genderTargets: input.genderTargets } : {}),
...(input.sportTypes ? { sportTypes: input.sportTypes } : {}),
// `!== undefined`, never truthiness: an explicit `[]` means "clear
// this", an absent key means "leave it alone". Conflating the two is
// how a rename used to wipe a product's targeting.
...(input.genderTargets === undefined ? {} : { genderTargets: input.genderTargets }),
...(input.sportTypes === undefined ? {} : { sportTypes: input.sportTypes }),
...(input.skuPrefix === undefined ? {} : { skuPrefix: input.skuPrefix.trim() || null }),
...(input.status ? this.statusPatch(input.status) : {}),
},
});
if (input.translations) {
if (input.translations !== undefined) {
await this.writeTranslations(tx, id, input as CreateProductInput, existing.slug);
}
if (input.collectionIds) {
if (input.collectionIds !== undefined) {
await this.writeCollections(tx, id, input.collectionIds);
}
if (input.attributes) {
if (input.attributes !== undefined) {
await this.writeAttributes(tx, id, input.attributes);
}
if (input.options) {
if (input.options !== undefined) {
const matrix = await this.writeOptions(tx, id, input.options);
await this.syncVariants(
tx,
id,
input.skuPrefix ?? existing.slug,
input.basePriceAmount ?? 0,
// The stored prefix, not the slug. The editor only sends a prefix on
// create, so falling back to the slug renamed every SKU generated by
// a later edit.
input.skuPrefix?.trim() || existing.skuPrefix || existing.slug,
input.basePriceAmount ?? (await this.inheritedBasePrice(tx, id)),
matrix,
);
}
@@ -246,6 +255,8 @@ export class ProductsAdminService {
throw AppException.badRequest('One or more variants do not belong to this product.');
}
await this.assertSalePricesBelowRegular(productId, input);
await this.prisma.$transaction(
input.variants.map((variant) =>
this.prisma.productVariant.update({
@@ -330,6 +341,28 @@ export class ProductsAdminService {
// ---- internals -----------------------------------------------------------
/**
* What a newly generated variant should cost when the caller did not say.
*
* Adding a size to a live product used to create it at 0, because the update
* path defaulted `basePriceAmount` to zero — the product then advertised
* "from 0 ₫" on every listing until somebody noticed. The cheapest existing
* variant is the only defensible guess, and it is never zero for a product
* that was already priced.
*/
private async inheritedBasePrice(
tx: Prisma.TransactionClient,
productId: string,
): Promise<number> {
const cheapest = await tx.productVariant.findFirst({
where: { productId, status: 'ACTIVE', deletedAt: null },
orderBy: { priceAmount: 'asc' },
select: { priceAmount: true },
});
return cheapest?.priceAmount ?? 0;
}
private statusPatch(status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED') {
return {
status,
@@ -367,6 +400,41 @@ export class ProductsAdminService {
throw AppException.conflict('Could not generate a unique slug for this product.');
}
/**
* A sale price must be below the price it discounts.
*
* Checked here rather than in the schema because a patch may carry only one
* of the two: setting `salePriceAmount` alone has to be compared against the
* variant's *stored* price. Without this, a typo propagated straight into
* `Product.maxPriceAmount` and the storefront showed a "sale" dearer than the
* original, struck through the wrong way round.
*/
private async assertSalePricesBelowRegular(
productId: string,
input: BulkUpdateVariantsInput,
): Promise<void> {
const stored = await this.prisma.productVariant.findMany({
where: { productId },
select: { id: true, sku: true, priceAmount: true, salePriceAmount: true },
});
const byId = new Map(stored.map((variant) => [variant.id, variant]));
for (const patch of input.variants) {
const current = byId.get(patch.id);
if (!current) continue;
const price = patch.priceAmount ?? current.priceAmount;
const sale =
patch.salePriceAmount === undefined ? current.salePriceAmount : patch.salePriceAmount;
if (sale !== null && sale !== undefined && sale >= price) {
throw AppException.badRequest(
`Sale price for ${current.sku} must be below its price of ${price}.`,
);
}
}
}
private async writeTranslations(
tx: Prisma.TransactionClient,
productId: string,
@@ -377,7 +445,15 @@ export class ProductsAdminService {
const fields = input.translations[locale];
if (!fields) continue;
const slug = fields.slug ?? slugify(fields.name) ?? fallbackSlug;
// `product_translations` is UNIQUE(locale, slug) and these slugs are the
// real URLs. Two products named the same used to fail the whole
// transaction with a bare "That value is already taken".
const slug = await this.uniqueTranslationSlug(
tx,
toDbLocale(locale),
fields.slug ?? slugify(fields.name) ?? fallbackSlug,
productId,
);
await tx.productTranslation.upsert({
where: { productId_locale: { productId, locale: toDbLocale(locale) } },
@@ -403,6 +479,28 @@ export class ProductsAdminService {
}
}
/** Appends `-2`, `-3`… until the slug is free within this locale. */
private async uniqueTranslationSlug(
tx: Prisma.TransactionClient,
locale: 'VI' | 'EN',
base: string,
productId: string,
): Promise<string> {
const candidate = base || 'product';
for (let suffix = 0; suffix < 50; suffix += 1) {
const slug = suffix === 0 ? candidate : `${candidate}-${suffix + 1}`;
const clash = await tx.productTranslation.findFirst({
where: { locale, slug, productId: { not: productId } },
select: { productId: true },
});
if (!clash) return slug;
}
throw AppException.conflict(`Could not generate a unique ${locale} slug from "${base}".`);
}
private async writeCollections(
tx: Prisma.TransactionClient,
productId: string,
@@ -294,8 +294,18 @@ export class ProductsMapper {
}
const effective = variants.map((variant) => variant.salePriceAmount ?? variant.priceAmount);
// A variant can be discounted two ways, and both need something to strike
// through: `compareAtAmount` is an explicit "was" price, and a
// `salePriceAmount` is a markdown off the variant's own price. Reading only
// the first left every sale set from the admin's variant grid rendering as
// a lone red number with no original beside it.
const compareAt = variants
.map((variant) => variant.compareAtAmount)
.map(
(variant) =>
variant.compareAtAmount ??
(variant.salePriceAmount !== null ? variant.priceAmount : null),
)
.filter((amount): amount is number => amount !== null);
return {
@@ -273,10 +273,24 @@ export class ProductsRepository {
return { AND: and };
}
/**
* "Offers this value on something you can actually buy."
*
* Matched through the variants, not through `Product.options`. Option values
* are retained when archived variants still reference them (ADR-0016), so the
* option set includes colourways that are no longer sold — filtering on it
* returned products with no red variant for `?colors=red`.
*/
private optionValueFilter(key: string, values: readonly string[]): Prisma.ProductWhereInput {
return {
options: {
some: { key, values: { some: { value: { in: [...values] } } } },
variants: {
some: {
status: 'ACTIVE',
deletedAt: null,
optionValues: {
some: { optionValue: { value: { in: [...values] }, option: { key } } },
},
},
},
};
}
@@ -528,7 +542,12 @@ export class ProductsRepository {
optionValueFacet(where: Prisma.ProductWhereInput, key: string) {
return this.prisma.productOptionValue.groupBy({
by: ['value'],
where: { option: { key, product: where } },
where: {
option: { key, product: where },
// Same reason as `optionValueFilter`: without this a retired colourway
// keeps its count in the rail, and clicking it returns nothing.
variantLinks: { some: { variant: { status: 'ACTIVE', deletedAt: null } } },
},
_count: { _all: true },
orderBy: { _count: { value: 'desc' } },
take: 50,
@@ -543,7 +562,11 @@ export class ProductsRepository {
locale: Locale,
) {
return this.prisma.productOptionValue.findMany({
where: { option: { key, product: where }, value: { in: [...values] } },
where: {
option: { key, product: where },
value: { in: [...values] },
variantLinks: { some: { variant: { status: 'ACTIVE', deletedAt: null } } },
},
distinct: ['value'],
select: {
value: true,