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
+12 -5
View File
@@ -269,13 +269,13 @@ the variant model under real concurrency.
Everything below was run, not assumed: Everything below was run, not assumed:
- `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 26/26 Turborepo tasks pass; - `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 27/27 Turborepo tasks pass;
`pnpm format:check` clean `pnpm format:check` clean
- 5 migrations, 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories, - 6 migrations, 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories,
3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts 3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts
- **44 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`, the - **48 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`, the
variant matrix planner, the HTTP client's fetch receiver and retry recursion, and the inventory variant matrix planner, the HTTP client's fetch receiver and retry recursion, the inventory
list's variant-driven projection list's variant-driven projection, and the two admin-schema defects that caused silent data loss
- Catalog: listings with filters/facets/cursor paging, PDP, navigation — correctly localised in - Catalog: listings with filters/facets/cursor paging, PDP, navigation — correctly localised in
both `vi` and `en`; money formats per locale from one integer (`690.000 ₫` / `₫690,000`) both `vi` and `en`; money formats per locale from one integer (`690.000 ₫` / `₫690,000`)
- Storefront: every route 200 in both locales; `/en/products/<vi-slug>` → 307 → - Storefront: every route 200 in both locales; `/en/products/<vi-slug>` → 307 →
@@ -303,6 +303,13 @@ through Chrome with the console and network panel open:
media library upload driven from the browser (presign → PUT to MinIO → register, 400×500 PNG media library upload driven from the browser (presign → PUT to MinIO → register, 400×500 PNG
landed at 10,962 bytes with a date-partitioned UUID key); inventory adjustment from the table landed at 10,962 bytes with a date-partitioned UUID key); inventory adjustment from the table
wrote a ledger entry and the storefront went `OUT_OF_STOCK` → `IN_STOCK` on the next request wrote a ledger entry and the storefront went `OUT_OF_STOCK` → `IN_STOCK` on the next request
- **Catalog write correctness**, each reproduced before the fix and re-run after: a product
authored in one language is accepted; renaming a product no longer clears its gender/sport
targeting, collections or attributes; adding a size inherits the sibling price and the stored
SKU prefix (`VEL-NOC-BLACK-S` at 1.590.000 ₫, not `AO-GIO-…-S` at 0 ₫); a sale price above the
regular price is rejected; two products with the same name get distinct per-locale slugs; a
retired colourway leaves both the filter and the facet count; 404s no longer write junk keys
into Redis
- **Listing controls (M4):** sort menu changes the order and the URL together - **Listing controls (M4):** sort menu changes the order and the URL together
(`?sort=price_asc`), and is shareable; "load more" appends the next page in place without (`?sort=price_asc`), and is shareable; "load more" appends the next page in place without
touching the address bar, updates "showing N of M", and disappears when the set is exhausted; touching the address bar, updates "showing N of M", and disappears when the set is exhausted;
@@ -146,8 +146,17 @@ export function ProductForm({
translations: filled, translations: filled,
genderTargets: genders, genderTargets: genders,
sportTypes: sports, sportTypes: sports,
...(skuPrefix.trim() ? { skuPrefix: skuPrefix.trim() } : {}), // SKU prefix and base price are create-only fields — the inputs are not
basePriceAmount: Number.parseInt(basePrice, 10) || 0, // even rendered when editing. Sending them anyway would post
// `basePriceAmount: 0`, and the API cannot distinguish "the operator
// wants zero" from "the form always sends this", so every variant added
// by a later edit would be created free.
...(product
? {}
: {
...(skuPrefix.trim() ? { skuPrefix: skuPrefix.trim() } : {}),
basePriceAmount: Number.parseInt(basePrice, 10) || 0,
}),
options: options.map((option, index) => ({ options: options.map((option, index) => ({
key: option.key, key: option.key,
position: index, position: index,
@@ -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") metaDescription String? @map("meta_description")
metadata Json? 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 -------- /// ---- Read-model projection, derived from this product's variants --------
/// ///
/// Denormalised deliberately. Sorting a listing by price and rendering a /// Denormalised deliberately. Sorting a listing by price and rendering a
@@ -79,10 +79,16 @@ export class RedisService implements OnModuleDestroy {
const value = await factory(); const value = await factory();
try { // A null is never served back — the read above treats it as a miss — so
await this.set(key, value, ttlSeconds); // writing it only fills Redis with entries that can never hit. A flood of
} catch (error) { // requests for slugs that do not exist was enough to grow memory
this.logger.warn(`Cache write failed for ${key}: ${messageOf(error)}`); // 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; return value;
@@ -139,6 +139,8 @@ export class ProductsAdminService {
primaryCategoryId: input.primaryCategoryId ?? null, primaryCategoryId: input.primaryCategoryId ?? null,
genderTargets: input.genderTargets, genderTargets: input.genderTargets,
sportTypes: input.sportTypes, sportTypes: input.sportTypes,
// Remembered so a colourway added later joins the same SKU family.
skuPrefix: input.skuPrefix?.trim() || null,
}, },
select: { id: true }, select: { id: true },
}); });
@@ -169,7 +171,7 @@ export class ProductsAdminService {
): Promise<AdminProductDetail> { ): Promise<AdminProductDetail> {
const existing = await this.prisma.product.findFirst({ const existing = await this.prisma.product.findFirst({
where: { id, deletedAt: null }, 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'); if (!existing) throw AppException.notFound('Product');
@@ -192,28 +194,35 @@ export class ProductsAdminService {
...(input.primaryCategoryId === undefined ...(input.primaryCategoryId === undefined
? {} ? {}
: { primaryCategoryId: input.primaryCategoryId ?? null }), : { primaryCategoryId: input.primaryCategoryId ?? null }),
...(input.genderTargets ? { genderTargets: input.genderTargets } : {}), // `!== undefined`, never truthiness: an explicit `[]` means "clear
...(input.sportTypes ? { sportTypes: input.sportTypes } : {}), // 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) : {}), ...(input.status ? this.statusPatch(input.status) : {}),
}, },
}); });
if (input.translations) { if (input.translations !== undefined) {
await this.writeTranslations(tx, id, input as CreateProductInput, existing.slug); await this.writeTranslations(tx, id, input as CreateProductInput, existing.slug);
} }
if (input.collectionIds) { if (input.collectionIds !== undefined) {
await this.writeCollections(tx, id, input.collectionIds); await this.writeCollections(tx, id, input.collectionIds);
} }
if (input.attributes) { if (input.attributes !== undefined) {
await this.writeAttributes(tx, id, input.attributes); await this.writeAttributes(tx, id, input.attributes);
} }
if (input.options) { if (input.options !== undefined) {
const matrix = await this.writeOptions(tx, id, input.options); const matrix = await this.writeOptions(tx, id, input.options);
await this.syncVariants( await this.syncVariants(
tx, tx,
id, id,
input.skuPrefix ?? existing.slug, // The stored prefix, not the slug. The editor only sends a prefix on
input.basePriceAmount ?? 0, // 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, matrix,
); );
} }
@@ -246,6 +255,8 @@ export class ProductsAdminService {
throw AppException.badRequest('One or more variants do not belong to this product.'); throw AppException.badRequest('One or more variants do not belong to this product.');
} }
await this.assertSalePricesBelowRegular(productId, input);
await this.prisma.$transaction( await this.prisma.$transaction(
input.variants.map((variant) => input.variants.map((variant) =>
this.prisma.productVariant.update({ this.prisma.productVariant.update({
@@ -330,6 +341,28 @@ export class ProductsAdminService {
// ---- internals ----------------------------------------------------------- // ---- 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') { private statusPatch(status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED') {
return { return {
status, status,
@@ -367,6 +400,41 @@ export class ProductsAdminService {
throw AppException.conflict('Could not generate a unique slug for this product.'); 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( private async writeTranslations(
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
productId: string, productId: string,
@@ -377,7 +445,15 @@ export class ProductsAdminService {
const fields = input.translations[locale]; const fields = input.translations[locale];
if (!fields) continue; 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({ await tx.productTranslation.upsert({
where: { productId_locale: { productId, locale: toDbLocale(locale) } }, 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( private async writeCollections(
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
productId: string, productId: string,
@@ -294,8 +294,18 @@ export class ProductsMapper {
} }
const effective = variants.map((variant) => variant.salePriceAmount ?? variant.priceAmount); 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 const compareAt = variants
.map((variant) => variant.compareAtAmount) .map(
(variant) =>
variant.compareAtAmount ??
(variant.salePriceAmount !== null ? variant.priceAmount : null),
)
.filter((amount): amount is number => amount !== null); .filter((amount): amount is number => amount !== null);
return { return {
@@ -273,10 +273,24 @@ export class ProductsRepository {
return { AND: and }; 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 { private optionValueFilter(key: string, values: readonly string[]): Prisma.ProductWhereInput {
return { return {
options: { variants: {
some: { key, values: { some: { value: { in: [...values] } } } }, 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) { optionValueFacet(where: Prisma.ProductWhereInput, key: string) {
return this.prisma.productOptionValue.groupBy({ return this.prisma.productOptionValue.groupBy({
by: ['value'], 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 }, _count: { _all: true },
orderBy: { _count: { value: 'desc' } }, orderBy: { _count: { value: 'desc' } },
take: 50, take: 50,
@@ -543,7 +562,11 @@ export class ProductsRepository {
locale: Locale, locale: Locale,
) { ) {
return this.prisma.productOptionValue.findMany({ 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'], distinct: ['value'],
select: { select: {
value: true, value: true,
+19 -1
View File
@@ -490,7 +490,25 @@ sent an English shopper following "load more" to the Vietnamese listing — invi
default locale, which is exactly why it survived. default locale, which is exactly why it survived.
So: **exercise a flow on data you just created, not only on seeded data, perform each action So: **exercise a flow on data you just created, not only on seeded data, perform each action
twice, and do it in a non-default locale.** Seed data has been through every code path already; new data has been through none. The twice, and do it in a non-default locale.**
The sweep before M5 added one more, and it is the sharpest of the set. Two of the worst defects
in the catalog write path were not logic errors at all — they were **library behaviour that had
drifted out from under the intent written in the comments**:
- `z.record(z.enum(LOCALES), …)` is exhaustive in Zod 4. The helper was named
`requiredTranslations`, its comment said "at least the default locale", and its `.refine`
checked exactly that — and was unreachable. Every single-language product was rejected.
- `.partial()` makes a field optional but keeps its `.default([])`. So an omitted
`genderTargets` arrived as `[]`, and `[]` is truthy. Renaming a product cleared its targeting,
dropped every collection link and deleted all of its attributes.
Both typechecked. Both read correctly. Neither had a test, because the behaviour they depended on
was assumed rather than asserted. `packages/validation/src/catalog-admin.spec.ts` now pins both.
So: **when a schema encodes an intent, assert the intent — not the shape.** "Absent means leave
alone" and "one language is enough" are claims about behaviour, and a type signature cannot make
either of them true. Seed data has been through every code path already; new data has been through none. The
inventory case is pinned in `apps/api/src/modules/inventory/inventory-list.spec.ts`. inventory case is pinned in `apps/api/src/modules/inventory/inventory-list.spec.ts`.
--- ---
+13 -1
View File
@@ -19,7 +19,8 @@
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"clean": "rm -rf dist .turbo *.tsbuildinfo", "clean": "rm -rf dist .turbo *.tsbuildinfo",
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit",
"test": "jest --passWithNoTests"
}, },
"dependencies": { "dependencies": {
"@sport/types": "workspace:*", "@sport/types": "workspace:*",
@@ -28,7 +29,18 @@
"devDependencies": { "devDependencies": {
"@sport/config": "workspace:*", "@sport/config": "workspace:*",
"@sport/eslint-config": "workspace:*", "@sport/eslint-config": "workspace:*",
"@types/jest": "^30.0.0",
"eslint": "catalog:", "eslint": "catalog:",
"jest": "^30.4.2",
"ts-jest": "^29.4.12",
"typescript": "catalog:" "typescript": "catalog:"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"roots": [
"<rootDir>/src"
],
"testRegex": ".*\\.spec\\.ts$"
} }
} }
@@ -0,0 +1,49 @@
import { createProductSchema, updateProductSchema } from './catalog-admin';
/**
* These two schemas each caused a data-loss bug, and neither was visible from
* reading the code — both came from a library behaviour that changed underneath
* the intent expressed in the comments.
*/
describe('createProductSchema translations', () => {
it('accepts a product authored in one language only', () => {
// `z.record` keyed by an enum is exhaustive in Zod 4, which made this fail
// and left the "at least one language" refine unreachable. The editor
// advertises single-language authoring, so this is the supported path.
const result = createProductSchema.safeParse({
translations: { vi: { name: 'Áo Chạy Bộ' } },
basePriceAmount: 100_000,
});
expect(result.success).toBe(true);
});
it('still rejects a product with no language at all', () => {
const result = createProductSchema.safeParse({ translations: {}, basePriceAmount: 0 });
expect(result.success).toBe(false);
});
});
describe('updateProductSchema', () => {
it('leaves omitted collections absent rather than defaulting them to empty', () => {
// `.partial()` keeps `.default([])`, so an omitted key arrived as `[]` —
// and `[]` is truthy, so the service wrote it. Renaming a product cleared
// its gender and sport targeting, dropped every collection link and deleted
// all of its attributes.
const parsed = updateProductSchema.parse({
translations: { vi: { name: 'Chỉ Đổi Tên' } },
});
expect(parsed.genderTargets).toBeUndefined();
expect(parsed.sportTypes).toBeUndefined();
expect(parsed.collectionIds).toBeUndefined();
expect(parsed.attributes).toBeUndefined();
expect(parsed.options).toBeUndefined();
expect(parsed.basePriceAmount).toBeUndefined();
});
it('still applies an explicit empty array, which means "clear this"', () => {
const parsed = updateProductSchema.parse({ genderTargets: [] });
expect(parsed.genderTargets).toEqual([]);
});
});
+36 -8
View File
@@ -16,10 +16,19 @@ import { offsetPageQuerySchema } from './pagination';
const localeKeySchema = z.enum(LOCALES); const localeKeySchema = z.enum(LOCALES);
/** At least the default locale must be present, or the product has no name. */ /**
function requiredTranslations<T extends z.ZodTypeAny>(fields: T) { * At least one locale must be present; the rest are optional.
*
* `z.partialRecord`, NOT `z.record`. In Zod 4 a record keyed by an enum is
* *exhaustive* — `z.record(localeKeySchema, …)` demands every locale and makes
* the refine below unreachable. That silently rejected any product authored in
* one language, which is precisely the workflow the editor advertises ("a
* language with no name is skipped"). The field-level fallback in the database
* exists so partial translations are normal, not exceptional.
*/
function partialTranslations<T extends z.ZodTypeAny>(fields: T) {
return z return z
.record(localeKeySchema, fields) .partialRecord(localeKeySchema, fields)
.refine((value) => Object.keys(value).length > 0, 'Provide content for at least one language'); .refine((value) => Object.keys(value).length > 0, 'Provide content for at least one language');
} }
@@ -47,7 +56,7 @@ export const productOptionValueInputSchema = z.object({
.string() .string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Use a hex colour like #1a2b3c') .regex(/^#[0-9a-fA-F]{6}$/, 'Use a hex colour like #1a2b3c')
.nullish(), .nullish(),
labels: requiredTranslations(z.string().trim().min(1).max(80)), labels: partialTranslations(z.string().trim().min(1).max(80)),
}); });
export const productOptionInputSchema = z.object({ export const productOptionInputSchema = z.object({
@@ -59,14 +68,14 @@ export const productOptionInputSchema = z.object({
.max(40) .max(40)
.regex(/^[a-z][a-z0-9_]*$/, 'Use lowercase letters, digits and underscores'), .regex(/^[a-z][a-z0-9_]*$/, 'Use lowercase letters, digits and underscores'),
position: z.coerce.number().int().min(0).default(0), position: z.coerce.number().int().min(0).default(0),
names: requiredTranslations(z.string().trim().min(1).max(60)), names: partialTranslations(z.string().trim().min(1).max(60)),
values: z.array(productOptionValueInputSchema).min(1, 'An option needs at least one value'), values: z.array(productOptionValueInputSchema).min(1, 'An option needs at least one value'),
}); });
export const productAttributeInputSchema = z.object({ export const productAttributeInputSchema = z.object({
key: z.string().trim().min(1).max(60), key: z.string().trim().min(1).max(60),
position: z.coerce.number().int().min(0).default(0), position: z.coerce.number().int().min(0).default(0),
translations: requiredTranslations( translations: partialTranslations(
z.object({ z.object({
label: z.string().trim().min(1).max(120), label: z.string().trim().min(1).max(120),
value: z.string().trim().min(1).max(500), value: z.string().trim().min(1).max(500),
@@ -75,7 +84,7 @@ export const productAttributeInputSchema = z.object({
}); });
export const createProductSchema = z.object({ export const createProductSchema = z.object({
translations: requiredTranslations(productTranslationSchema), translations: partialTranslations(productTranslationSchema),
brandId: anyIdSchema.nullish(), brandId: anyIdSchema.nullish(),
primaryCategoryId: anyIdSchema.nullish(), primaryCategoryId: anyIdSchema.nullish(),
genderTargets: z.array(genderTargetSchema).default([]), genderTargets: z.array(genderTargetSchema).default([]),
@@ -89,7 +98,26 @@ export const createProductSchema = z.object({
basePriceAmount: z.int().min(0).default(0), basePriceAmount: z.int().min(0).default(0),
}); });
export const updateProductSchema = createProductSchema.partial().extend({ /**
* Written out rather than derived with `.partial()`.
*
* `.partial()` makes a field optional but leaves its `.default([])` in place,
* so an omitted `genderTargets` arrived as `[]` — and `[]` is truthy, so the
* service wrote it. Renaming a product silently cleared its gender and sport
* targeting, dropped it from every collection and deleted all of its
* attributes. Absent must mean "leave alone"; only an explicit `[]` clears.
*/
export const updateProductSchema = z.object({
translations: partialTranslations(productTranslationSchema).optional(),
brandId: anyIdSchema.nullish(),
primaryCategoryId: anyIdSchema.nullish(),
genderTargets: z.array(genderTargetSchema).optional(),
sportTypes: z.array(sportTypeSchema).optional(),
collectionIds: z.array(anyIdSchema).optional(),
skuPrefix: z.string().trim().max(24).optional(),
options: z.array(productOptionInputSchema).optional(),
attributes: z.array(productAttributeInputSchema).optional(),
basePriceAmount: z.int().min(0).optional(),
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(), status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
}); });
+9
View File
@@ -476,9 +476,18 @@ importers:
'@sport/eslint-config': '@sport/eslint-config':
specifier: workspace:* specifier: workspace:*
version: link:../eslint-config version: link:../eslint-config
'@types/jest':
specifier: ^30.0.0
version: 30.0.0
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.39.5(jiti@2.7.0) version: 9.39.5(jiti@2.7.0)
jest:
specifier: ^30.4.2
version: 30.4.2(@types/node@22.20.1)
ts-jest:
specifier: ^29.4.12
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(jest-util@30.4.1)(jest@30.4.2(@types/node@22.20.1))(typescript@5.9.3)
typescript: typescript:
specifier: 'catalog:' specifier: 'catalog:'
version: 5.9.3 version: 5.9.3