feat: define validators and use normalize-products step (#12473)

Fixes: FRMW-2965

In this PR we replace/remove the existing step to normalize a CSV file with the newly written CSV normalizer and also we validate the file contents further using a Zod schema.

I have duplicated the schema for now. But it is makes sense to re-use the schema for CSV validating and `/admin/products/batch`, then I can keep one source of truth under utils and re-export it. WDYT?

**Screenshots of some errors after validating the file strictly**

![CleanShot 2025-05-15 at 16 36 46@2x](https://github.com/user-attachments/assets/c7fa424f-b947-4898-9b94-47c48617c129)

![CleanShot 2025-05-15 at 16 36 34@2x](https://github.com/user-attachments/assets/0fefef79-148b-4eeb-8ef0-3077e8063ea8)
This commit is contained in:
Harminder Virk
2025-05-16 08:37:25 +00:00
committed by GitHub
parent 2affc0d7d9
commit e149a99886
23 changed files with 1124 additions and 862 deletions
@@ -65,11 +65,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -78,7 +78,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
},
"toUpdate": {},
@@ -136,11 +136,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -157,11 +157,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -178,11 +178,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -191,7 +191,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
},
"toUpdate": {},
@@ -258,11 +258,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -280,11 +280,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -303,11 +303,11 @@ describe("CSV processor", () => {
"origin_country": "EU",
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -316,7 +316,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
},
"toUpdate": {},
@@ -377,11 +377,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -398,11 +398,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -419,11 +419,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -440,11 +440,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -453,7 +453,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
"sweatpants": {
"categories": [],
@@ -498,11 +498,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -519,11 +519,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -540,11 +540,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -561,11 +561,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -574,7 +574,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
"t-shirt": {
"categories": [],
@@ -600,12 +600,8 @@ describe("CSV processor", () => {
"title": "Size",
"values": [
"S",
"S",
"M",
"M",
"L",
"L",
"XL",
"XL",
],
},
@@ -614,12 +610,6 @@ describe("CSV processor", () => {
"values": [
"Black",
"White",
"Black",
"White",
"Black",
"White",
"Black",
"White",
],
},
],
@@ -643,11 +633,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -665,11 +655,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -687,11 +677,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -709,11 +699,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -731,11 +721,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -753,11 +743,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -775,11 +765,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -797,11 +787,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -810,7 +800,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
},
"toUpdate": {
@@ -858,11 +848,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -879,11 +869,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -900,11 +890,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -921,11 +911,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "10",
"amount": 10,
"currency_code": "eur",
},
{
"amount": "15",
"amount": 15,
"currency_code": "usd",
},
],
@@ -934,7 +924,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
"prod_01JT598HEWAE555V0A6BD602MG": {
"categories": [],
@@ -970,11 +960,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "1000",
"amount": 1000,
"currency_code": "eur",
},
{
"amount": "1200",
"amount": 1200,
"currency_code": "usd",
},
],
@@ -982,7 +972,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
"prod_01JT598HEX26EHDG7SRK37Q3FG": {
"categories": [],
@@ -1024,11 +1014,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "2950",
"amount": 2950,
"currency_code": "eur",
},
{
"amount": "3350",
"amount": 3350,
"currency_code": "usd",
},
],
@@ -1044,11 +1034,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "2950",
"amount": 2950,
"currency_code": "eur",
},
{
"amount": "3350",
"amount": 3350,
"currency_code": "usd",
},
],
@@ -1064,11 +1054,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "2950",
"amount": 2950,
"currency_code": "eur",
},
{
"amount": "3350",
"amount": 3350,
"currency_code": "usd",
},
],
@@ -1084,11 +1074,11 @@ describe("CSV processor", () => {
},
"prices": [
{
"amount": "2950",
"amount": 2950,
"currency_code": "eur",
},
{
"amount": "3350",
"amount": 3350,
"currency_code": "usd",
},
],
@@ -1096,7 +1086,7 @@ describe("CSV processor", () => {
"variant_rank": 0,
},
],
"weight": "400",
"weight": 400,
},
},
}
@@ -169,25 +169,17 @@ const productStaticColumns: {
"product discountable",
"discountable"
),
"product height": processAsNumber("product height", "height", {
asNumericString: true,
}),
"product height": processAsNumber("product height", "height"),
"product hs code": processAsString("product hs code", "hs_code"),
"product length": processAsNumber("product length", "length", {
asNumericString: true,
}),
"product length": processAsNumber("product length", "length"),
"product material": processAsString("product material", "material"),
"product mid code": processAsString("product mid code", "mid_code"),
"product origin country": processAsString(
"product origin country",
"origin_country"
),
"product weight": processAsNumber("product weight", "weight", {
asNumericString: true,
}),
"product width": processAsNumber("product width", "width", {
asNumericString: true,
}),
"product weight": processAsNumber("product weight", "weight"),
"product width": processAsNumber("product width", "width"),
"product metadata": processAsString("product metadata", "metadata"),
"shipping profile id": processAsString(
"shipping profile id",
@@ -243,12 +235,8 @@ const variantStaticColumns: {
"allow_backorder"
),
"variant barcode": processAsString("variant barcode", "barcode"),
"variant height": processAsNumber("variant height", "height", {
asNumericString: true,
}),
"variant length": processAsNumber("variant length", "length", {
asNumericString: true,
}),
"variant height": processAsNumber("variant height", "height"),
"variant length": processAsNumber("variant length", "length"),
"variant material": processAsString("variant material", "material"),
"variant metadata": processAsString("variant metadata", "metadata"),
"variant origin country": processAsString(
@@ -259,12 +247,8 @@ const variantStaticColumns: {
"variant variant rank",
"variant_rank"
),
"variant width": processAsNumber("variant width", "width", {
asNumericString: true,
}),
"variant weight": processAsNumber("variant weight", "weight", {
asNumericString: true,
}),
"variant width": processAsNumber("variant width", "width"),
"variant weight": processAsNumber("variant weight", "weight"),
}
/**
@@ -295,7 +279,7 @@ const variantWildcardColumns: {
} else {
output["prices"].push({
currency_code: iso,
amount: String(numericValue),
amount: numericValue,
})
}
})
@@ -438,7 +422,7 @@ export class CSVNormalizer {
}, {})
if (unknownColumns.length) {
return new MedusaError(
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Invalid column name(s) "${unknownColumns.join('","')}"`
)
@@ -509,7 +493,7 @@ export class CSVNormalizer {
)
if (!matchingKey) {
product.options.push({ title: key, values: [value] })
} else {
} else if (!matchingKey.values.includes(value)) {
matchingKey.values.push(value)
}
})
+6
View File
@@ -0,0 +1,6 @@
export enum ProductStatus {
DRAFT = "draft",
PROPOSED = "proposed",
PUBLISHED = "published",
REJECTED = "rejected",
}
+3 -8
View File
@@ -1,10 +1,5 @@
export enum ProductStatus {
DRAFT = "draft",
PROPOSED = "proposed",
PUBLISHED = "published",
REJECTED = "rejected",
}
export * from "./events"
export * from "./get-variant-availability"
export * from "./enums"
export * from "./csv-normalizer"
export * from "./get-variant-availability"
export * as productValidators from "./validators"
@@ -0,0 +1,173 @@
import { z } from "zod"
import { ProductStatus } from "./enums"
export const booleanString = () =>
z
.union([z.boolean(), z.string()])
.refine((value) => {
return ["true", "false"].includes(value.toString().toLowerCase())
})
.transform((value) => {
return value.toString().toLowerCase() === "true"
})
export const numericString = () =>
z.number().transform((value) => {
return value !== null && value !== undefined ? String(value) : value
})
export const IdAssociation = z.object({
id: z.string(),
})
const statusEnum = z.nativeEnum(ProductStatus)
export const CreateVariantPrice = z.object({
currency_code: z.string(),
amount: z.number(),
min_quantity: z.number().nullish(),
max_quantity: z.number().nullish(),
rules: z.record(z.string(), z.string()).optional(),
})
export const CreateProductOption = z.object({
title: z.string(),
values: z.array(z.string()),
})
export const CreateProductVariant = z
.object({
title: z.string(),
sku: z.string().nullish(),
ean: z.string().nullish(),
upc: z.string().nullish(),
barcode: z.string().nullish(),
hs_code: z.string().nullish(),
mid_code: z.string().nullish(),
allow_backorder: booleanString().optional().default(false),
manage_inventory: booleanString().optional().default(true),
variant_rank: z.number().optional(),
weight: z.number().nullish(),
length: z.number().nullish(),
height: z.number().nullish(),
width: z.number().nullish(),
origin_country: z.string().nullish(),
material: z.string().nullish(),
metadata: z.record(z.unknown()).nullish(),
prices: z.array(CreateVariantPrice),
options: z.record(z.string()).optional(),
inventory_items: z
.array(
z.object({
inventory_item_id: z.string(),
required_quantity: z.number(),
})
)
.optional(),
})
.strict()
export const CreateProduct = z
.object({
title: z.string(),
subtitle: z.string().nullish(),
description: z.string().nullish(),
is_giftcard: booleanString().optional().default(false),
discountable: booleanString().optional().default(true),
images: z.array(z.object({ url: z.string() })).optional(),
thumbnail: z.string().nullish(),
handle: z.string().optional(),
status: statusEnum.optional().default(ProductStatus.DRAFT),
external_id: z.string().nullish(),
type_id: z.string().nullish(),
collection_id: z.string().nullish(),
categories: z.array(IdAssociation).optional(),
tags: z.array(IdAssociation).optional(),
options: z.array(CreateProductOption).optional(),
variants: z.array(CreateProductVariant).optional(),
sales_channels: z.array(z.object({ id: z.string() })).optional(),
shipping_profile_id: z.string().optional(),
weight: z.number().nullish(),
length: z.number().nullish(),
height: z.number().nullish(),
width: z.number().nullish(),
hs_code: z.string().nullish(),
mid_code: z.string().nullish(),
origin_country: z.string().nullish(),
material: z.string().nullish(),
metadata: z.record(z.unknown()).nullish(),
})
.strict()
export const UpdateProductOption = z.object({
id: z.string().optional(),
title: z.string().optional(),
values: z.array(z.string()).optional(),
})
export const UpdateVariantPrice = z.object({
id: z.string().optional(),
currency_code: z.string().optional(),
amount: z.number().optional(),
min_quantity: z.number().nullish(),
max_quantity: z.number().nullish(),
rules: z.record(z.string(), z.string()).optional(),
})
export const UpdateProductVariant = z
.object({
id: z.string().optional(),
title: z.string().optional(),
prices: z.array(UpdateVariantPrice).optional(),
sku: z.string().nullish(),
ean: z.string().nullish(),
upc: z.string().nullish(),
barcode: z.string().nullish(),
hs_code: z.string().nullish(),
mid_code: z.string().nullish(),
allow_backorder: booleanString().optional(),
manage_inventory: booleanString().optional(),
variant_rank: z.number().optional(),
weight: numericString().nullish(),
length: numericString().nullish(),
height: numericString().nullish(),
width: numericString().nullish(),
origin_country: z.string().nullish(),
material: z.string().nullish(),
metadata: z.record(z.unknown()).nullish(),
options: z.record(z.string()).optional(),
})
.strict()
export const UpdateProduct = z
.object({
id: z.string(),
title: z.string().optional(),
discountable: booleanString().optional(),
is_giftcard: booleanString().optional(),
options: z.array(UpdateProductOption).optional(),
variants: z.array(UpdateProductVariant).optional(),
status: statusEnum.optional(),
subtitle: z.string().nullish(),
description: z.string().nullish(),
images: z.array(z.object({ url: z.string() })).optional(),
thumbnail: z.string().nullish(),
handle: z.string().nullish(),
type_id: z.string().nullish(),
external_id: z.string().nullish(),
collection_id: z.string().nullish(),
categories: z.array(IdAssociation).optional(),
tags: z.array(IdAssociation).optional(),
sales_channels: z.array(z.object({ id: z.string() })).optional(),
shipping_profile_id: z.string().nullish(),
weight: numericString().nullish(),
length: numericString().nullish(),
height: numericString().nullish(),
width: numericString().nullish(),
hs_code: z.string().nullish(),
mid_code: z.string().nullish(),
origin_country: z.string().nullish(),
material: z.string().nullish(),
metadata: z.record(z.unknown()).nullish(),
})
.strict()