Fix API Error before M5
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "products" ADD COLUMN "sku_prefix" VARCHAR(24);
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user