From 7baedf73d584da5b2f7633573bb00639ca328aae Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Mon, 27 May 2024 17:54:11 +0200 Subject: [PATCH] feat(core-flows,types,utils,medusa): Update existing line items when adding the same variant to cart (#7470) * feat(core-flows,types,utils,medusa): Update existing line items when adding the same variant to cart * chore: split steps into 2 for add-to-cart * chore: split steps into 2 for add-to-cart * chore: iterate safely * chore: parallelize upsert --- .../__tests__/cart/store/carts.spec.ts | 132 +++++++++++++++++- .../src/definition/cart/steps/add-to-cart.ts | 31 ---- .../cart/steps/create-line-items.ts | 35 +++++ .../cart/steps/fetch-line-items-to-upsert | 0 .../cart/steps/get-line-item-actions.ts | 59 ++++++++ .../src/definition/cart/steps/index.ts | 4 +- .../cart/steps/update-line-items.ts | 64 +++++++++ .../definition/cart/workflows/add-to-cart.ts | 32 ++++- .../workflows/update-line-item-in-cart.ts | 4 +- .../line-item/steps/update-line-items.ts | 7 +- packages/core/types/src/cart/mutations.ts | 5 + packages/core/types/src/cart/workflows.ts | 2 +- .../core/utils/src/common/deep-equal-obj.ts | 6 +- .../src/api/store/carts/query-config.ts | 1 + .../medusa/src/api/store/carts/validators.ts | 3 +- .../modules/cart/src/services/cart-module.ts | 3 +- 16 files changed, 339 insertions(+), 49 deletions(-) delete mode 100644 packages/core/core-flows/src/definition/cart/steps/add-to-cart.ts create mode 100644 packages/core/core-flows/src/definition/cart/steps/create-line-items.ts create mode 100644 packages/core/core-flows/src/definition/cart/steps/fetch-line-items-to-upsert create mode 100644 packages/core/core-flows/src/definition/cart/steps/get-line-item-actions.ts create mode 100644 packages/core/core-flows/src/definition/cart/steps/update-line-items.ts diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index 6297f367fd..733aedc70b 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -17,6 +17,7 @@ import { IRegionModuleService, ISalesChannelModuleService, ITaxModuleService, + ProductStatus, } from "@medusajs/types" import { ContainerRegistrationKeys, @@ -1166,14 +1167,65 @@ medusaIntegrationTestRunner({ }) describe("POST /store/carts/:id/line-items", () => { - it.skip("should add item to cart", async () => { + let region + const productData = { + title: "Medusa T-Shirt", + handle: "t-shirt", + status: ProductStatus.PUBLISHED, + options: [ + { + title: "Size", + values: ["S"], + }, + { + title: "Color", + values: ["Black", "White"], + }, + ], + variants: [ + { + title: "S / Black", + sku: "SHIRT-S-BLACK", + options: { + Size: "S", + Color: "Black", + }, + manage_inventory: false, + prices: [ + { + amount: 1500, + currency_code: "usd", + }, + ], + }, + { + title: "S / White", + sku: "SHIRT-S-WHITE", + options: { + Size: "S", + Color: "White", + }, + manage_inventory: false, + prices: [ + { + amount: 1500, + currency_code: "usd", + }, + ], + }, + ], + } + + beforeEach(async () => { await setupTaxStructure(taxModule) - const region = await regionModule.create({ + region = await regionModule.create({ name: "US", currency_code: "usd", }) + }) + it("should add item to cart", async () => { const customer = await customerModule.create({ email: "tony@stark-industries.com", }) @@ -1357,6 +1409,82 @@ medusaIntegrationTestRunner({ }) ) }) + + it("adding an existing variant should update or create line item depending on metadata", async () => { + const product = ( + await api.post(`/admin/products`, productData, adminHeaders) + ).data.product + + const cart = ( + await api.post(`/store/carts`, { + email: "tony@stark.com", + currency_code: region.currency_code, + region_id: region.id, + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + metadata: { + Size: "S", + Color: "Black", + }, + }, + ], + }) + ).data.cart + + let response = await api.post(`/store/carts/${cart.id}/line-items`, { + variant_id: product.variants[0].id, + quantity: 1, + metadata: { + Size: "S", + Color: "Black", + }, + }) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + items: [ + expect.objectContaining({ + unit_price: 1500, + quantity: 2, + title: "S / Black", + }), + ], + subtotal: 3000, + }) + ) + + response = await api.post(`/store/carts/${cart.id}/line-items`, { + variant_id: product.variants[0].id, + quantity: 1, + metadata: { + Size: "S", + Color: "White", + Special: "attribute", + }, + }) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + items: [ + expect.objectContaining({ + unit_price: 1500, + quantity: 2, + title: "S / Black", + }), + expect.objectContaining({ + unit_price: 1500, + quantity: 1, + title: "S / Black", + }), + ], + subtotal: 4500, + }) + ) + }) }) describe("POST /store/payment-collections", () => { diff --git a/packages/core/core-flows/src/definition/cart/steps/add-to-cart.ts b/packages/core/core-flows/src/definition/cart/steps/add-to-cart.ts deleted file mode 100644 index caca51b73d..0000000000 --- a/packages/core/core-flows/src/definition/cart/steps/add-to-cart.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { CreateLineItemForCartDTO, ICartModuleService } from "@medusajs/types" -import { StepResponse, createStep } from "@medusajs/workflows-sdk" - -interface StepInput { - items: CreateLineItemForCartDTO[] -} - -export const addToCartStepId = "add-to-cart-step" -export const addToCartStep = createStep( - addToCartStepId, - async (data: StepInput, { container }) => { - const cartService = container.resolve( - ModuleRegistrationName.CART - ) - - const items = await cartService.addLineItems(data.items) - - return new StepResponse(items, items) - }, - async (createdLineItems, { container }) => { - const cartService: ICartModuleService = container.resolve( - ModuleRegistrationName.CART - ) - if (!createdLineItems?.length) { - return - } - - await cartService.deleteLineItems(createdLineItems.map((c) => c.id)) - } -) diff --git a/packages/core/core-flows/src/definition/cart/steps/create-line-items.ts b/packages/core/core-flows/src/definition/cart/steps/create-line-items.ts new file mode 100644 index 0000000000..7cf8d9e07d --- /dev/null +++ b/packages/core/core-flows/src/definition/cart/steps/create-line-items.ts @@ -0,0 +1,35 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { CreateLineItemForCartDTO, ICartModuleService } from "@medusajs/types" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" + +interface StepInput { + id: string + items: CreateLineItemForCartDTO[] +} + +export const createLineItemsStepId = "create-line-items-step" +export const createLineItemsStep = createStep( + createLineItemsStepId, + async (data: StepInput, { container }) => { + const cartModule = container.resolve( + ModuleRegistrationName.CART + ) + + const createdItems = data.items.length + ? await cartModule.addLineItems(data.items) + : [] + + return new StepResponse(createdItems, createdItems) + }, + async (createdItems, { container }) => { + if (!createdItems?.length) { + return + } + + const cartModule: ICartModuleService = container.resolve( + ModuleRegistrationName.CART + ) + + await cartModule.deleteLineItems(createdItems.map((c) => c.id)) + } +) diff --git a/packages/core/core-flows/src/definition/cart/steps/fetch-line-items-to-upsert b/packages/core/core-flows/src/definition/cart/steps/fetch-line-items-to-upsert new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/core/core-flows/src/definition/cart/steps/get-line-item-actions.ts b/packages/core/core-flows/src/definition/cart/steps/get-line-item-actions.ts new file mode 100644 index 0000000000..06a4010604 --- /dev/null +++ b/packages/core/core-flows/src/definition/cart/steps/get-line-item-actions.ts @@ -0,0 +1,59 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + CartLineItemDTO, + CreateLineItemForCartDTO, + ICartModuleService, + UpdateLineItemWithSelectorDTO, +} from "@medusajs/types" +import { deepEqualObj, isPresent, MathBN } from "@medusajs/utils" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" + +interface StepInput { + id: string + items: CreateLineItemForCartDTO[] +} + +export const getLineItemActionsStepId = "get-line-item-actions-step" +export const getLineItemActionsStep = createStep( + getLineItemActionsStepId, + async (data: StepInput, { container }) => { + const cartModule = container.resolve( + ModuleRegistrationName.CART + ) + + const existingVariantItems = await cartModule.listLineItems({ + cart_id: data.id, + variant_id: data.items.map((d) => d.variant_id!), + }) + + const variantItemMap = new Map( + existingVariantItems.map((item) => [item.variant_id!, item]) + ) + + const itemsToCreate: CreateLineItemForCartDTO[] = [] + const itemsToUpdate: UpdateLineItemWithSelectorDTO[] = [] + + for (const item of data.items) { + const existingItem = variantItemMap.get(item.variant_id!) + const metadataMatches = + (!isPresent(existingItem?.metadata) && !isPresent(item.metadata)) || + deepEqualObj(existingItem?.metadata, item.metadata) + + if (existingItem && metadataMatches) { + const quantity = MathBN.sum( + existingItem.quantity as number, + item.quantity || 1 + ).toNumber() + + itemsToUpdate.push({ + selector: { id: existingItem.id }, + data: { id: existingItem.id, quantity: quantity }, + }) + } else { + itemsToCreate.push(item) + } + } + + return new StepResponse({ itemsToCreate, itemsToUpdate }, null) + } +) diff --git a/packages/core/core-flows/src/definition/cart/steps/index.ts b/packages/core/core-flows/src/definition/cart/steps/index.ts index cb6f553a9c..0369a90973 100644 --- a/packages/core/core-flows/src/definition/cart/steps/index.ts +++ b/packages/core/core-flows/src/definition/cart/steps/index.ts @@ -1,8 +1,8 @@ export * from "./add-shipping-method-to-cart" -export * from "./add-to-cart" export * from "./confirm-inventory" export * from "./create-carts" export * from "./create-line-item-adjustments" +export * from "./create-line-items" export * from "./create-order-from-cart" export * from "./create-shipping-method-adjustments" export * from "./find-one-or-any-region" @@ -10,6 +10,7 @@ export * from "./find-or-create-customer" export * from "./find-sales-channel" export * from "./get-actions-to-compute-from-promotions" export * from "./get-item-tax-lines" +export * from "./get-line-item-actions" export * from "./get-promotion-codes-to-apply" export * from "./get-variant-price-sets" export * from "./get-variants" @@ -22,6 +23,7 @@ export * from "./retrieve-cart-with-links" export * from "./set-tax-lines-for-items" export * from "./update-cart-promotions" export * from "./update-carts" +export * from "./update-line-items" export * from "./validate-cart-payments" export * from "./validate-cart-shipping-options" export * from "./validate-variant-prices" diff --git a/packages/core/core-flows/src/definition/cart/steps/update-line-items.ts b/packages/core/core-flows/src/definition/cart/steps/update-line-items.ts new file mode 100644 index 0000000000..4c47027efa --- /dev/null +++ b/packages/core/core-flows/src/definition/cart/steps/update-line-items.ts @@ -0,0 +1,64 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + ICartModuleService, + UpdateLineItemWithSelectorDTO, +} from "@medusajs/types" +import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" + +interface StepInput { + id: string + items: UpdateLineItemWithSelectorDTO[] +} + +export const updateLineItemsStepId = "update-line-items-step" +export const updateLineItemsStep = createStep( + updateLineItemsStepId, + async (input: StepInput, { container }) => { + const { id, items = [] } = input + + if (!items?.length) { + return new StepResponse([], []) + } + + const cartModule = container.resolve( + ModuleRegistrationName.CART + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray( + items.map((item) => item.data) + ) + + const itemsBeforeUpdate = await cartModule.listLineItems( + { id: items.map((d) => d.selector.id!) }, + { select: selects, relations } + ) + + const updatedItems = items.length + ? await cartModule.updateLineItems(items) + : [] + + return new StepResponse(updatedItems, itemsBeforeUpdate) + }, + async (itemsBeforeUpdate, { container }) => { + if (!itemsBeforeUpdate?.length) { + return + } + + const cartModule: ICartModuleService = container.resolve( + ModuleRegistrationName.CART + ) + + if (itemsBeforeUpdate.length) { + const itemsToUpdate: UpdateLineItemWithSelectorDTO[] = [] + + for (const item of itemsBeforeUpdate) { + const { id, ...data } = item + + itemsToUpdate.push({ selector: { id }, data }) + } + + await cartModule.updateLineItems(itemsToUpdate) + } + } +) diff --git a/packages/core/core-flows/src/definition/cart/workflows/add-to-cart.ts b/packages/core/core-flows/src/definition/cart/workflows/add-to-cart.ts index b0318a5f1f..4855d64bbb 100644 --- a/packages/core/core-flows/src/definition/cart/workflows/add-to-cart.ts +++ b/packages/core/core-flows/src/definition/cart/workflows/add-to-cart.ts @@ -5,10 +5,16 @@ import { import { WorkflowData, createWorkflow, + parallelize, transform, } from "@medusajs/workflows-sdk" import { useRemoteQueryStep } from "../../../common/steps/use-remote-query" -import { addToCartStep, refreshCartShippingMethodsStep } from "../steps" +import { + createLineItemsStep, + getLineItemActionsStep, + refreshCartShippingMethodsStep, + updateLineItemsStep, +} from "../steps" import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions" import { updateTaxLinesStep } from "../steps/update-tax-lines" import { validateVariantPricesStep } from "../steps/validate-variant-prices" @@ -78,7 +84,25 @@ export const addToCartWorkflow = createWorkflow( return items }) - const items = addToCartStep({ items: lineItems }) + const { itemsToCreate = [], itemsToUpdate = [] } = getLineItemActionsStep({ + id: input.cart.id, + items: lineItems, + }) + + const [createdItems, updatedItems] = parallelize( + createLineItemsStep({ + id: input.cart.id, + items: itemsToCreate, + }), + updateLineItemsStep({ + id: input.cart.id, + items: itemsToUpdate, + }) + ) + + const items = transform({ createdItems, updatedItems }, (data) => { + return [...(data.createdItems || []), ...(data.updatedItems || [])] + }) const cart = useRemoteQueryStep({ entry_point: "cart", @@ -88,9 +112,7 @@ export const addToCartWorkflow = createWorkflow( }).config({ name: "refetch–cart" }) refreshCartShippingMethodsStep({ cart }) - // TODO: since refreshCartShippingMethodsStep potentially removes cart shipping methods, we need the updated cart here - // for the following 2 steps as they act upon final cart shape - updateTaxLinesStep({ cart_or_cart_id: cart, items }) + updateTaxLinesStep({ cart_or_cart_id: input.cart.id, items }) refreshCartPromotionsStep({ id: input.cart.id }) refreshPaymentCollectionForCartStep({ cart_id: input.cart.id }) diff --git a/packages/core/core-flows/src/definition/cart/workflows/update-line-item-in-cart.ts b/packages/core/core-flows/src/definition/cart/workflows/update-line-item-in-cart.ts index 643cffc6e1..dfc922e39a 100644 --- a/packages/core/core-flows/src/definition/cart/workflows/update-line-item-in-cart.ts +++ b/packages/core/core-flows/src/definition/cart/workflows/update-line-item-in-cart.ts @@ -5,7 +5,7 @@ import { transform, } from "@medusajs/workflows-sdk" import { useRemoteQueryStep } from "../../../common/steps/use-remote-query" -import { updateLineItemsStep } from "../../line-item/steps" +import { updateLineItemsStepWithSelector } from "../../line-item/steps" import { refreshCartShippingMethodsStep } from "../steps" import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions" import { validateVariantPricesStep } from "../steps/validate-variant-prices" @@ -77,7 +77,7 @@ export const updateLineItemInCartWorkflow = createWorkflow( } }) - const result = updateLineItemsStep(lineItemUpdate) + const result = updateLineItemsStepWithSelector(lineItemUpdate) const cart = useRemoteQueryStep({ entry_point: "cart", diff --git a/packages/core/core-flows/src/definition/line-item/steps/update-line-items.ts b/packages/core/core-flows/src/definition/line-item/steps/update-line-items.ts index b107e9056d..28458b239b 100644 --- a/packages/core/core-flows/src/definition/line-item/steps/update-line-items.ts +++ b/packages/core/core-flows/src/definition/line-item/steps/update-line-items.ts @@ -10,9 +10,10 @@ import { } from "@medusajs/utils" import { StepResponse, createStep } from "@medusajs/workflows-sdk" -export const updateLineItemsStepId = "update-line-items" -export const updateLineItemsStep = createStep( - updateLineItemsStepId, +export const updateLineItemsStepWithSelectorId = + "update-line-items-with-selector" +export const updateLineItemsStepWithSelector = createStep( + updateLineItemsStepWithSelectorId, async (input: UpdateLineItemWithSelectorDTO, { container }) => { const service = container.resolve( ModuleRegistrationName.CART diff --git a/packages/core/types/src/cart/mutations.ts b/packages/core/types/src/cart/mutations.ts index 7dda0ff230..6159d98e5e 100644 --- a/packages/core/types/src/cart/mutations.ts +++ b/packages/core/types/src/cart/mutations.ts @@ -560,6 +560,11 @@ export interface CreateLineItemDTO { * The adjustments of the line item. */ adjustments?: CreateAdjustmentDTO[] + + /** + * Holds custom data in key-value pairs. + */ + metadata?: Record | null } /** diff --git a/packages/core/types/src/cart/workflows.ts b/packages/core/types/src/cart/workflows.ts index 8b075edce2..1e4e129300 100644 --- a/packages/core/types/src/cart/workflows.ts +++ b/packages/core/types/src/cart/workflows.ts @@ -36,7 +36,7 @@ export interface CreateCartCreateLineItemDTO { compare_at_unit_price?: BigNumberInput unit_price?: BigNumberInput - metadata?: Record + metadata?: Record | null } export interface UpdateLineItemInCartWorkflowInputDTO { diff --git a/packages/core/utils/src/common/deep-equal-obj.ts b/packages/core/utils/src/common/deep-equal-obj.ts index 495b683cde..c3b21b9e08 100644 --- a/packages/core/utils/src/common/deep-equal-obj.ts +++ b/packages/core/utils/src/common/deep-equal-obj.ts @@ -1,4 +1,4 @@ -export function deepEqualObj(obj1: object, obj2: object): boolean { +export function deepEqualObj(obj1: unknown, obj2: unknown): boolean { if (typeof obj1 !== typeof obj2) { return false } @@ -7,6 +7,10 @@ export function deepEqualObj(obj1: object, obj2: object): boolean { return obj1 === obj2 } + if (typeof obj2 !== "object" || obj2 === null) { + return obj2 === obj1 + } + const obj1Keys = Object.keys(obj1) const obj2Keys = Object.keys(obj2) diff --git a/packages/medusa/src/api/store/carts/query-config.ts b/packages/medusa/src/api/store/carts/query-config.ts index e3344c8e0f..59a18dfad9 100644 --- a/packages/medusa/src/api/store/carts/query-config.ts +++ b/packages/medusa/src/api/store/carts/query-config.ts @@ -45,6 +45,7 @@ export const defaultStoreCartFields = [ "items.variant_sku", "items.variant_barcode", "items.variant_title", + "items.metadata", "items.created_at", "items.updated_at", "items.title", diff --git a/packages/medusa/src/api/store/carts/validators.ts b/packages/medusa/src/api/store/carts/validators.ts index a1ed20983b..fed41058a8 100644 --- a/packages/medusa/src/api/store/carts/validators.ts +++ b/packages/medusa/src/api/store/carts/validators.ts @@ -8,6 +8,7 @@ export const StoreGetCartsCart = createSelectParams() const ItemSchema = z.object({ variant_id: z.string(), quantity: z.number(), + metadata: z.record(z.unknown()).optional().nullable(), }) export type StoreCreateCartType = z.infer @@ -62,7 +63,7 @@ export type StoreAddCartLineItemType = z.infer export const StoreAddCartLineItem = z.object({ variant_id: z.string(), quantity: z.number(), - metadata: z.record(z.unknown()).optional(), + metadata: z.record(z.unknown()).optional().nullable(), }) export type StoreUpdateCartLineItemType = z.infer< diff --git a/packages/modules/cart/src/services/cart-module.ts b/packages/modules/cart/src/services/cart-module.ts index fc728718d5..7dfb559004 100644 --- a/packages/modules/cart/src/services/cart-module.ts +++ b/packages/modules/cart/src/services/cart-module.ts @@ -37,7 +37,6 @@ import { CreateShippingMethodDTO, CreateShippingMethodTaxLineDTO, UpdateLineItemDTO, - UpdateLineItemTaxLineDTO, UpdateShippingMethodTaxLineDTO, } from "@types" import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" @@ -1034,7 +1033,7 @@ export default class CartModuleService< } const result = await this.lineItemTaxLineService_.upsert( - taxLines as UpdateLineItemTaxLineDTO[], + taxLines, sharedContext )