From 3ee0f599c1cac1930b42275d14cf736f06f5356b Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Tue, 27 Feb 2024 13:47:00 +0100 Subject: [PATCH] feat: Line Items API Routes (#6478) **What** - `POST /store/carts/:id/line-items` - `POST /store/carts/:id/line-items/:id` - `DELETE /store/carts/:id/line-items/:id` **Outstanding** - Integration tests - Module integrations: Payment, Fulfillment, Promotions Depends on #6475 and #6449. --- .../cart/store/cart.workflows.spec.ts | 261 ++++++++++++++++++ .../src/migrations/.snapshot-medusa-cart.json | 65 ++++- .../src/migrations/Migration20240222170223.ts | 7 + packages/cart/src/models/adjustment-line.ts | 11 +- packages/cart/src/models/cart.ts | 125 +++++---- .../cart/src/models/line-item-adjustment.ts | 53 ++-- .../cart/src/models/line-item-tax-line.ts | 53 ++-- packages/cart/src/models/line-item.ts | 69 +++-- .../src/models/shipping-method-adjustment.ts | 53 ++-- .../src/models/shipping-method-tax-line.ts | 53 ++-- packages/cart/src/models/shipping-method.ts | 50 ++-- packages/cart/src/models/tax-line.ts | 3 + packages/cart/src/services/cart-module.ts | 20 +- .../src/definition/cart/workflows/index.ts | 2 + .../workflows/update-line-item-in-cart.ts | 62 +++++ packages/core-flows/src/definition/index.ts | 2 + .../src/definition/line-item/index.ts | 2 + .../line-item/steps/delete-line-items.ts | 27 ++ .../src/definition/line-item/steps/index.ts | 3 + .../line-item/steps/list-line-items.ts | 27 ++ .../line-item/steps/update-line-items.ts | 59 ++++ .../line-item/workflows/delete-line-items.ts | 17 ++ .../definition/line-item/workflows/index.ts | 1 + .../carts/[id]/line-items/[line_id]/route.ts | 84 ++++++ .../[id]/line-items/[line_id]/validators.ts | 9 + .../src/api-v2/store/carts/middlewares.ts | 9 + packages/types/src/cart/common.ts | 4 + packages/types/src/cart/mutations.ts | 1 + packages/types/src/cart/workflows.ts | 9 +- .../internal-module-service-factory.ts | 4 +- 30 files changed, 937 insertions(+), 208 deletions(-) create mode 100644 packages/core-flows/src/definition/cart/workflows/update-line-item-in-cart.ts create mode 100644 packages/core-flows/src/definition/line-item/index.ts create mode 100644 packages/core-flows/src/definition/line-item/steps/delete-line-items.ts create mode 100644 packages/core-flows/src/definition/line-item/steps/index.ts create mode 100644 packages/core-flows/src/definition/line-item/steps/list-line-items.ts create mode 100644 packages/core-flows/src/definition/line-item/steps/update-line-items.ts create mode 100644 packages/core-flows/src/definition/line-item/workflows/delete-line-items.ts create mode 100644 packages/core-flows/src/definition/line-item/workflows/index.ts create mode 100644 packages/medusa/src/api-v2/store/carts/[id]/line-items/[line_id]/route.ts create mode 100644 packages/medusa/src/api-v2/store/carts/[id]/line-items/[line_id]/validators.ts diff --git a/integration-tests/plugins/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/plugins/__tests__/cart/store/cart.workflows.spec.ts index 408d076c09..021d1f9d94 100644 --- a/integration-tests/plugins/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/plugins/__tests__/cart/store/cart.workflows.spec.ts @@ -1,7 +1,12 @@ import { addToCartWorkflow, createCartWorkflow, + + deleteLineItemsStepId, + deleteLineItemsWorkflow, findOrCreateCustomerStepId, + updateLineItemInCartWorkflow, + updateLineItemsStepId, } from "@medusajs/core-flows" import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { @@ -409,4 +414,260 @@ describe("Carts workflows", () => { ]) }) }) + + describe("updateLineItemInCartWorkflow", () => { + it("should update item in cart", async () => { + const [product] = await productModule.create([ + { + title: "Test product", + variants: [ + { + title: "Test variant", + }, + ], + }, + ]) + + const priceSet = await pricingModule.create({ + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }) + + await remoteLink.create([ + { + productService: { + variant_id: product.variants[0].id, + }, + pricingService: { + price_set_id: priceSet.id, + }, + }, + ]) + + let cart = await cartModuleService.create({ + currency_code: "usd", + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + unit_price: 5000, + title: "Test item", + }, + ], + }) + + cart = await cartModuleService.retrieve(cart.id, { + select: ["id", "region_id", "currency_code"], + relations: ["items", "items.variant_id", "items.metadata"], + }) + + const item = cart.items?.[0]! + + await updateLineItemInCartWorkflow(appContainer).run({ + input: { + cart, + item, + update: { + metadata: { + foo: "bar", + }, + quantity: 2, + }, + }, + throwOnError: false, + }) + + const updatedItem = await cartModuleService.retrieveLineItem(item.id) + + expect(updatedItem).toEqual( + expect.objectContaining({ + id: item.id, + unit_price: 3000, + quantity: 2, + title: "Test item", + }) + ) + }) + + describe("compensation", () => { + it("should revert line item update to original state", async () => { + expect.assertions(2) + const workflow = updateLineItemInCartWorkflow(appContainer) + + workflow.appendAction("throw", updateLineItemsStepId, { + invoke: async function failStep() { + throw new Error(`Failed to update something after line items`) + }, + }) + + const [product] = await productModule.create([ + { + title: "Test product", + variants: [ + { + title: "Test variant", + }, + ], + }, + ]) + + let cart = await cartModuleService.create({ + currency_code: "usd", + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + unit_price: 3000, + title: "Test item", + }, + ], + }) + + const priceSet = await pricingModule.create({ + prices: [ + { + amount: 5000, + currency_code: "usd", + }, + ], + }) + + await remoteLink.create([ + { + productService: { + variant_id: product.variants[0].id, + }, + pricingService: { + price_set_id: priceSet.id, + }, + }, + ]) + + cart = await cartModuleService.retrieve(cart.id, { + select: ["id", "region_id", "currency_code"], + relations: ["items", "items.variant_id", "items.metadata"], + }) + + const item = cart.items?.[0]! + + const { errors } = await workflow.run({ + input: { + cart, + item, + update: { + metadata: { + foo: "bar", + }, + title: "Test item updated", + quantity: 2, + }, + }, + throwOnError: false, + }) + + expect(errors).toEqual([ + { + action: "throw", + handlerType: "invoke", + error: new Error(`Failed to update something after line items`), + }, + ]) + + const updatedItem = await cartModuleService.retrieveLineItem(item.id) + + expect(updatedItem).toEqual( + expect.objectContaining({ + id: item.id, + unit_price: 3000, + quantity: 1, + title: "Test item", + }) + ) + }) + }) + }) + + describe("deleteLineItems", () => { + it("should delete items in cart", async () => { + const cart = await cartModuleService.create({ + currency_code: "usd", + items: [ + { + quantity: 1, + unit_price: 5000, + title: "Test item", + }, + ], + }) + + const items = await cartModuleService.listLineItems({ cart_id: cart.id }) + + await deleteLineItemsWorkflow(appContainer).run({ + input: { + ids: items.map((i) => i.id), + }, + throwOnError: false, + }) + + const [deletedItem] = await cartModuleService.listLineItems({ + id: items.map((i) => i.id), + }) + + expect(deletedItem).toBeUndefined() + }) + + describe("compensation", () => { + it("should restore line item if delete fails", async () => { + const workflow = deleteLineItemsWorkflow(appContainer) + + workflow.appendAction("throw", deleteLineItemsStepId, { + invoke: async function failStep() { + throw new Error(`Failed to do something after deleting line items`) + }, + }) + + const cart = await cartModuleService.create({ + currency_code: "usd", + items: [ + { + quantity: 1, + unit_price: 3000, + title: "Test item", + }, + ], + }) + + const items = await cartModuleService.listLineItems({ + cart_id: cart.id, + }) + + const { errors } = await workflow.run({ + input: { + ids: items.map((i) => i.id), + }, + throwOnError: false, + }) + + expect(errors).toEqual([ + { + action: "throw", + handlerType: "invoke", + error: new Error( + `Failed to do something after deleting line items` + ), + }, + ]) + + const updatedItem = await cartModuleService.retrieveLineItem( + items[0].id + ) + + expect(updatedItem).not.toBeUndefined() + }) + }) + }) }) diff --git a/packages/cart/src/migrations/.snapshot-medusa-cart.json b/packages/cart/src/migrations/.snapshot-medusa-cart.json index d6d42245e3..3b0ca191cb 100644 --- a/packages/cart/src/migrations/.snapshot-medusa-cart.json +++ b/packages/cart/src/migrations/.snapshot-medusa-cart.json @@ -658,6 +658,15 @@ "nullable": false, "mappedType": "json" }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, "created_at": { "name": "created_at", "type": "timestamptz", @@ -798,6 +807,15 @@ "nullable": false, "mappedType": "decimal" }, + "raw_amount": { + "name": "raw_amount", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "json" + }, "provider_id": { "name": "provider_id", "type": "text", @@ -807,6 +825,15 @@ "nullable": true, "mappedType": "text" }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, "created_at": { "name": "created_at", "type": "timestamptz", @@ -957,6 +984,15 @@ "nullable": true, "mappedType": "text" }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, "created_at": { "name": "created_at", "type": "timestamptz", @@ -1283,6 +1319,15 @@ "nullable": false, "mappedType": "decimal" }, + "raw_amount": { + "name": "raw_amount", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "json" + }, "provider_id": { "name": "provider_id", "type": "text", @@ -1323,6 +1368,15 @@ "nullable": false, "mappedType": "text" }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, "promotion_id": { "name": "promotion_id", "type": "text", @@ -1436,6 +1490,15 @@ "nullable": true, "mappedType": "text" }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, "created_at": { "name": "created_at", "type": "timestamptz", @@ -1534,4 +1597,4 @@ "foreignKeys": {} } ] -} +} \ No newline at end of file diff --git a/packages/cart/src/migrations/Migration20240222170223.ts b/packages/cart/src/migrations/Migration20240222170223.ts index db2592f6f3..c404aec51e 100644 --- a/packages/cart/src/migrations/Migration20240222170223.ts +++ b/packages/cart/src/migrations/Migration20240222170223.ts @@ -85,6 +85,7 @@ export class Migration20240222170223 extends Migration { "raw_compare_at_unit_price" JSONB NULL, "unit_price" NUMERIC NOT NULL, "raw_unit_price" JSONB NOT NULL, + "metadata" JSONB NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "deleted_at" TIMESTAMPTZ NULL, @@ -106,7 +107,9 @@ export class Migration20240222170223 extends Migration { "promotion_id" TEXT NULL, "code" TEXT NULL, "amount" NUMERIC NOT NULL, + "raw_amount" JSONB NOT NULL, "provider_id" TEXT NULL, + "metadata" JSONB NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "deleted_at" TIMESTAMPTZ NULL, @@ -125,6 +128,7 @@ export class Migration20240222170223 extends Migration { "code" TEXT NOT NULL, "rate" NUMERIC NOT NULL, "provider_id" TEXT NULL, + "metadata" JSONB NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "deleted_at" TIMESTAMPTZ NULL, @@ -162,7 +166,9 @@ export class Migration20240222170223 extends Migration { "promotion_id" TEXT NULL, "code" TEXT NULL, "amount" NUMERIC NOT NULL, + "raw_amount" JSONB NOT NULL, "provider_id" TEXT NULL, + "metadata" JSONB NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "deleted_at" TIMESTAMPTZ NULL, @@ -180,6 +186,7 @@ export class Migration20240222170223 extends Migration { "code" TEXT NOT NULL, "rate" NUMERIC NOT NULL, "provider_id" TEXT NULL, + "metadata" JSONB NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "deleted_at" TIMESTAMPTZ NULL, diff --git a/packages/cart/src/models/adjustment-line.ts b/packages/cart/src/models/adjustment-line.ts index e3091f8531..33c85df8d9 100644 --- a/packages/cart/src/models/adjustment-line.ts +++ b/packages/cart/src/models/adjustment-line.ts @@ -1,4 +1,5 @@ import { DAL } from "@medusajs/types" +import { BigNumber, MikroOrmBigNumberProperty } from "@medusajs/utils" import { OptionalProps, PrimaryKey, Property } from "@mikro-orm/core" type OptionalAdjustmentLineProps = DAL.SoftDeletableEntityDateColumns @@ -19,12 +20,18 @@ export default abstract class AdjustmentLine { @Property({ columnType: "text", nullable: true }) code: string | null = null - @Property({ columnType: "numeric", serializer: Number }) - amount: number + @MikroOrmBigNumberProperty() + amount: BigNumber | number + + @Property({ columnType: "jsonb" }) + raw_amount: Record @Property({ columnType: "text", nullable: true }) provider_id: string | null = null + @Property({ columnType: "jsonb", nullable: true }) + metadata: Record | null = null + @Property({ onCreate: () => new Date(), columnType: "timestamptz", diff --git a/packages/cart/src/models/cart.ts b/packages/cart/src/models/cart.ts index ba9942dfe2..f9ace5b607 100644 --- a/packages/cart/src/models/cart.ts +++ b/packages/cart/src/models/cart.ts @@ -26,6 +26,54 @@ type OptionalCartProps = | "billing_address" | DAL.SoftDeletableEntityDateColumns +const RegionIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_cart_region_id", + tableName: "cart", + columns: "region_id", + where: "deleted_at IS NULL AND region_id IS NOT NULL", +}).MikroORMIndex + +const CustomerIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_cart_customer_id", + tableName: "cart", + columns: "customer_id", + where: "deleted_at IS NULL AND customer_id IS NOT NULL", +}).MikroORMIndex + +const SalesChannelIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_cart_sales_channel_id", + tableName: "cart", + columns: "sales_channel_id", + where: "deleted_at IS NULL AND sales_channel_id IS NOT NULL", +}).MikroORMIndex + +const CurrencyCodeIndex = createPsqlIndexStatementHelper({ + name: "IDX_cart_curency_code", + tableName: "cart", + columns: "currency_code", + where: "deleted_at IS NULL", +}).MikroORMIndex + +const ShippingAddressIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_cart_shipping_address_id", + tableName: "cart", + columns: "shipping_address_id", + where: "deleted_at IS NULL AND shipping_address_id IS NOT NULL", +}).MikroORMIndex + +const BillingAddressIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_cart_billing_address_id", + tableName: "cart", + columns: "billing_address_id", + where: "deleted_at IS NULL AND billing_address_id IS NOT NULL", +}).MikroORMIndex + +const DeletedAtIndex = createPsqlIndexStatementHelper({ + tableName: "cart", + columns: "deleted_at", + where: "deleted_at IS NOT NULL", +}).MikroORMIndex + @Entity({ tableName: "cart" }) @Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) export default class Cart { @@ -34,77 +82,56 @@ export default class Cart { @PrimaryKey({ columnType: "text" }) id: string - @createPsqlIndexStatementHelper({ - name: "IDX_cart_region_id", - tableName: "cart", - columns: "region_id", - where: "deleted_at IS NULL AND region_id IS NOT NULL", - }).MikroORMIndex() + @RegionIdIndex() @Property({ columnType: "text", nullable: true }) region_id: string | null = null - @createPsqlIndexStatementHelper({ - name: "IDX_cart_customer_id", - tableName: "cart", - columns: "customer_id", - where: "deleted_at IS NULL AND customer_id IS NOT NULL", - }).MikroORMIndex() + @CustomerIdIndex() @Property({ columnType: "text", nullable: true }) customer_id: string | null = null - @createPsqlIndexStatementHelper({ - name: "IDX_cart_sales_channel_id", - tableName: "cart", - columns: "sales_channel_id", - where: "deleted_at IS NULL AND sales_channel_id IS NOT NULL", - }).MikroORMIndex() - @Property({ - columnType: "text", - nullable: true, - index: "IDX_cart_sales_channel_id", - }) + @SalesChannelIdIndex() + @Property({ columnType: "text", nullable: true }) sales_channel_id: string | null = null @Property({ columnType: "text", nullable: true }) email: string | null = null - @Property({ columnType: "text", index: "IDX_cart_curency_code" }) + @CurrencyCodeIndex() + @Property({ columnType: "text" }) currency_code: string - @createPsqlIndexStatementHelper({ - name: "IDX_cart_shipping_address_id", - tableName: "cart", - columns: "shipping_address_id", - where: "deleted_at IS NULL AND shipping_address_id IS NOT NULL", - }).MikroORMIndex() - @Property({ columnType: "text", nullable: true }) - shipping_address_id?: string | null - + @ShippingAddressIdIndex() @ManyToOne({ entity: () => Address, + columnType: "text", fieldName: "shipping_address_id", + mapToPk: true, nullable: true, - cascade: [Cascade.PERSIST], }) - shipping_address?: Address | null + shipping_address_id: string | null - @createPsqlIndexStatementHelper({ - name: "IDX_cart_billing_address_id", - tableName: "cart", - columns: "billing_address_id", - where: "deleted_at IS NULL AND billing_address_id IS NOT NULL", - }).MikroORMIndex() - @Property({ columnType: "text", nullable: true }) - billing_address_id?: string | null + @ManyToOne(() => Address, { + cascade: [Cascade.PERSIST], + nullable: true, + }) + shipping_address: Address | null + @BillingAddressIdIndex() @ManyToOne({ entity: () => Address, + columnType: "text", fieldName: "billing_address_id", + mapToPk: true, nullable: true, - index: "IDX_cart_billing_address_id", - cascade: [Cascade.PERSIST], }) - billing_address?: Address | null + billing_address_id: string | null + + @ManyToOne(() => Address, { + cascade: [Cascade.PERSIST], + nullable: true, + }) + billing_address: Address | null @Property({ columnType: "jsonb", nullable: true }) metadata: Record | null = null @@ -134,11 +161,7 @@ export default class Cart { }) updated_at: Date - @createPsqlIndexStatementHelper({ - tableName: "cart", - columns: "deleted_at", - where: "deleted_at IS NOT NULL", - }).MikroORMIndex() + @DeletedAtIndex() @Property({ columnType: "timestamptz", nullable: true }) deleted_at: Date | null = null diff --git a/packages/cart/src/models/line-item-adjustment.ts b/packages/cart/src/models/line-item-adjustment.ts index d3944b3192..a7c121490e 100644 --- a/packages/cart/src/models/line-item-adjustment.ts +++ b/packages/cart/src/models/line-item-adjustment.ts @@ -5,7 +5,6 @@ import { } from "@medusajs/utils" import { BeforeCreate, - Cascade, Check, Entity, Filter, @@ -16,41 +15,49 @@ import { import AdjustmentLine from "./adjustment-line" import LineItem from "./line-item" +const LineItemIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_adjustment_item_id", + tableName: "cart_line_item_adjustment", + columns: "item_id", + where: "deleted_at IS NULL", +}).MikroORMIndex + +const PromotionIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_line_item_adjustment_promotion_id", + tableName: "cart_line_item_adjustment", + columns: "promotion_id", + where: "deleted_at IS NULL AND promotion_id IS NOT NULL", +}).MikroORMIndex + +const DeletedAtIndex = createPsqlIndexStatementHelper({ + tableName: "cart_line_item_adjustment", + columns: "deleted_at", + where: "deleted_at IS NOT NULL", +}).MikroORMIndex + @Entity({ tableName: "cart_line_item_adjustment" }) @Check({ expression: (columns) => `${columns.amount} >= 0`, }) @Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) export default class LineItemAdjustment extends AdjustmentLine { - @ManyToOne({ - entity: () => LineItem, - cascade: [Cascade.REMOVE, Cascade.PERSIST], - }) + @ManyToOne({ entity: () => LineItem, persist: false }) item: LineItem - @createPsqlIndexStatementHelper({ - name: "IDX_adjustment_item_id", - tableName: "cart_line_item_adjustment", - columns: "item_id", - where: "deleted_at IS NULL", - }).MikroORMIndex() - @Property({ columnType: "text" }) + @LineItemIdIndex() + @ManyToOne({ + entity: () => LineItem, + columnType: "text", + fieldName: "item_id", + mapToPk: true, + }) item_id: string - @createPsqlIndexStatementHelper({ - name: "IDX_line_item_adjustment_promotion_id", - tableName: "cart_line_item_adjustment", - columns: "promotion_id", - where: "deleted_at IS NULL and promotion_id IS NOT NULL", - }).MikroORMIndex() + @PromotionIdIndex() @Property({ columnType: "text", nullable: true }) promotion_id: string | null = null - @createPsqlIndexStatementHelper({ - tableName: "cart_line_item_adjustment", - columns: "deleted_at", - where: "deleted_at IS NOT NULL", - }).MikroORMIndex() + @DeletedAtIndex() @Property({ columnType: "timestamptz", nullable: true }) deleted_at: Date | null = null diff --git a/packages/cart/src/models/line-item-tax-line.ts b/packages/cart/src/models/line-item-tax-line.ts index 1b3ceffa21..173dc05238 100644 --- a/packages/cart/src/models/line-item-tax-line.ts +++ b/packages/cart/src/models/line-item-tax-line.ts @@ -5,7 +5,6 @@ import { } from "@medusajs/utils" import { BeforeCreate, - Cascade, Entity, Filter, ManyToOne, @@ -15,38 +14,46 @@ import { import LineItem from "./line-item" import TaxLine from "./tax-line" +const LineItemIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_tax_line_item_id", + tableName: "cart_line_item_tax_line", + columns: "item_id", + where: "deleted_at IS NULL", +}).MikroORMIndex + +const TaxRateIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_line_item_tax_line_tax_rate_id", + tableName: "cart_line_item_tax_line", + columns: "tax_rate_id", + where: "deleted_at IS NULL AND tax_rate_id IS NOT NULL", +}).MikroORMIndex + +const DeletedAtIndex = createPsqlIndexStatementHelper({ + tableName: "cart_line_item_tax_line", + columns: "deleted_at", + where: "deleted_at IS NOT NULL", +}).MikroORMIndex + @Entity({ tableName: "cart_line_item_tax_line" }) @Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) export default class LineItemTaxLine extends TaxLine { - @ManyToOne({ - entity: () => LineItem, - cascade: [Cascade.REMOVE, Cascade.PERSIST, "soft-remove"] as any, - }) + @ManyToOne({ entity: () => LineItem, persist: false }) item: LineItem - @createPsqlIndexStatementHelper({ - name: "IDX_tax_line_item_id", - tableName: "cart_line_item_tax_line", - columns: "item_id", - where: "deleted_at IS NULL", - }).MikroORMIndex() - @Property({ columnType: "text" }) + @LineItemIdIndex() + @ManyToOne({ + entity: () => LineItem, + columnType: "text", + fieldName: "item_id", + mapToPk: true, + }) item_id: string - @createPsqlIndexStatementHelper({ - name: "IDX_line_item_tax_line_tax_rate_id", - tableName: "cart_line_item_tax_line", - columns: "tax_rate_id", - where: "deleted_at IS NULL AND tax_rate_id IS NOT NULL", - }).MikroORMIndex() + @TaxRateIdIndex() @Property({ columnType: "text", nullable: true }) tax_rate_id: string | null = null - @createPsqlIndexStatementHelper({ - tableName: "cart_line_item_tax_line", - columns: "deleted_at", - where: "deleted_at IS NOT NULL", - }).MikroORMIndex() + @DeletedAtIndex() @Property({ columnType: "timestamptz", nullable: true }) deleted_at: Date | null = null diff --git a/packages/cart/src/models/line-item.ts b/packages/cart/src/models/line-item.ts index 7c27e6a064..3282db8ae7 100644 --- a/packages/cart/src/models/line-item.ts +++ b/packages/cart/src/models/line-item.ts @@ -17,7 +17,7 @@ import { OneToMany, OptionalProps, PrimaryKey, - Property, + Property } from "@mikro-orm/core" import Cart from "./cart" import LineItemAdjustment from "./line-item-adjustment" @@ -31,6 +31,33 @@ type OptionalLineItemProps = | "cart" | DAL.SoftDeletableEntityDateColumns +const CartIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_line_item_cart_id", + tableName: "cart_line_item", + columns: "cart_id", + where: "deleted_at IS NULL", +}).MikroORMIndex + +const VariantIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_line_item_variant_id", + tableName: "cart_line_item", + columns: "variant_id", + where: "deleted_at IS NULL AND variant_id IS NOT NULL", +}).MikroORMIndex + +const ProductIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_line_item_product_id", + tableName: "cart_line_item", + columns: "product_id", + where: "deleted_at IS NULL AND product_id IS NOT NULL", +}).MikroORMIndex + +const DeletedAtIndex = createPsqlIndexStatementHelper({ + tableName: "cart_line_item", + columns: "deleted_at", + where: "deleted_at IS NOT NULL", +}).MikroORMIndex + @Entity({ tableName: "cart_line_item" }) @Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) export default class LineItem { @@ -39,19 +66,16 @@ export default class LineItem { @PrimaryKey({ columnType: "text" }) id: string - @createPsqlIndexStatementHelper({ - name: "IDX_line_item_cart_id", - tableName: "cart_line_item", - columns: "cart_id", - where: "deleted_at IS NULL", - }).MikroORMIndex() - @Property({ columnType: "text" }) - cart_id: string - + @CartIdIndex() @ManyToOne({ entity: () => Cart, - cascade: [Cascade.REMOVE, Cascade.PERSIST, "soft-remove"] as any, + columnType: "text", + fieldName: "cart_id", + mapToPk: true, }) + cart_id: string + + @ManyToOne({ entity: () => Cart, persist: false }) cart: Cart @Property({ columnType: "text" }) @@ -66,21 +90,11 @@ export default class LineItem { @Property({ columnType: "integer" }) quantity: number - @createPsqlIndexStatementHelper({ - name: "IDX_line_item_variant_id", - tableName: "cart_line_item", - columns: "variant_id", - where: "deleted_at IS NULL AND variant_id IS NOT NULL", - }).MikroORMIndex() + @VariantIdIndex() @Property({ columnType: "text", nullable: true }) variant_id: string | null = null - @createPsqlIndexStatementHelper({ - name: "IDX_line_item_product_id", - tableName: "cart_line_item", - columns: "product_id", - where: "deleted_at IS NULL AND product_id IS NOT NULL", - }).MikroORMIndex() + @ProductIdIndex() @Property({ columnType: "text", nullable: true }) product_id: string | null = null @@ -145,6 +159,9 @@ export default class LineItem { }) adjustments = new Collection(this) + @Property({ columnType: "jsonb", nullable: true }) + metadata: Record | null = null + @Property({ onCreate: () => new Date(), columnType: "timestamptz", @@ -160,11 +177,7 @@ export default class LineItem { }) updated_at: Date - @createPsqlIndexStatementHelper({ - tableName: "cart_line_item", - columns: "deleted_at", - where: "deleted_at IS NOT NULL", - }).MikroORMIndex() + @DeletedAtIndex() @Property({ columnType: "timestamptz", nullable: true }) deleted_at: Date | null = null diff --git a/packages/cart/src/models/shipping-method-adjustment.ts b/packages/cart/src/models/shipping-method-adjustment.ts index f47c347d03..405d0baa4a 100644 --- a/packages/cart/src/models/shipping-method-adjustment.ts +++ b/packages/cart/src/models/shipping-method-adjustment.ts @@ -5,7 +5,6 @@ import { } from "@medusajs/utils" import { BeforeCreate, - Cascade, Entity, Filter, ManyToOne, @@ -15,38 +14,46 @@ import { import AdjustmentLine from "./adjustment-line" import ShippingMethod from "./shipping-method" +const ShippingMethodIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_adjustment_shipping_method_id", + tableName: "cart_shipping_method_adjustment", + columns: "shipping_method_id", + where: "deleted_at IS NULL", +}).MikroORMIndex + +const PromotionIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_shipping_method_adjustment_promotion_id", + tableName: "cart_shipping_method_adjustment", + columns: "promotion_id", + where: "deleted_at IS NULL AND promotion_id IS NOT NULL", +}).MikroORMIndex + +const DeletedAtIndex = createPsqlIndexStatementHelper({ + tableName: "cart_shipping_method_adjustment", + columns: "deleted_at", + where: "deleted_at IS NOT NULL", +}).MikroORMIndex + @Entity({ tableName: "cart_shipping_method_adjustment" }) @Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) export default class ShippingMethodAdjustment extends AdjustmentLine { - @ManyToOne({ - entity: () => ShippingMethod, - cascade: [Cascade.REMOVE, Cascade.PERSIST], - }) + @ManyToOne({ entity: () => ShippingMethod, persist: false }) shipping_method: ShippingMethod - @createPsqlIndexStatementHelper({ - name: "IDX_adjustment_shipping_method_id", - tableName: "cart_shipping_method_adjustment", - columns: "shipping_method_id", - where: "deleted_at IS NULL", - }).MikroORMIndex() - @Property({ columnType: "text" }) + @ShippingMethodIdIndex() + @ManyToOne({ + entity: () => ShippingMethod, + columnType: "text", + fieldName: "shipping_method_id", + mapToPk: true, + }) shipping_method_id: string - @createPsqlIndexStatementHelper({ - name: "IDX_shipping_method_adjustment_promotion_id", - tableName: "cart_shipping_method_adjustment", - columns: "promotion_id", - where: "deleted_at IS NULL and promotion_id IS NOT NULL", - }).MikroORMIndex() + @PromotionIdIndex() @Property({ columnType: "text", nullable: true }) promotion_id: string | null = null - @createPsqlIndexStatementHelper({ - tableName: "cart_shipping_method_adjustment", - columns: "deleted_at", - where: "deleted_at IS NOT NULL", - }).MikroORMIndex() + @DeletedAtIndex() @Property({ columnType: "timestamptz", nullable: true }) deleted_at: Date | null = null diff --git a/packages/cart/src/models/shipping-method-tax-line.ts b/packages/cart/src/models/shipping-method-tax-line.ts index ad4c30b9a9..9d1d9ec88e 100644 --- a/packages/cart/src/models/shipping-method-tax-line.ts +++ b/packages/cart/src/models/shipping-method-tax-line.ts @@ -5,7 +5,6 @@ import { } from "@medusajs/utils" import { BeforeCreate, - Cascade, Entity, Filter, ManyToOne, @@ -15,38 +14,46 @@ import { import ShippingMethod from "./shipping-method" import TaxLine from "./tax-line" +const ShippingMethodIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_tax_line_shipping_method_id", + tableName: "cart_shipping_method_tax_line", + columns: "shipping_method_id", + where: "deleted_at IS NULL", +}).MikroORMIndex + +const TaxRateIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_shipping_method_tax_line_tax_rate_id", + tableName: "cart_shipping_method_tax_line", + columns: "tax_rate_id", + where: "deleted_at IS NULL AND tax_rate_id IS NOT NULL", +}).MikroORMIndex + +const DeletedAtIndex = createPsqlIndexStatementHelper({ + tableName: "cart_shipping_method_tax_line", + columns: "deleted_at", + where: "deleted_at IS NOT NULL", +}).MikroORMIndex + @Entity({ tableName: "cart_shipping_method_tax_line" }) @Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) export default class ShippingMethodTaxLine extends TaxLine { - @ManyToOne({ - entity: () => ShippingMethod, - cascade: [Cascade.REMOVE, Cascade.PERSIST, "soft-remove"] as any, - }) + @ManyToOne({ entity: () => ShippingMethod, persist: false }) shipping_method: ShippingMethod - @createPsqlIndexStatementHelper({ - name: "IDX_tax_line_shipping_method_id", - tableName: "cart_shipping_method_tax_line", - columns: "shipping_method_id", - where: "deleted_at IS NULL", - }).MikroORMIndex() - @Property({ columnType: "text" }) + @ShippingMethodIdIndex() + @ManyToOne({ + entity: () => ShippingMethod, + columnType: "text", + fieldName: "shipping_method_id", + mapToPk: true, + }) shipping_method_id: string - @createPsqlIndexStatementHelper({ - name: "IDX_shipping_method_tax_line_tax_rate_id", - tableName: "cart_shipping_method_tax_line", - columns: "tax_rate_id", - where: "deleted_at IS NULL AND tax_rate_id IS NOT NULL", - }).MikroORMIndex() + @TaxRateIdIndex() @Property({ columnType: "text", nullable: true }) tax_rate_id: string | null = null - @createPsqlIndexStatementHelper({ - tableName: "cart_shipping_method_tax_line", - columns: "deleted_at", - where: "deleted_at IS NOT NULL", - }).MikroORMIndex() + @DeletedAtIndex() @Property({ columnType: "timestamptz", nullable: true }) deleted_at: Date | null = null diff --git a/packages/cart/src/models/shipping-method.ts b/packages/cart/src/models/shipping-method.ts index c3e59e4324..92bcd626ab 100644 --- a/packages/cart/src/models/shipping-method.ts +++ b/packages/cart/src/models/shipping-method.ts @@ -29,6 +29,26 @@ type OptionalShippingMethodProps = | "is_tax_inclusive" | DAL.SoftDeletableEntityDateColumns +const CartIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_shipping_method_cart_id", + tableName: "cart_shipping_method", + columns: "cart_id", + where: "deleted_at IS NULL", +}).MikroORMIndex + +const ShippingOptionIdIndex = createPsqlIndexStatementHelper({ + name: "IDX_shipping_method_option_id", + tableName: "cart_shipping_method", + columns: "shipping_option_id", + where: "deleted_at IS NULL AND shipping_option_id IS NOT NULL", +}).MikroORMIndex + +const DeletedAtIndex = createPsqlIndexStatementHelper({ + tableName: "cart_shipping_method", + columns: "deleted_at", + where: "deleted_at IS NOT NULL", +}).MikroORMIndex + @Entity({ tableName: "cart_shipping_method" }) @Check({ expression: (columns) => `${columns.amount} >= 0` }) @Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) @@ -38,19 +58,16 @@ export default class ShippingMethod { @PrimaryKey({ columnType: "text" }) id: string - @createPsqlIndexStatementHelper({ - name: "IDX_shipping_method_cart_id", - tableName: "cart_shipping_method", - columns: "cart_id", - where: "deleted_at IS NULL", - }).MikroORMIndex() - @Property({ columnType: "text" }) - cart_id: string - + @CartIdIndex() @ManyToOne({ entity: () => Cart, - cascade: [Cascade.REMOVE, Cascade.PERSIST], + columnType: "text", + fieldName: "cart_id", + mapToPk: true, }) + cart_id: string + + @ManyToOne({ entity: () => Cart, persist: false }) cart: Cart @Property({ columnType: "text" }) @@ -68,12 +85,7 @@ export default class ShippingMethod { @Property({ columnType: "boolean" }) is_tax_inclusive = false - @createPsqlIndexStatementHelper({ - name: "IDX_shipping_method_option_id", - tableName: "cart_shipping_method", - columns: "shipping_option_id", - where: "deleted_at IS NULL AND shipping_option_id IS NOT NULL", - }).MikroORMIndex() + @ShippingOptionIdIndex() @Property({ columnType: "text", nullable: true }) shipping_option_id: string | null = null @@ -116,11 +128,7 @@ export default class ShippingMethod { }) updated_at: Date - @createPsqlIndexStatementHelper({ - tableName: "cart_shipping_method", - columns: "deleted_at", - where: "deleted_at IS NOT NULL", - }).MikroORMIndex() + @DeletedAtIndex() @Property({ columnType: "timestamptz", nullable: true }) deleted_at: Date | null = null diff --git a/packages/cart/src/models/tax-line.ts b/packages/cart/src/models/tax-line.ts index 149634d8ce..2dbddd6746 100644 --- a/packages/cart/src/models/tax-line.ts +++ b/packages/cart/src/models/tax-line.ts @@ -25,6 +25,9 @@ export default abstract class TaxLine { @Property({ columnType: "text", nullable: true }) provider_id?: string | null + @Property({ columnType: "jsonb", nullable: true }) + metadata: Record | null = null + @Property({ onCreate: () => new Date(), columnType: "timestamptz", diff --git a/packages/cart/src/services/cart-module.ts b/packages/cart/src/services/cart-module.ts index 099613e1a3..9ad39eed45 100644 --- a/packages/cart/src/services/cart-module.ts +++ b/packages/cart/src/services/cart-module.ts @@ -450,7 +450,7 @@ export default class CartModuleService< : [itemIdsOrSelector] } - await this.lineItemService_.delete(toDelete, sharedContext) + await this.lineItemService_.softDelete(toDelete, sharedContext) } async createAddresses( @@ -636,7 +636,7 @@ export default class CartModuleService< ? methodIdsOrSelector : [methodIdsOrSelector] } - await this.shippingMethodService_.delete(toDelete, sharedContext) + await this.shippingMethodService_.softDelete(toDelete, sharedContext) } async addLineItemAdjustments( @@ -736,7 +736,7 @@ export default class CartModuleService< }) if (toDelete.length) { - await this.lineItemAdjustmentService_.delete( + await this.lineItemAdjustmentService_.softDelete( toDelete.map((adj) => adj!.id), sharedContext ) @@ -791,7 +791,7 @@ export default class CartModuleService< : [adjustmentIdsOrSelector] } - await this.lineItemAdjustmentService_.delete(ids, sharedContext) + await this.lineItemAdjustmentService_.softDelete(ids, sharedContext) } @InjectTransactionManager("baseRepository_") @@ -833,7 +833,7 @@ export default class CartModuleService< ) if (toDelete.length) { - await this.shippingMethodAdjustmentService_.delete( + await this.shippingMethodAdjustmentService_.softDelete( toDelete.map((adj) => adj!.id), sharedContext ) @@ -960,7 +960,7 @@ export default class CartModuleService< : [adjustmentIdsOrSelector] } - await this.shippingMethodAdjustmentService_.delete(ids, sharedContext) + await this.shippingMethodAdjustmentService_.softDelete(ids, sharedContext) } addLineItemTaxLines( @@ -1058,7 +1058,7 @@ export default class CartModuleService< }) if (toDelete.length) { - await this.lineItemTaxLineService_.delete( + await this.lineItemTaxLineService_.softDelete( toDelete.map((taxLine) => taxLine!.id), sharedContext ) @@ -1114,7 +1114,7 @@ export default class CartModuleService< : [taxLineIdsOrSelector] } - await this.lineItemTaxLineService_.delete(ids, sharedContext) + await this.lineItemTaxLineService_.softDelete(ids, sharedContext) } addShippingMethodTaxLines( @@ -1216,7 +1216,7 @@ export default class CartModuleService< }) if (toDelete.length) { - await this.shippingMethodTaxLineService_.delete( + await this.shippingMethodTaxLineService_.softDelete( toDelete.map((taxLine) => taxLine!.id), sharedContext ) @@ -1271,6 +1271,6 @@ export default class CartModuleService< : [taxLineIdsOrSelector] } - await this.shippingMethodTaxLineService_.delete(ids, sharedContext) + await this.shippingMethodTaxLineService_.softDelete(ids, sharedContext) } } diff --git a/packages/core-flows/src/definition/cart/workflows/index.ts b/packages/core-flows/src/definition/cart/workflows/index.ts index f39530663d..5092090483 100644 --- a/packages/core-flows/src/definition/cart/workflows/index.ts +++ b/packages/core-flows/src/definition/cart/workflows/index.ts @@ -2,3 +2,5 @@ export * from "./add-to-cart" export * from "./create-carts" export * from "./update-cart" export * from "./update-cart-promotions" +export * from "./update-line-item-in-cart" + diff --git a/packages/core-flows/src/definition/cart/workflows/update-line-item-in-cart.ts b/packages/core-flows/src/definition/cart/workflows/update-line-item-in-cart.ts new file mode 100644 index 0000000000..d5b44fe23c --- /dev/null +++ b/packages/core-flows/src/definition/cart/workflows/update-line-item-in-cart.ts @@ -0,0 +1,62 @@ +import { UpdateLineItemInCartWorkflowInputDTO } from "@medusajs/types" +import { + WorkflowData, + createWorkflow, + transform, +} from "@medusajs/workflows-sdk" +import { getVariantPriceSetsStep } from ".." +import { updateLineItemsStep } from "../../line-item/steps" + +// TODO: The UpdateLineItemsWorkflow are missing the following steps: +// - Confirm inventory exists (inventory module) +// - Validate shipping methods for new items (fulfillment module) +// - Refresh line item adjustments (promotion module) +// - Update payment sessions (payment module) + +export const updateLineItemInCartWorkflowId = "update-line-item-in-cart" +export const updateLineItemInCartWorkflow = createWorkflow( + updateLineItemInCartWorkflowId, + (input: WorkflowData) => { + const item = transform({ input }, (data) => data.input.item) + + const pricingContext = transform({ cart: input.cart, item }, (data) => { + return { + currency_code: data.cart.currency_code, + region_id: data.cart.region_id, + customer_id: data.cart.customer_id, + } + }) + + const variantIds = transform({ input }, (data) => [ + data.input.item.variant_id!, + ]) + + const priceSets = getVariantPriceSetsStep({ + variantIds, + context: pricingContext, + }) + + const lineItemUpdate = transform({ input, priceSets, item }, (data) => { + const price = data.priceSets[data.item.variant_id!].calculated_amount + + return { + data: { + ...data.input.update, + unit_price: price, + }, + selector: { + id: data.input.item.id, + }, + } + }) + + const result = updateLineItemsStep({ + data: lineItemUpdate.data, + selector: lineItemUpdate.selector, + }) + + const updatedItem = transform({ result }, (data) => data.result?.[0]) + + return updatedItem + } +) diff --git a/packages/core-flows/src/definition/index.ts b/packages/core-flows/src/definition/index.ts index 9200339f2b..fffeab915d 100644 --- a/packages/core-flows/src/definition/index.ts +++ b/packages/core-flows/src/definition/index.ts @@ -1,4 +1,6 @@ export * from "./cart" export * from "./inventory" +export * from "./line-item" export * from "./price-list" export * from "./product" + diff --git a/packages/core-flows/src/definition/line-item/index.ts b/packages/core-flows/src/definition/line-item/index.ts new file mode 100644 index 0000000000..68de82c9f9 --- /dev/null +++ b/packages/core-flows/src/definition/line-item/index.ts @@ -0,0 +1,2 @@ +export * from "./steps" +export * from "./workflows" diff --git a/packages/core-flows/src/definition/line-item/steps/delete-line-items.ts b/packages/core-flows/src/definition/line-item/steps/delete-line-items.ts new file mode 100644 index 0000000000..66ea68c338 --- /dev/null +++ b/packages/core-flows/src/definition/line-item/steps/delete-line-items.ts @@ -0,0 +1,27 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICartModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const deleteLineItemsStepId = "delete-line-items" +export const deleteLineItemsStep = createStep( + deleteLineItemsStepId, + async (ids: string[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.CART + ) + + await service.removeLineItems(ids) + + return new StepResponse(void 0, ids) + }, + async (ids, { container }) => { + if (!ids?.length) { + return + } + const service = container.resolve( + ModuleRegistrationName.CART + ) + + await service.restoreLineItems(ids) + } +) diff --git a/packages/core-flows/src/definition/line-item/steps/index.ts b/packages/core-flows/src/definition/line-item/steps/index.ts new file mode 100644 index 0000000000..dd2789467f --- /dev/null +++ b/packages/core-flows/src/definition/line-item/steps/index.ts @@ -0,0 +1,3 @@ +export * from "./delete-line-items" +export * from "./list-line-items" +export * from "./update-line-items" diff --git a/packages/core-flows/src/definition/line-item/steps/list-line-items.ts b/packages/core-flows/src/definition/line-item/steps/list-line-items.ts new file mode 100644 index 0000000000..e2675e505d --- /dev/null +++ b/packages/core-flows/src/definition/line-item/steps/list-line-items.ts @@ -0,0 +1,27 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + CartLineItemDTO, + FilterableLineItemProps, + FindConfig, + ICartModuleService, +} from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +interface StepInput { + filters: FilterableLineItemProps + config?: FindConfig +} + +export const listLineItemsStepId = "list-line-items" +export const listLineItemsStep = createStep( + listLineItemsStepId, + async (data: StepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.CART + ) + + const items = await service.listLineItems(data.filters, data.config) + + return new StepResponse(items) + } +) diff --git a/packages/core-flows/src/definition/line-item/steps/update-line-items.ts b/packages/core-flows/src/definition/line-item/steps/update-line-items.ts new file mode 100644 index 0000000000..b107e9056d --- /dev/null +++ b/packages/core-flows/src/definition/line-item/steps/update-line-items.ts @@ -0,0 +1,59 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + ICartModuleService, + UpdateLineItemWithSelectorDTO, +} from "@medusajs/types" +import { + getSelectsAndRelationsFromObjectArray, + promiseAll, + removeUndefined, +} from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const updateLineItemsStepId = "update-line-items" +export const updateLineItemsStep = createStep( + updateLineItemsStepId, + async (input: UpdateLineItemWithSelectorDTO, { container }) => { + const service = container.resolve( + ModuleRegistrationName.CART + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ + input.data, + ]) + + const itemsBefore = await service.listLineItems(input.selector, { + select: selects, + relations, + }) + + const items = await service.updateLineItems(input.selector, input.data) + + return new StepResponse(items, itemsBefore) + }, + async (itemsBefore, { container }) => { + if (!itemsBefore) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CART + ) + + await promiseAll( + itemsBefore.map(async (i) => + service.updateLineItems( + i.id, + removeUndefined({ + quantity: i.quantity, + title: i.title, + metadata: i.metadata, + unit_price: i.unit_price, + tax_lines: i.tax_lines, + adjustments: i.adjustments, + }) + ) + ) + ) + } +) diff --git a/packages/core-flows/src/definition/line-item/workflows/delete-line-items.ts b/packages/core-flows/src/definition/line-item/workflows/delete-line-items.ts new file mode 100644 index 0000000000..5065dd588a --- /dev/null +++ b/packages/core-flows/src/definition/line-item/workflows/delete-line-items.ts @@ -0,0 +1,17 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { deleteLineItemsStep } from "../steps/delete-line-items" + +type WorkflowInput = { ids: string[] } + +// TODO: The DeleteLineItemsWorkflow are missing the following steps: +// - Refresh/delete shipping methods (fulfillment module) +// - Refresh line item adjustments (promotion module) +// - Update payment sessions (payment module) + +export const deleteLineItemsWorkflowId = "delete-line-items" +export const deleteLineItemsWorkflow = createWorkflow( + deleteLineItemsWorkflowId, + (input: WorkflowData) => { + return deleteLineItemsStep(input.ids) + } +) diff --git a/packages/core-flows/src/definition/line-item/workflows/index.ts b/packages/core-flows/src/definition/line-item/workflows/index.ts new file mode 100644 index 0000000000..00c487a47a --- /dev/null +++ b/packages/core-flows/src/definition/line-item/workflows/index.ts @@ -0,0 +1 @@ +export * from "./delete-line-items" diff --git a/packages/medusa/src/api-v2/store/carts/[id]/line-items/[line_id]/route.ts b/packages/medusa/src/api-v2/store/carts/[id]/line-items/[line_id]/route.ts new file mode 100644 index 0000000000..8389f115be --- /dev/null +++ b/packages/medusa/src/api-v2/store/carts/[id]/line-items/[line_id]/route.ts @@ -0,0 +1,84 @@ +import { + deleteLineItemsWorkflow, + updateLineItemInCartWorkflow, +} from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICartModuleService } from "@medusajs/types" +import { MedusaError, remoteQueryObjectFromString } from "@medusajs/utils" +import { MedusaRequest, MedusaResponse } from "../../../../../../types/routing" +import { defaultStoreCartFields } from "../../../query-config" +import { StorePostCartsCartLineItemsItemReq } from "./validators" + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const cartModuleService = req.scope.resolve( + ModuleRegistrationName.CART + ) + + const cart = await cartModuleService.retrieve(req.params.id, { + select: ["id", "region_id", "currency_code"], + relations: ["region", "items", "items.variant_id"], + }) + + const item = cart.items?.find((i) => i.id === req.params.line_id) + + if (!item) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Line item with id: ${req.params.line_id} was not found` + ) + } + + const input = { + cart, + item, + update: req.validatedBody as StorePostCartsCartLineItemsItemReq, + } + + const { errors } = await updateLineItemInCartWorkflow(req.scope).run({ + input, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const remoteQuery = req.scope.resolve("remoteQuery") + + const query = remoteQueryObjectFromString({ + entryPoint: "cart", + fields: defaultStoreCartFields, + }) + + const [updatedCart] = await remoteQuery(query, { + cart: { id: req.params.id }, + }) + + res.status(200).json({ cart: updatedCart }) +} + +export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => { + const id = req.params.line_id + + const { errors } = await deleteLineItemsWorkflow(req.scope).run({ + input: { ids: [id] }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const remoteQuery = req.scope.resolve("remoteQuery") + + const query = remoteQueryObjectFromString({ + entryPoint: "cart", + fields: defaultStoreCartFields, + }) + + const [cart] = await remoteQuery(query, { + cart: { id: req.params.id }, + }) + + res.status(200).json({ cart }) +} diff --git a/packages/medusa/src/api-v2/store/carts/[id]/line-items/[line_id]/validators.ts b/packages/medusa/src/api-v2/store/carts/[id]/line-items/[line_id]/validators.ts new file mode 100644 index 0000000000..2ea37e5dcf --- /dev/null +++ b/packages/medusa/src/api-v2/store/carts/[id]/line-items/[line_id]/validators.ts @@ -0,0 +1,9 @@ +import { IsInt, IsOptional } from "class-validator" + +export class StorePostCartsCartLineItemsItemReq { + @IsInt() + quantity: number + + @IsOptional() + metadata?: Record | undefined +} diff --git a/packages/medusa/src/api-v2/store/carts/middlewares.ts b/packages/medusa/src/api-v2/store/carts/middlewares.ts index 6a45a541d4..51f1407e09 100644 --- a/packages/medusa/src/api-v2/store/carts/middlewares.ts +++ b/packages/medusa/src/api-v2/store/carts/middlewares.ts @@ -1,6 +1,7 @@ import { transformBody, transformQuery } from "../../../api/middlewares" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { authenticate } from "../../../utils/authenticate-middleware" +import { StorePostCartsCartLineItemsItemReq } from "./[id]/line-items/[line_id]/validators" import { StorePostCartsCartLineItemsReq } from "./[id]/line-items/validators" import * as QueryConfig from "./query-config" import { @@ -48,6 +49,14 @@ export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [ }, { method: ["POST"], + matcher: "/store/carts/:id/line-items/:line_id", + middlewares: [transformBody(StorePostCartsCartLineItemsItemReq)], + }, + { + method: ["DELETE"], + matcher: "/store/carts/:id/line-items/:line_id", + }, + { matcher: "/store/carts/:id/promotions", middlewares: [transformBody(StorePostCartsCartPromotionsReq)], }, diff --git a/packages/types/src/cart/common.ts b/packages/types/src/cart/common.ts index ea10cbe2c7..d79ff23845 100644 --- a/packages/types/src/cart/common.ts +++ b/packages/types/src/cart/common.ts @@ -400,6 +400,10 @@ export interface CartLineItemDTO extends CartLineItemTotalsDTO { * When the line item was updated. */ updated_at?: Date + /** + * When the line item was deleted. + */ + deleted_at?: Date } export interface CartDTO { diff --git a/packages/types/src/cart/mutations.ts b/packages/types/src/cart/mutations.ts index 6c0d00fbcb..b113edb6dc 100644 --- a/packages/types/src/cart/mutations.ts +++ b/packages/types/src/cart/mutations.ts @@ -189,6 +189,7 @@ export interface UpdateLineItemDTO title?: string quantity?: number unit_price?: number + metadata?: Record | null tax_lines?: UpdateTaxLineDTO[] | CreateTaxLineDTO[] adjustments?: UpdateAdjustmentDTO[] | CreateAdjustmentDTO[] diff --git a/packages/types/src/cart/workflows.ts b/packages/types/src/cart/workflows.ts index 174525aae6..fdaf82ac7f 100644 --- a/packages/types/src/cart/workflows.ts +++ b/packages/types/src/cart/workflows.ts @@ -1,4 +1,5 @@ -import { CartDTO } from "./common" +import { CartDTO, CartLineItemDTO } from "./common" +import { UpdateLineItemDTO } from "./mutations" export interface CreateCartCreateLineItemDTO { quantity: number @@ -32,6 +33,12 @@ export interface CreateCartCreateLineItemDTO { metadata?: Record } +export interface UpdateLineItemInCartWorkflowInputDTO { + cart: CartDTO + item: CartLineItemDTO + update: Partial +} + export interface CreateCartAddressDTO { first_name?: string last_name?: string diff --git a/packages/utils/src/modules-sdk/internal-module-service-factory.ts b/packages/utils/src/modules-sdk/internal-module-service-factory.ts index 9c8a4ecafc..7bc865090f 100644 --- a/packages/utils/src/modules-sdk/internal-module-service-factory.ts +++ b/packages/utils/src/modules-sdk/internal-module-service-factory.ts @@ -2,19 +2,19 @@ import { BaseFilterable, Context, FilterQuery, - FilterQuery as InternalFilterQuery, FindConfig, + FilterQuery as InternalFilterQuery, ModulesSdkTypes, } from "@medusajs/types" import { EntitySchema } from "@mikro-orm/core" import { EntityClass } from "@mikro-orm/core/typings" import { + MedusaError, doNotForceTransaction, isDefined, isObject, isString, lowerCaseFirst, - MedusaError, shouldForceTransaction, } from "../common" import { buildQuery } from "./build-query"