feat: Add support for batch method for products and product variants (#7038)
* feat(products): Add batch methods for product and variants * chore: Rename batch validator and minor changes based on PR review
This commit is contained in:
@@ -2828,6 +2828,163 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
})
|
||||
|
||||
describe("batch methods", () => {
|
||||
it("successfully creates, updates, and deletes products", async () => {
|
||||
await breaking(
|
||||
() => {},
|
||||
async () => {
|
||||
const createPayload = getProductFixture({
|
||||
title: "Test batch create",
|
||||
handle: "test-batch-create",
|
||||
})
|
||||
|
||||
const updatePayload = {
|
||||
id: publishedProduct.id,
|
||||
title: "Test batch update",
|
||||
}
|
||||
|
||||
const response = await api.post(
|
||||
"/admin/products/op/batch",
|
||||
{
|
||||
create: [createPayload],
|
||||
update: [updatePayload],
|
||||
delete: [baseProduct.id],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.created).toHaveLength(1)
|
||||
expect(response.data.updated).toHaveLength(1)
|
||||
expect(response.data.deleted.ids).toHaveLength(1)
|
||||
|
||||
expect(response.data.created).toEqual([
|
||||
expect.objectContaining({
|
||||
title: "Test batch create",
|
||||
}),
|
||||
])
|
||||
|
||||
expect(response.data.updated).toEqual([
|
||||
expect.objectContaining({
|
||||
title: "Test batch update",
|
||||
}),
|
||||
])
|
||||
|
||||
expect(response.data.deleted).toEqual(
|
||||
expect.objectContaining({ ids: [baseProduct.id] })
|
||||
)
|
||||
|
||||
const dbData = (await api.get("/admin/products", adminHeaders))
|
||||
.data.products
|
||||
|
||||
expect(dbData).toHaveLength(3)
|
||||
expect(dbData).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Test batch create",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "Test batch update",
|
||||
}),
|
||||
])
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("successfully creates, updates, and deletes product variants", async () => {
|
||||
await breaking(
|
||||
() => {},
|
||||
async () => {
|
||||
const productWithMultipleVariants = getProductFixture({
|
||||
title: "Test batch variants",
|
||||
handle: "test-batch-variants",
|
||||
variants: [
|
||||
{
|
||||
title: "Variant 1",
|
||||
inventory_quantity: 5,
|
||||
prices: [
|
||||
{
|
||||
currency_code: "usd",
|
||||
amount: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Variant 2",
|
||||
inventory_quantity: 20,
|
||||
prices: [
|
||||
{
|
||||
currency_code: "usd",
|
||||
amount: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const createdProduct = (
|
||||
await api.post(
|
||||
"/admin/products",
|
||||
productWithMultipleVariants,
|
||||
adminHeaders
|
||||
)
|
||||
).data.product
|
||||
|
||||
const createPayload = {
|
||||
title: "Test batch create variant",
|
||||
inventory_quantity: 10,
|
||||
prices: [
|
||||
{
|
||||
currency_code: "usd",
|
||||
amount: 20,
|
||||
},
|
||||
{
|
||||
currency_code: "dkk",
|
||||
amount: 10,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
id: createdProduct.variants[0].id,
|
||||
title: "Test batch update variant",
|
||||
}
|
||||
|
||||
const response = await api.post(
|
||||
`/admin/products/${createdProduct.id}/variants/op/batch`,
|
||||
{
|
||||
create: [createPayload],
|
||||
update: [updatePayload],
|
||||
delete: [createdProduct.variants[1].id],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const dbData = (
|
||||
await api.get(
|
||||
`/admin/products/${createdProduct.id}`,
|
||||
adminHeaders
|
||||
)
|
||||
).data.product.variants
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(dbData).toHaveLength(2)
|
||||
expect(dbData).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Test batch create variant",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "Test batch update variant",
|
||||
}),
|
||||
])
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: Discuss how this should be handled
|
||||
describe.skip("GET /admin/products/tag-usage", () => {
|
||||
it("successfully gets the tags usage", async () => {
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
|
||||
import { IPricingModuleService, PricingTypes } from "@medusajs/types"
|
||||
import {
|
||||
convertItemResponseToUpdateRequest,
|
||||
MedusaError,
|
||||
getSelectsAndRelationsFromObjectArray,
|
||||
} from "@medusajs/utils"
|
||||
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||
|
||||
type UpdatePriceSetsStepInput = {
|
||||
selector?: PricingTypes.FilterablePriceSetProps
|
||||
update?: PricingTypes.UpdatePriceSetDTO
|
||||
}
|
||||
type UpdatePriceSetsStepInput =
|
||||
| {
|
||||
selector?: PricingTypes.FilterablePriceSetProps
|
||||
update?: PricingTypes.UpdatePriceSetDTO
|
||||
}
|
||||
| {
|
||||
price_sets: PricingTypes.UpsertPriceSetDTO[]
|
||||
}
|
||||
|
||||
export const updatePriceSetsStepId = "update-price-sets"
|
||||
export const updatePriceSetsStep = createStep(
|
||||
updatePriceSetsStepId,
|
||||
async (data: UpdatePriceSetsStepInput, { container }) => {
|
||||
if (!data.selector || !data.update) {
|
||||
return new StepResponse([], null)
|
||||
}
|
||||
|
||||
const pricingModule = container.resolve<IPricingModuleService>(
|
||||
ModuleRegistrationName.PRICING
|
||||
)
|
||||
|
||||
if ("price_sets" in data) {
|
||||
if (data.price_sets.some((p) => !p.id)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Price set id is required when doing a batch update"
|
||||
)
|
||||
}
|
||||
|
||||
const prevData = await pricingModule.list({
|
||||
id: data.price_sets.map((p) => p.id) as string[],
|
||||
})
|
||||
|
||||
const priceSets = await pricingModule.upsert(data.price_sets)
|
||||
return new StepResponse(priceSets, prevData)
|
||||
}
|
||||
|
||||
if (!data.selector || !data.update) {
|
||||
return new StepResponse([], null)
|
||||
}
|
||||
|
||||
const { selects, relations } = getSelectsAndRelationsFromObjectArray([
|
||||
data.update,
|
||||
])
|
||||
@@ -36,27 +57,17 @@ export const updatePriceSetsStep = createStep(
|
||||
data.update
|
||||
)
|
||||
|
||||
return new StepResponse(updatedPriceSets, {
|
||||
dataBeforeUpdate,
|
||||
selects,
|
||||
relations,
|
||||
})
|
||||
return new StepResponse(updatedPriceSets, dataBeforeUpdate)
|
||||
},
|
||||
async (revertInput, { container }) => {
|
||||
if (!revertInput || !revertInput.dataBeforeUpdate?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const { dataBeforeUpdate, selects, relations } = revertInput
|
||||
|
||||
const pricingModule = container.resolve<IPricingModuleService>(
|
||||
ModuleRegistrationName.PRICING
|
||||
)
|
||||
|
||||
await pricingModule.upsert(
|
||||
dataBeforeUpdate.map((data) =>
|
||||
convertItemResponseToUpdateRequest(data, selects, relations)
|
||||
)
|
||||
)
|
||||
if (!revertInput) {
|
||||
return
|
||||
}
|
||||
|
||||
await pricingModule.upsert(revertInput as PricingTypes.UpsertPriceSetDTO[])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||
import { deleteProductVariantsWorkflow } from "../workflows/delete-product-variants"
|
||||
import { createProductVariantsWorkflow } from "../workflows/create-product-variants"
|
||||
import { updateProductVariantsWorkflow } from "../workflows/update-product-variants"
|
||||
import { PricingTypes, ProductTypes } from "@medusajs/types"
|
||||
|
||||
type BatchProductVariantsInput = {
|
||||
create: (ProductTypes.CreateProductVariantDTO & {
|
||||
prices?: PricingTypes.CreateMoneyAmountDTO[]
|
||||
})[]
|
||||
update: (ProductTypes.UpsertProductVariantDTO & {
|
||||
prices?: PricingTypes.CreateMoneyAmountDTO[]
|
||||
})[]
|
||||
delete: string[]
|
||||
}
|
||||
|
||||
export const batchProductVariantsStepId = "batch-product-variants"
|
||||
export const batchProductVariantsStep = createStep(
|
||||
batchProductVariantsStepId,
|
||||
async (data: BatchProductVariantsInput, { container }) => {
|
||||
const { transaction: createTransaction, result: created } =
|
||||
await createProductVariantsWorkflow(container).run({
|
||||
input: { product_variants: data.create },
|
||||
})
|
||||
const { transaction: updateTransaction, result: updated } =
|
||||
await updateProductVariantsWorkflow(container).run({
|
||||
input: { product_variants: data.update },
|
||||
})
|
||||
const { transaction: deleteTransaction } =
|
||||
await deleteProductVariantsWorkflow(container).run({
|
||||
input: { ids: data.delete },
|
||||
})
|
||||
|
||||
return new StepResponse(
|
||||
{
|
||||
created,
|
||||
updated,
|
||||
deleted: {
|
||||
ids: data.delete,
|
||||
object: "product_variant",
|
||||
deleted: true,
|
||||
},
|
||||
},
|
||||
{ createTransaction, updateTransaction, deleteTransaction }
|
||||
)
|
||||
},
|
||||
|
||||
async (flow, { container }) => {
|
||||
if (!flow) {
|
||||
return
|
||||
}
|
||||
|
||||
if (flow.createTransaction) {
|
||||
await createProductVariantsWorkflow(container).cancel({
|
||||
transaction: flow.createTransaction,
|
||||
})
|
||||
}
|
||||
|
||||
if (flow.updateTransaction) {
|
||||
await updateProductVariantsWorkflow(container).cancel({
|
||||
transaction: flow.updateTransaction,
|
||||
})
|
||||
}
|
||||
|
||||
if (flow.deleteTransaction) {
|
||||
await deleteProductVariantsWorkflow(container).cancel({
|
||||
transaction: flow.deleteTransaction,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
75
packages/core-flows/src/product/steps/batch-products.ts
Normal file
75
packages/core-flows/src/product/steps/batch-products.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||
import { createProductsWorkflow } from "../workflows/create-products"
|
||||
import { updateProductsWorkflow } from "../workflows/update-products"
|
||||
import { deleteProductsWorkflow } from "../workflows/delete-products"
|
||||
import { PricingTypes, ProductTypes } from "@medusajs/types"
|
||||
|
||||
type WorkflowInput = {
|
||||
create: (Omit<ProductTypes.CreateProductDTO, "variants"> & {
|
||||
sales_channels?: { id: string }[]
|
||||
variants?: (ProductTypes.CreateProductVariantDTO & {
|
||||
prices?: PricingTypes.CreateMoneyAmountDTO[]
|
||||
})[]
|
||||
})[]
|
||||
update: (ProductTypes.UpsertProductDTO & {
|
||||
sales_channels?: { id: string }[]
|
||||
})[]
|
||||
delete: string[]
|
||||
}
|
||||
|
||||
export const batchProductsStepId = "batch-products"
|
||||
export const batchProductsStep = createStep(
|
||||
batchProductsStepId,
|
||||
async (data: WorkflowInput, { container }) => {
|
||||
const { transaction: createTransaction, result: created } =
|
||||
await createProductsWorkflow(container).run({
|
||||
input: { products: data.create },
|
||||
})
|
||||
const { transaction: updateTransaction, result: updated } =
|
||||
await updateProductsWorkflow(container).run({
|
||||
input: { products: data.update },
|
||||
})
|
||||
const { transaction: deleteTransaction } = await deleteProductsWorkflow(
|
||||
container
|
||||
).run({
|
||||
input: { ids: data.delete },
|
||||
})
|
||||
|
||||
return new StepResponse(
|
||||
{
|
||||
created,
|
||||
updated,
|
||||
deleted: {
|
||||
ids: data.delete,
|
||||
object: "product",
|
||||
deleted: true,
|
||||
},
|
||||
},
|
||||
{ createTransaction, updateTransaction, deleteTransaction }
|
||||
)
|
||||
},
|
||||
|
||||
async (flow, { container }) => {
|
||||
if (!flow) {
|
||||
return
|
||||
}
|
||||
|
||||
if (flow.createTransaction) {
|
||||
await createProductsWorkflow(container).cancel({
|
||||
transaction: flow.createTransaction,
|
||||
})
|
||||
}
|
||||
|
||||
if (flow.updateTransaction) {
|
||||
await updateProductsWorkflow(container).cancel({
|
||||
transaction: flow.updateTransaction,
|
||||
})
|
||||
}
|
||||
|
||||
if (flow.deleteTransaction) {
|
||||
await deleteProductsWorkflow(container).cancel({
|
||||
transaction: flow.deleteTransaction,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -2,6 +2,7 @@ export * from "./create-products"
|
||||
export * from "./update-products"
|
||||
export * from "./delete-products"
|
||||
export * from "./get-products"
|
||||
export * from "./batch-products"
|
||||
export * from "./create-variant-pricing-link"
|
||||
export * from "./create-product-options"
|
||||
export * from "./update-product-options"
|
||||
@@ -9,6 +10,7 @@ export * from "./delete-product-options"
|
||||
export * from "./create-product-variants"
|
||||
export * from "./update-product-variants"
|
||||
export * from "./delete-product-variants"
|
||||
export * from "./batch-product-variants"
|
||||
export * from "./create-collections"
|
||||
export * from "./update-collections"
|
||||
export * from "./delete-collections"
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
|
||||
import { IProductModuleService, ProductTypes } from "@medusajs/types"
|
||||
import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils"
|
||||
import {
|
||||
MedusaError,
|
||||
getSelectsAndRelationsFromObjectArray,
|
||||
} from "@medusajs/utils"
|
||||
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||
|
||||
type UpdateProductVariantsStepInput = {
|
||||
selector: ProductTypes.FilterableProductVariantProps
|
||||
update: ProductTypes.UpdateProductVariantDTO
|
||||
}
|
||||
type UpdateProductVariantsStepInput =
|
||||
| {
|
||||
selector: ProductTypes.FilterableProductVariantProps
|
||||
update: ProductTypes.UpdateProductVariantDTO
|
||||
}
|
||||
| {
|
||||
product_variants: ProductTypes.UpsertProductVariantDTO[]
|
||||
}
|
||||
|
||||
export const updateProductVariantsStepId = "update-product-variants"
|
||||
export const updateProductVariantsStep = createStep(
|
||||
@@ -16,6 +23,24 @@ export const updateProductVariantsStep = createStep(
|
||||
ModuleRegistrationName.PRODUCT
|
||||
)
|
||||
|
||||
if ("product_variants" in data) {
|
||||
if (data.product_variants.some((p) => !p.id)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Product variant ID is required when doing a batch update of product variants"
|
||||
)
|
||||
}
|
||||
|
||||
const prevData = await service.listVariants({
|
||||
id: data.product_variants.map((p) => p.id) as string[],
|
||||
})
|
||||
|
||||
const productVariants = await service.upsertVariants(
|
||||
data.product_variants
|
||||
)
|
||||
return new StepResponse(productVariants, prevData)
|
||||
}
|
||||
|
||||
const { selects, relations } = getSelectsAndRelationsFromObjectArray([
|
||||
data.update,
|
||||
])
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
|
||||
import { IProductModuleService, ProductTypes } from "@medusajs/types"
|
||||
import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils"
|
||||
import {
|
||||
MedusaError,
|
||||
getSelectsAndRelationsFromObjectArray,
|
||||
} from "@medusajs/utils"
|
||||
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||
|
||||
type UpdateProductsStepInput = {
|
||||
selector: ProductTypes.FilterableProductProps
|
||||
update: ProductTypes.UpdateProductDTO
|
||||
}
|
||||
type UpdateProductsStepInput =
|
||||
| {
|
||||
selector: ProductTypes.FilterableProductProps
|
||||
update: ProductTypes.UpdateProductDTO
|
||||
}
|
||||
| {
|
||||
products: ProductTypes.UpsertProductDTO[]
|
||||
}
|
||||
|
||||
export const updateProductsStepId = "update-products"
|
||||
export const updateProductsStep = createStep(
|
||||
@@ -16,6 +23,22 @@ export const updateProductsStep = createStep(
|
||||
ModuleRegistrationName.PRODUCT
|
||||
)
|
||||
|
||||
if ("products" in data) {
|
||||
if (data.products.some((p) => !p.id)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Product ID is required when doing a batch update of products"
|
||||
)
|
||||
}
|
||||
|
||||
const prevData = await service.list({
|
||||
id: data.products.map((p) => p.id) as string[],
|
||||
})
|
||||
|
||||
const products = await service.upsert(data.products)
|
||||
return new StepResponse(products, prevData)
|
||||
}
|
||||
|
||||
const { selects, relations } = getSelectsAndRelationsFromObjectArray([
|
||||
data.update,
|
||||
])
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
|
||||
import { batchProductVariantsStep } from "../steps/batch-product-variants"
|
||||
import { PricingTypes, ProductTypes } from "@medusajs/types"
|
||||
|
||||
type BatchProductVariantsInput = {
|
||||
create: (ProductTypes.CreateProductVariantDTO & {
|
||||
prices?: PricingTypes.CreateMoneyAmountDTO[]
|
||||
})[]
|
||||
update: (ProductTypes.UpsertProductVariantDTO & {
|
||||
prices?: PricingTypes.CreateMoneyAmountDTO[]
|
||||
})[]
|
||||
delete: string[]
|
||||
}
|
||||
|
||||
type BatchProductVariantsOutput = {
|
||||
created: ProductTypes.ProductVariantDTO[]
|
||||
updated: ProductTypes.ProductVariantDTO[]
|
||||
deleted: {
|
||||
ids: string[]
|
||||
object: string
|
||||
deleted: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const batchProductVariantsWorkflowId = "batch-product-variants"
|
||||
export const batchProductVariantsWorkflow = createWorkflow(
|
||||
batchProductVariantsWorkflowId,
|
||||
(
|
||||
input: WorkflowData<BatchProductVariantsInput>
|
||||
): WorkflowData<BatchProductVariantsOutput> => {
|
||||
return batchProductVariantsStep(input)
|
||||
}
|
||||
)
|
||||
34
packages/core-flows/src/product/workflows/batch-products.ts
Normal file
34
packages/core-flows/src/product/workflows/batch-products.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
|
||||
import { batchProductsStep } from "../steps/batch-products"
|
||||
import { PricingTypes, ProductTypes } from "@medusajs/types"
|
||||
|
||||
type WorkflowInput = {
|
||||
create: (Omit<ProductTypes.CreateProductDTO, "variants"> & {
|
||||
sales_channels?: { id: string }[]
|
||||
variants?: (ProductTypes.CreateProductVariantDTO & {
|
||||
prices?: PricingTypes.CreateMoneyAmountDTO[]
|
||||
})[]
|
||||
})[]
|
||||
update: (ProductTypes.UpsertProductDTO & {
|
||||
sales_channels?: { id: string }[]
|
||||
})[]
|
||||
delete: string[]
|
||||
}
|
||||
|
||||
type BatchProductsOutput = {
|
||||
created: ProductTypes.ProductDTO[]
|
||||
updated: ProductTypes.ProductDTO[]
|
||||
deleted: {
|
||||
ids: string[]
|
||||
object: string
|
||||
deleted: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const batchProductsWorkflowId = "batch-products"
|
||||
export const batchProductsWorkflow = createWorkflow(
|
||||
batchProductsWorkflowId,
|
||||
(input: WorkflowData<WorkflowInput>): WorkflowData<BatchProductsOutput> => {
|
||||
return batchProductsStep(input)
|
||||
}
|
||||
)
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
createWorkflow,
|
||||
transform,
|
||||
} from "@medusajs/workflows-sdk"
|
||||
import { createProductsStep, createVariantPricingLinkStep } from "../steps"
|
||||
import { createProductsStep } from "../steps/create-products"
|
||||
import { createVariantPricingLinkStep } from "../steps/create-variant-pricing-link"
|
||||
import { createPriceSetsStep } from "../../pricing"
|
||||
import { associateProductsWithSalesChannelsStep } from "../../sales-channel"
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
transform,
|
||||
} from "@medusajs/workflows-sdk"
|
||||
import { Modules } from "@medusajs/modules-sdk"
|
||||
import { deleteProductsStep, getProductsStep } from "../steps"
|
||||
import { deleteProductsStep } from "../steps/delete-products"
|
||||
import { getProductsStep } from "../steps/get-products"
|
||||
import { removeRemoteLinkStep } from "../../common"
|
||||
|
||||
type WorkflowInput = { ids: string[] }
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export * from "./create-products"
|
||||
export * from "./delete-products"
|
||||
export * from "./update-products"
|
||||
export * from "./batch-products"
|
||||
export * from "./create-product-options"
|
||||
export * from "./delete-product-options"
|
||||
export * from "./update-product-options"
|
||||
export * from "./create-product-variants"
|
||||
export * from "./delete-product-variants"
|
||||
export * from "./update-product-variants"
|
||||
export * from "./batch-product-variants"
|
||||
export * from "./create-collections"
|
||||
export * from "./delete-collections"
|
||||
export * from "./update-collections"
|
||||
|
||||
@@ -8,12 +8,18 @@ import { updateProductVariantsStep } from "../steps"
|
||||
import { updatePriceSetsStep } from "../../pricing"
|
||||
import { getVariantPricingLinkStep } from "../steps/get-variant-pricing-link"
|
||||
|
||||
type UpdateProductVariantsStepInput = {
|
||||
selector: ProductTypes.FilterableProductVariantProps
|
||||
update: ProductTypes.UpdateProductVariantDTO & {
|
||||
prices?: Partial<PricingTypes.CreateMoneyAmountDTO>[]
|
||||
}
|
||||
}
|
||||
type UpdateProductVariantsStepInput =
|
||||
| {
|
||||
selector: ProductTypes.FilterableProductVariantProps
|
||||
update: ProductTypes.UpdateProductVariantDTO & {
|
||||
prices?: Partial<PricingTypes.CreateMoneyAmountDTO>[]
|
||||
}
|
||||
}
|
||||
| {
|
||||
product_variants: (ProductTypes.UpsertProductVariantDTO & {
|
||||
prices?: Partial<PricingTypes.CreateMoneyAmountDTO>[]
|
||||
})[]
|
||||
}
|
||||
|
||||
type WorkflowInput = UpdateProductVariantsStepInput
|
||||
|
||||
@@ -25,6 +31,17 @@ export const updateProductVariantsWorkflow = createWorkflow(
|
||||
): WorkflowData<ProductTypes.ProductVariantDTO[]> => {
|
||||
// Passing prices to the product module will fail, we want to keep them for after the variant is updated.
|
||||
const updateWithoutPrices = transform({ input }, (data) => {
|
||||
if ("product_variants" in data.input) {
|
||||
return {
|
||||
product_variants: data.input.product_variants.map((variant) => {
|
||||
return {
|
||||
...variant,
|
||||
prices: undefined,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selector: data.input.selector,
|
||||
update: {
|
||||
@@ -38,6 +55,10 @@ export const updateProductVariantsWorkflow = createWorkflow(
|
||||
|
||||
// We don't want to do any pricing updates if the prices didn't change
|
||||
const variantIds = transform({ input, updatedVariants }, (data) => {
|
||||
if ("product_variants" in data.input) {
|
||||
return data.updatedVariants.map((v) => v.id)
|
||||
}
|
||||
|
||||
if (!data.input.update.prices) {
|
||||
return []
|
||||
}
|
||||
@@ -56,6 +77,19 @@ export const updateProductVariantsWorkflow = createWorkflow(
|
||||
return {}
|
||||
}
|
||||
|
||||
if ("product_variants" in data.input) {
|
||||
return data.variantPriceSetLinks.map((link) => {
|
||||
const variant = (data.input as any).product_variants.find(
|
||||
(v) => v.id === link.variant_id
|
||||
)
|
||||
|
||||
return {
|
||||
id: link.price_set_id,
|
||||
prices: variant.prices,
|
||||
} as PricingTypes.UpsertPriceSetDTO
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
selector: {
|
||||
id: data.variantPriceSetLinks.map((link) => link.price_set_id),
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { ProductTypes } from "@medusajs/types"
|
||||
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
|
||||
import { updateProductsStep } from "../steps"
|
||||
import { updateProductsStep } from "../steps/update-products"
|
||||
|
||||
type UpdateProductsStepInput = {
|
||||
selector: ProductTypes.FilterableProductProps
|
||||
update: ProductTypes.UpdateProductDTO
|
||||
}
|
||||
type UpdateProductsStepInput =
|
||||
| {
|
||||
selector: ProductTypes.FilterableProductProps
|
||||
update: ProductTypes.UpdateProductDTO
|
||||
}
|
||||
| {
|
||||
products: ProductTypes.UpsertProductDTO[]
|
||||
}
|
||||
|
||||
type WorkflowInput = UpdateProductsStepInput
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ export const POST = async (
|
||||
req.remoteQueryConfig.fields
|
||||
)
|
||||
res.status(200).json({ product: remapProductResponse(product) })
|
||||
Response
|
||||
}
|
||||
|
||||
export const DELETE = async (
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { batchProductVariantsWorkflow } from "@medusajs/core-flows"
|
||||
import {
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "../../../../../../../types/routing"
|
||||
import {
|
||||
AdminBatchUpdateProductVariantType,
|
||||
AdminCreateProductType,
|
||||
} from "../../../../validators"
|
||||
import { BatchMethodRequest } from "@medusajs/types"
|
||||
import { refetchBatchVariants, remapVariantResponse } from "../../../../helpers"
|
||||
|
||||
export const POST = async (
|
||||
req: AuthenticatedMedusaRequest<
|
||||
BatchMethodRequest<
|
||||
AdminCreateProductType,
|
||||
AdminBatchUpdateProductVariantType
|
||||
>
|
||||
>,
|
||||
res: MedusaResponse
|
||||
) => {
|
||||
const productId = req.params.id
|
||||
|
||||
const normalizedInput = {
|
||||
create: req.validatedBody.create?.map((c) => ({
|
||||
...c,
|
||||
product_id: productId,
|
||||
})),
|
||||
update: req.validatedBody.update?.map((u) => ({
|
||||
...u,
|
||||
product_id: productId,
|
||||
})),
|
||||
delete: req.validatedBody.delete,
|
||||
// TODO: Fix types
|
||||
} as any
|
||||
|
||||
const { result, errors } = await batchProductVariantsWorkflow(req.scope).run({
|
||||
input: normalizedInput,
|
||||
throwOnError: false,
|
||||
})
|
||||
|
||||
if (Array.isArray(errors) && errors[0]) {
|
||||
throw errors[0].error
|
||||
}
|
||||
|
||||
const batchResults = await refetchBatchVariants(
|
||||
result,
|
||||
req.scope,
|
||||
req.remoteQueryConfig.fields
|
||||
)
|
||||
|
||||
res.status(200).json({
|
||||
created: batchResults.created.map(remapVariantResponse),
|
||||
updated: batchResults.updated.map(remapVariantResponse),
|
||||
deleted: batchResults.deleted,
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
} from "../../../../../types/routing"
|
||||
|
||||
import { createProductVariantsWorkflow } from "@medusajs/core-flows"
|
||||
import { remoteQueryObjectFromString } from "@medusajs/utils"
|
||||
import {
|
||||
remoteQueryObjectFromString,
|
||||
ContainerRegistrationKeys,
|
||||
} from "@medusajs/utils"
|
||||
import {
|
||||
refetchProduct,
|
||||
remapKeysForVariant,
|
||||
@@ -17,7 +20,7 @@ export const GET = async (
|
||||
req: AuthenticatedMedusaRequest,
|
||||
res: MedusaResponse
|
||||
) => {
|
||||
const remoteQuery = req.scope.resolve("remoteQuery")
|
||||
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
const productId = req.params.id
|
||||
|
||||
const queryObject = remoteQueryObjectFromString({
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { MedusaContainer, ProductDTO, ProductVariantDTO } from "@medusajs/types"
|
||||
import { remoteQueryObjectFromString } from "@medusajs/utils"
|
||||
import {
|
||||
BatchMethodResponse,
|
||||
MedusaContainer,
|
||||
ProductDTO,
|
||||
ProductVariantDTO,
|
||||
} from "@medusajs/types"
|
||||
import { ContainerRegistrationKeys } from "@medusajs/utils"
|
||||
import { promiseAll, remoteQueryObjectFromString } from "@medusajs/utils"
|
||||
|
||||
const isPricing = (fieldName: string) =>
|
||||
fieldName.startsWith("variants.prices") ||
|
||||
@@ -67,7 +73,7 @@ export const refetchProduct = async (
|
||||
scope: MedusaContainer,
|
||||
fields: string[]
|
||||
) => {
|
||||
const remoteQuery = scope.resolve("remoteQuery")
|
||||
const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
const queryObject = remoteQueryObjectFromString({
|
||||
entryPoint: "product",
|
||||
variables: {
|
||||
@@ -79,3 +85,85 @@ export const refetchProduct = async (
|
||||
const products = await remoteQuery(queryObject)
|
||||
return products[0]
|
||||
}
|
||||
|
||||
export const refetchBatchProducts = async (
|
||||
batchResult: BatchMethodResponse<ProductDTO>,
|
||||
scope: MedusaContainer,
|
||||
fields: string[]
|
||||
) => {
|
||||
const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
let created = Promise.resolve<ProductDTO[]>([])
|
||||
let updated = Promise.resolve<ProductDTO[]>([])
|
||||
|
||||
if (batchResult.created.length) {
|
||||
const createdQuery = remoteQueryObjectFromString({
|
||||
entryPoint: "product",
|
||||
variables: {
|
||||
filters: { id: batchResult.created.map((p) => p.id) },
|
||||
},
|
||||
fields: remapKeysForProduct(fields ?? []),
|
||||
})
|
||||
|
||||
created = remoteQuery(createdQuery)
|
||||
}
|
||||
|
||||
if (batchResult.updated.length) {
|
||||
const updatedQuery = remoteQueryObjectFromString({
|
||||
entryPoint: "product",
|
||||
variables: {
|
||||
filters: { id: batchResult.updated.map((p) => p.id) },
|
||||
},
|
||||
fields: remapKeysForProduct(fields ?? []),
|
||||
})
|
||||
|
||||
updated = remoteQuery(updatedQuery)
|
||||
}
|
||||
|
||||
const [createdRes, updatedRes] = await promiseAll([created, updated])
|
||||
return {
|
||||
created: createdRes,
|
||||
updated: updatedRes,
|
||||
deleted: batchResult.deleted,
|
||||
}
|
||||
}
|
||||
|
||||
export const refetchBatchVariants = async (
|
||||
batchResult: BatchMethodResponse<ProductVariantDTO>,
|
||||
scope: MedusaContainer,
|
||||
fields: string[]
|
||||
) => {
|
||||
const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
let created = Promise.resolve<ProductVariantDTO[]>([])
|
||||
let updated = Promise.resolve<ProductVariantDTO[]>([])
|
||||
|
||||
if (batchResult.created.length) {
|
||||
const createdQuery = remoteQueryObjectFromString({
|
||||
entryPoint: "variant",
|
||||
variables: {
|
||||
filters: { id: batchResult.created.map((v) => v.id) },
|
||||
},
|
||||
fields: remapKeysForVariant(fields ?? []),
|
||||
})
|
||||
|
||||
created = remoteQuery(createdQuery)
|
||||
}
|
||||
|
||||
if (batchResult.updated.length) {
|
||||
const updatedQuery = remoteQueryObjectFromString({
|
||||
entryPoint: "variant",
|
||||
variables: {
|
||||
filters: { id: batchResult.updated.map((v) => v.id) },
|
||||
},
|
||||
fields: remapKeysForVariant(fields ?? []),
|
||||
})
|
||||
|
||||
updated = remoteQuery(updatedQuery)
|
||||
}
|
||||
|
||||
const [createdRes, updatedRes] = await promiseAll([created, updated])
|
||||
return {
|
||||
created: createdRes,
|
||||
updated: updatedRes,
|
||||
deleted: batchResult.deleted,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { transformBody, transformQuery } from "../../../api/middlewares"
|
||||
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
|
||||
import { authenticate } from "../../../utils/authenticate-middleware"
|
||||
import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter"
|
||||
import { validateAndTransformBody } from "../../utils/validate-body"
|
||||
import { validateAndTransformQuery } from "../../utils/validate-query"
|
||||
import { createBatchBody } from "../../utils/validators"
|
||||
import * as QueryConfig from "./query-config"
|
||||
import { maybeApplyPriceListsFilter } from "./utils"
|
||||
import {
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
AdminUpdateProductVariant,
|
||||
AdminGetProductOptionsParams,
|
||||
AdminGetProductOptionParams,
|
||||
AdminBatchUpdateProduct,
|
||||
AdminBatchUpdateProductVariant,
|
||||
} from "./validators"
|
||||
|
||||
export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
|
||||
@@ -64,6 +66,19 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
method: ["POST"],
|
||||
matcher: "/admin/products/op/batch",
|
||||
middlewares: [
|
||||
validateAndTransformBody(
|
||||
createBatchBody(AdminCreateProduct, AdminBatchUpdateProduct)
|
||||
),
|
||||
validateAndTransformQuery(
|
||||
AdminGetProductParams,
|
||||
QueryConfig.retrieveProductQueryConfig
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
method: ["POST"],
|
||||
matcher: "/admin/products/:id",
|
||||
@@ -96,6 +111,22 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
method: ["POST"],
|
||||
matcher: "/admin/products/:id/variants/op/batch",
|
||||
middlewares: [
|
||||
validateAndTransformBody(
|
||||
createBatchBody(
|
||||
AdminCreateProductVariant,
|
||||
AdminBatchUpdateProductVariant
|
||||
)
|
||||
),
|
||||
validateAndTransformQuery(
|
||||
AdminGetProductVariantParams,
|
||||
QueryConfig.retrieveVariantConfig
|
||||
),
|
||||
],
|
||||
},
|
||||
// Note: New endpoint in v2
|
||||
{
|
||||
method: ["GET"],
|
||||
|
||||
43
packages/medusa/src/api-v2/admin/products/op/batch/route.ts
Normal file
43
packages/medusa/src/api-v2/admin/products/op/batch/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { batchProductsWorkflow } from "@medusajs/core-flows"
|
||||
import {
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "../../../../../types/routing"
|
||||
import {
|
||||
AdminBatchUpdateProductType,
|
||||
AdminCreateProductType,
|
||||
} from "../../validators"
|
||||
import { BatchMethodRequest } from "@medusajs/types"
|
||||
import { refetchBatchProducts, remapProductResponse } from "../../helpers"
|
||||
import { CreateProductDTO, UpsertProductDTO } from "@medusajs/types"
|
||||
|
||||
export const POST = async (
|
||||
req: AuthenticatedMedusaRequest<
|
||||
BatchMethodRequest<AdminCreateProductType, AdminBatchUpdateProductType>
|
||||
>,
|
||||
res: MedusaResponse
|
||||
) => {
|
||||
// TODO: Fix types
|
||||
const input = req.validatedBody as any
|
||||
|
||||
const { result, errors } = await batchProductsWorkflow(req.scope).run({
|
||||
input,
|
||||
throwOnError: false,
|
||||
})
|
||||
|
||||
if (Array.isArray(errors) && errors[0]) {
|
||||
throw errors[0].error
|
||||
}
|
||||
|
||||
const batchResults = await refetchBatchProducts(
|
||||
result,
|
||||
req.scope,
|
||||
req.remoteQueryConfig.fields
|
||||
)
|
||||
|
||||
res.status(200).json({
|
||||
created: batchResults.created.map(remapProductResponse),
|
||||
updated: batchResults.updated.map(remapProductResponse),
|
||||
deleted: batchResults.deleted,
|
||||
})
|
||||
}
|
||||
@@ -187,6 +187,13 @@ export const AdminUpdateProductVariant = AdminCreateProductVariant.extend({
|
||||
manage_inventory: z.boolean().optional(),
|
||||
}).strict()
|
||||
|
||||
export type AdminBatchUpdateProductVariantType = z.infer<
|
||||
typeof AdminBatchUpdateProductVariant
|
||||
>
|
||||
export const AdminBatchUpdateProductVariant = AdminUpdateProductVariant.extend({
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
export type AdminCreateProductType = z.infer<typeof AdminCreateProduct>
|
||||
export const AdminCreateProduct = z
|
||||
.object({
|
||||
@@ -228,6 +235,13 @@ export const AdminUpdateProduct = AdminCreateProduct.omit({ is_giftcard: true })
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type AdminBatchUpdateProductType = z.infer<
|
||||
typeof AdminBatchUpdateProduct
|
||||
>
|
||||
export const AdminBatchUpdateProduct = AdminUpdateProduct.extend({
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
// TODO: Handle in create and update product once ready
|
||||
// @IsOptional()
|
||||
// @Type(() => ProductProductCategoryReq)
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const createBatchBody = (
|
||||
createValidator: z.ZodType,
|
||||
updateValidator: z.ZodType
|
||||
) => {
|
||||
return z.object({
|
||||
create: z.array(createValidator).optional(),
|
||||
update: z.array(updateValidator).optional(),
|
||||
delete: z.array(z.string()).optional(),
|
||||
})
|
||||
}
|
||||
|
||||
export const createSelectParams = () => {
|
||||
return z.object({
|
||||
fields: z.string().optional(),
|
||||
|
||||
15
packages/types/src/common/batch.ts
Normal file
15
packages/types/src/common/batch.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type BatchMethodRequest<TCreate extends any, TUpdate extends any> = {
|
||||
create?: TCreate[]
|
||||
update?: TUpdate[]
|
||||
delete?: string[]
|
||||
}
|
||||
|
||||
export type BatchMethodResponse<T extends any> = {
|
||||
created: T[]
|
||||
updated: T[]
|
||||
deleted: {
|
||||
ids: string[]
|
||||
object: string
|
||||
deleted: boolean
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./common"
|
||||
export * from "./rule"
|
||||
export * from "./batch"
|
||||
export * from "./config-module"
|
||||
export * from "./medusa-container"
|
||||
|
||||
@@ -19,6 +19,8 @@ type StepFunctionReturnConfig<TOutput> = {
|
||||
): WorkflowData<TOutput>
|
||||
}
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never
|
||||
|
||||
/**
|
||||
* A step function to be used in a workflow.
|
||||
*
|
||||
@@ -28,7 +30,7 @@ type StepFunctionReturnConfig<TOutput> = {
|
||||
export type StepFunction<
|
||||
TInput,
|
||||
TOutput = unknown
|
||||
> = (keyof TInput extends never
|
||||
> = (KeysOfUnion<TInput> extends []
|
||||
? // Function that doesn't expect any input
|
||||
{
|
||||
(): WorkflowData<TOutput> & StepFunctionReturnConfig<TOutput>
|
||||
|
||||
Reference in New Issue
Block a user