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
@@ -6,7 +6,7 @@ import {
import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import { normalizeForExport } from "../helpers/normalize-for-export"
import { convertJsonToCsv } from "../utlils"
import { convertJsonToCsv } from "../utils"
const prodColumnPositions = new Map([
["Product Id", 0],
@@ -81,13 +81,13 @@ export const generateProductCsvStepId = "generate-product-csv"
/**
* This step generates a CSV file that exports products. The CSV
* file is created and stored using the registered [File Module Provider](https://docs.medusajs.com/resources/infrastructure-modules/file).
*
*
* @example
* const { data: products } = useQueryGraphStep({
* entity: "product",
* fields: ["*", "variants.*", "collection.*", "categories.*"]
* })
*
*
* // @ts-ignore
* const data = generateProductCsvStep(products)
*/
@@ -118,7 +118,7 @@ export const generateProductCsvStep = createStep(
})
return new StepResponse(
{ id: file.id, filename } as GenerateProductCsvStepOutput,
{ id: file.id, filename } as GenerateProductCsvStepOutput,
file.id
)
},
@@ -1,117 +0,0 @@
import { HttpTypes, IProductModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
/**
* The products to group.
*/
export type GroupProductsForBatchStepInput = (HttpTypes.AdminCreateProduct & {
/**
* The ID of the product to update.
*/
id?: string
})[]
export type GroupProductsForBatchStepOutput = {
/**
* The products to create.
*/
create: HttpTypes.AdminCreateProduct[]
/**
* The products to update.
*/
update: (HttpTypes.AdminUpdateProduct & { id: string })[]
}
export const groupProductsForBatchStepId = "group-products-for-batch"
/**
* This step groups products to be created or updated.
*
* @example
* const data = groupProductsForBatchStep([
* {
* id: "prod_123",
* title: "Shirt",
* options: [
* {
* title: "Size",
* values: ["S", "M", "L"]
* }
* ]
* },
* {
* title: "Pants",
* options: [
* {
* title: "Color",
* values: ["Red", "Blue"]
* }
* ],
* variants: [
* {
* title: "Red Pants",
* options: {
* Color: "Red"
* },
* prices: [
* {
* amount: 10,
* currency_code: "usd"
* }
* ]
* }
* ]
* }
* ])
*/
export const groupProductsForBatchStep = createStep(
groupProductsForBatchStepId,
async (
data: GroupProductsForBatchStepInput,
{ container }
) => {
const service = container.resolve<IProductModuleService>(Modules.PRODUCT)
const existingProducts = await service.listProducts(
{
// We use the ID to do product updates
id: data.map((product) => product.id).filter(Boolean) as string[],
},
{ select: ["handle"] }
)
const existingProductsSet = new Set(existingProducts.map((p) => p.id))
const { toUpdate, toCreate } = data.reduce(
(
acc: {
toUpdate: (HttpTypes.AdminUpdateProduct & { id: string })[]
toCreate: HttpTypes.AdminCreateProduct[]
},
product
) => {
// There are few data normalizations to do if we are dealing with an update.
if (product.id && existingProductsSet.has(product.id)) {
acc.toUpdate.push(
product as HttpTypes.AdminUpdateProduct & { id: string }
)
return acc
}
// New products and variants will be created with a new ID, even if there is one present in the CSV.
// To add support for creating with predefined IDs we will need to do changes to the upsert method.
delete product.id
product.variants?.forEach((variant) => {
delete (variant as any).id
})
acc.toCreate.push(product)
return acc
},
{ toUpdate: [], toCreate: [] }
)
return new StepResponse(
{ create: toCreate, update: toUpdate } as GroupProductsForBatchStepOutput,
)
}
)
@@ -23,7 +23,6 @@ export * from "./update-product-tags"
export * from "./delete-product-tags"
export * from "./generate-product-csv"
export * from "./parse-product-csv"
export * from "./group-products-for-batch"
export * from "./wait-confirmation-product-import"
export * from "./get-variant-availability"
export * from "./normalize-products-v1"
export * from "./normalize-products"
@@ -1,7 +1,7 @@
import { CSVNormalizer } from "@medusajs/framework/utils"
import { HttpTypes } from "@medusajs/framework/types"
import { CSVNormalizer, productValidators } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import { convertCsvToJson } from "../utlils"
import { GroupProductsForBatchStepOutput } from "./group-products-for-batch"
import { convertCsvToJson } from "../utils"
/**
* The CSV file content to parse.
@@ -18,7 +18,7 @@ export const normalizeCsvStepId = "normalize-product-csv"
*/
export const normalizeCsvStep = createStep(
normalizeCsvStepId,
async (fileContent: NormalizeProductCsvStepInput, { container }) => {
async (fileContent: NormalizeProductCsvStepInput) => {
const csvProducts =
convertCsvToJson<ConstructorParameters<typeof CSVNormalizer>[0][0]>(
fileContent
@@ -27,22 +27,28 @@ export const normalizeCsvStep = createStep(
const products = normalizer.proccess()
const create = Object.keys(products.toCreate).reduce<
(typeof products)["toCreate"][keyof (typeof products)["toCreate"]][]
HttpTypes.AdminCreateProduct[]
>((result, toCreateHandle) => {
result.push(products.toCreate[toCreateHandle])
result.push(
productValidators.CreateProduct.parse(
products.toCreate[toCreateHandle]
) as HttpTypes.AdminCreateProduct
)
return result
}, [])
const update = Object.keys(products.toUpdate).reduce<
(typeof products)["toUpdate"][keyof (typeof products)["toUpdate"]][]
>((result, toCreateId) => {
result.push(products.toUpdate[toCreateId])
HttpTypes.AdminUpdateProduct & { id: string }[]
>((result, toUpdateId) => {
result.push(
productValidators.UpdateProduct.parse(products.toUpdate[toUpdateId])
)
return result
}, [])
return new StepResponse({
create,
update,
} as GroupProductsForBatchStepOutput)
})
}
)
@@ -8,7 +8,7 @@ import { MedusaError, Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import { normalizeForImport } from "../helpers/normalize-for-import"
import { normalizeV1Products } from "../helpers/normalize-v1-import"
import { convertCsvToJson } from "../utlils"
import { convertCsvToJson } from "../utils"
/**
* The CSV file content to parse.
@@ -6,11 +6,7 @@ import {
transform,
} from "@medusajs/framework/workflows-sdk"
import { notifyOnFailureStep, sendNotificationsStep } from "../../notification"
import {
groupProductsForBatchStep,
parseProductCsvStep,
waitConfirmationProductImportStep,
} from "../steps"
import { normalizeCsvStep, waitConfirmationProductImportStep } from "../steps"
import { batchProductsWorkflow } from "./batch-products"
export const importProductsWorkflowId = "import-products"
@@ -94,8 +90,7 @@ export const importProductsWorkflow = createWorkflow(
(
input: WorkflowData<WorkflowTypes.ProductWorkflow.ImportProductsDTO>
): WorkflowResponse<WorkflowTypes.ProductWorkflow.ImportProductsSummary> => {
const products = parseProductCsvStep(input.fileContent)
const batchRequest = groupProductsForBatchStep(products)
const batchRequest = normalizeCsvStep(input.fileContent)
const summary = transform({ batchRequest }, (data) => {
return {