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:
Stevche Radevski
2024-04-15 16:48:29 +02:00
committed by GitHub
parent c3efac5a0d
commit fd83e75e4b
25 changed files with 793 additions and 56 deletions

View File

@@ -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 () => {

View File

@@ -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[])
}
)

View File

@@ -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,
})
}
}
)

View 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,
})
}
}
)

View File

@@ -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"

View File

@@ -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,
])

View File

@@ -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,
])

View File

@@ -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)
}
)

View 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)
}
)

View File

@@ -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"

View File

@@ -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[] }

View File

@@ -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"

View File

@@ -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),

View File

@@ -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

View File

@@ -64,7 +64,6 @@ export const POST = async (
req.remoteQueryConfig.fields
)
res.status(200).json({ product: remapProductResponse(product) })
Response
}
export const DELETE = async (

View File

@@ -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,
})
}

View File

@@ -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({

View File

@@ -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,
}
}

View File

@@ -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"],

View 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,
})
}

View File

@@ -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)

View File

@@ -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(),

View 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
}
}

View File

@@ -1,4 +1,5 @@
export * from "./common"
export * from "./rule"
export * from "./batch"
export * from "./config-module"
export * from "./medusa-container"

View File

@@ -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>