From a5e8bf06c63301dc001bbd3fc972633da12d7821 Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Tue, 16 Jan 2024 15:44:49 +0100 Subject: [PATCH] feat(cart): Line items operations (#6066) --- .../services/cart-module/index.spec.ts | 367 ++++++++++++++++- packages/cart/src/loaders/container.ts | 2 + packages/cart/src/models/cart.ts | 3 +- packages/cart/src/models/line-item.ts | 20 +- packages/cart/src/repositories/address.ts | 25 +- packages/cart/src/repositories/index.ts | 1 + packages/cart/src/repositories/line-item.ts | 11 + packages/cart/src/services/address.ts | 7 +- packages/cart/src/services/cart-module.ts | 383 +++++++++++++++--- packages/cart/src/services/index.ts | 1 + packages/cart/src/services/line-item.ts | 128 ++++++ packages/cart/src/types/index.ts | 1 + packages/cart/src/types/line-item.ts | 37 ++ .../src/handlers/cart/create-cart.ts | 4 +- .../src/handlers/cart/retrieve-cart.ts | 4 +- packages/types/src/cart/common.ts | 22 +- packages/types/src/cart/mutations.ts | 38 +- packages/types/src/cart/service.ts | 55 ++- 18 files changed, 973 insertions(+), 136 deletions(-) create mode 100644 packages/cart/src/repositories/line-item.ts create mode 100644 packages/cart/src/services/line-item.ts create mode 100644 packages/cart/src/types/line-item.ts diff --git a/packages/cart/integration-tests/__tests__/services/cart-module/index.spec.ts b/packages/cart/integration-tests/__tests__/services/cart-module/index.spec.ts index 151343e1d1..acbdd99435 100644 --- a/packages/cart/integration-tests/__tests__/services/cart-module/index.spec.ts +++ b/packages/cart/integration-tests/__tests__/services/cart-module/index.spec.ts @@ -1,5 +1,4 @@ import { ICartModuleService } from "@medusajs/types" -import { SqlEntityManager } from "@mikro-orm/postgresql" import { initialize } from "../../../../src/initialize" import { DB_URL, MikroOrmWrapper } from "../../../utils" @@ -7,11 +6,9 @@ jest.setTimeout(30000) describe("Cart Module Service", () => { let service: ICartModuleService - let repositoryManager: SqlEntityManager beforeEach(async () => { await MikroOrmWrapper.setupDatabase() - repositoryManager = await MikroOrmWrapper.forkManager() service = await initialize({ database: { @@ -242,4 +239,368 @@ describe("Cart Module Service", () => { expect(address).toBe(undefined) }) }) + + describe("addLineItems", () => { + it("should add a line item to cart succesfully", async () => { + const [createdCart] = await service.create([ + { + currency_code: "eur", + }, + ]) + + const items = await service.addLineItems(createdCart.id, [ + { + quantity: 1, + unit_price: 100, + title: "test", + }, + ]) + + const cart = await service.retrieve(createdCart.id, { + relations: ["items"], + }) + + expect(items[0]).toEqual( + expect.objectContaining({ + title: "test", + quantity: 1, + unit_price: 100, + }) + ) + expect(cart.items?.length).toBe(1) + }) + + it("should add multiple line items to cart succesfully", async () => { + const [createdCart] = await service.create([ + { + currency_code: "eur", + }, + ]) + + await service.addLineItems([ + { + quantity: 1, + unit_price: 100, + title: "test", + cart_id: createdCart.id, + }, + { + quantity: 2, + unit_price: 200, + title: "test-2", + cart_id: createdCart.id, + }, + ]) + + const cart = await service.retrieve(createdCart.id, { + relations: ["items"], + }) + + expect(cart.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: "test", + quantity: 1, + unit_price: 100, + }), + expect.objectContaining({ + title: "test-2", + quantity: 2, + unit_price: 200, + }), + ]) + ) + + expect(cart.items?.length).toBe(2) + }) + + it("should add multiple line items to multiple carts succesfully", async () => { + let [eurCart] = await service.create([ + { + currency_code: "eur", + }, + ]) + + let [usdCart] = await service.create([ + { + currency_code: "usd", + }, + ]) + + const items = await service.addLineItems([ + { + cart_id: eurCart.id, + quantity: 1, + unit_price: 100, + title: "test", + }, + { + cart_id: usdCart.id, + quantity: 1, + unit_price: 100, + title: "test", + }, + ]) + + const carts = await service.list( + { id: [eurCart.id, usdCart.id] }, + { relations: ["items"] } + ) + + eurCart = carts.find((c) => c.currency_code === "eur")! + usdCart = carts.find((c) => c.currency_code === "usd")! + + const eurItems = items.filter((i) => i.cart_id === eurCart.id) + const usdItems = items.filter((i) => i.cart_id === usdCart.id) + + expect(eurCart.items![0].id).toBe(eurItems[0].id) + expect(usdCart.items![0].id).toBe(usdItems[0].id) + + expect(eurCart.items?.length).toBe(1) + expect(usdCart.items?.length).toBe(1) + }) + + it("should throw if cart does not exist", async () => { + const error = await service + .addLineItems("foo", [ + { + quantity: 1, + unit_price: 100, + title: "test", + tax_lines: [], + }, + ]) + .catch((e) => e) + + expect(error.message).toContain("Cart with id: foo was not found") + }) + + it("should throw an error when required params are not passed adding to a single cart", async () => { + const [createdCart] = await service.create([ + { + currency_code: "eur", + }, + ]) + + const error = await service + .addLineItems(createdCart.id, [ + { + quantity: 1, + title: "test", + }, + ] as any) + .catch((e) => e) + + expect(error.message).toContain( + "Value for LineItem.unit_price is required, 'undefined' found" + ) + }) + + it("should throw a generic error when required params are not passed using bulk add method", async () => { + const [createdCart] = await service.create([ + { + currency_code: "eur", + }, + ]) + + const error = await service + .addLineItems([ + { + cart_id: createdCart.id, + quantity: 1, + title: "test", + }, + ] as any) + .catch((e) => e) + + expect(error.message).toContain( + "Value for LineItem.unit_price is required, 'undefined' found" + ) + }) + }) + + describe("updateLineItems", () => { + it("should update a line item in cart succesfully with selector approach", async () => { + const [createdCart] = await service.create([ + { + currency_code: "eur", + }, + ]) + + const [item] = await service.addLineItems(createdCart.id, [ + { + quantity: 1, + unit_price: 100, + title: "test", + tax_lines: [], + }, + ]) + + expect(item.title).toBe("test") + + const [updatedItem] = await service.updateLineItems( + { cart_id: createdCart.id }, + { + title: "test2", + } + ) + + expect(updatedItem.title).toBe("test2") + }) + + it("should update a line item in cart succesfully with id approach", async () => { + const [createdCart] = await service.create([ + { + currency_code: "eur", + }, + ]) + + const [item] = await service.addLineItems(createdCart.id, [ + { + quantity: 1, + unit_price: 100, + title: "test", + tax_lines: [], + }, + ]) + + expect(item.title).toBe("test") + + const updatedItem = await service.updateLineItems( + item.id, + { + title: "test2", + } + ) + + expect(updatedItem.title).toBe("test2") + }) + + it("should update line items in carts succesfully with multi-selector approach", async () => { + const [createdCart] = await service.create([ + { + currency_code: "eur", + }, + ]) + + const items = await service.addLineItems(createdCart.id, [ + { + quantity: 1, + unit_price: 100, + title: "test", + }, + { + quantity: 2, + unit_price: 200, + title: "other-test", + }, + ]) + + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: "test", + quantity: 1, + unit_price: 100, + }), + expect.objectContaining({ + title: "other-test", + quantity: 2, + unit_price: 200, + }), + ]) + ) + + const itemOne = items.find((i) => i.title === "test") + const itemTwo = items.find((i) => i.title === "other-test") + + const updatedItems = await service.updateLineItems([ + { + selector: { cart_id: createdCart.id }, + data: { + title: "changed-test", + } + }, + { + selector: { id: itemTwo!.id }, + data: { + title: "changed-other-test", + } + }, + ]) + + expect(updatedItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: "changed-test", + quantity: 1, + unit_price: 100, + }), + expect.objectContaining({ + title: "changed-other-test", + quantity: 2, + unit_price: 200, + }), + ]) + ) + }) + }) + + describe("removeLineItems", () => { + it("should remove a line item succesfully", async () => { + const [createdCart] = await service.create([ + { + currency_code: "eur", + }, + ]) + + const [item] = await service.addLineItems(createdCart.id, [ + { + quantity: 1, + unit_price: 100, + title: "test", + tax_lines: [], + }, + ]) + + expect(item.title).toBe("test") + + await service.removeLineItems([item.id]) + + const cart = await service.retrieve(createdCart.id, { + relations: ["items"], + }) + + expect(cart.items?.length).toBe(0) + }) + + it("should remove multiple line items succesfully", async () => { + const [createdCart] = await service.create([ + { + currency_code: "eur", + }, + ]) + + const [item, item2] = await service.addLineItems(createdCart.id, [ + { + quantity: 1, + unit_price: 100, + title: "test", + }, + { + quantity: 1, + unit_price: 100, + title: "test-2", + }, + ]) + + await service.removeLineItems([item.id, item2.id]) + + const cart = await service.retrieve(createdCart.id, { + relations: ["items"], + }) + + expect(cart.items?.length).toBe(0) + }) + }) }) diff --git a/packages/cart/src/loaders/container.ts b/packages/cart/src/loaders/container.ts index a8716ea5ab..ae8a1be50e 100644 --- a/packages/cart/src/loaders/container.ts +++ b/packages/cart/src/loaders/container.ts @@ -20,6 +20,7 @@ export default async ({ container.register({ cartService: asClass(defaultServices.CartService).singleton(), addressService: asClass(defaultServices.AddressService).singleton(), + lineItemService: asClass(defaultServices.LineItemService).singleton(), }) if (customRepositories) { @@ -38,5 +39,6 @@ function loadDefaultRepositories({ container }) { baseRepository: asClass(defaultRepositories.BaseRepository).singleton(), cartRepository: asClass(defaultRepositories.CartRepository).singleton(), addressRepository: asClass(defaultRepositories.AddressRepository).singleton(), + lineItemRepository: asClass(defaultRepositories.LineItemRepository).singleton(), }) } diff --git a/packages/cart/src/models/cart.ts b/packages/cart/src/models/cart.ts index 2dfc02aab4..168f9216ba 100644 --- a/packages/cart/src/models/cart.ts +++ b/packages/cart/src/models/cart.ts @@ -11,7 +11,7 @@ import { OneToMany, OptionalProps, PrimaryKey, - Property, + Property } from "@mikro-orm/core" import Address from "./address" import LineItem from "./line-item" @@ -78,7 +78,6 @@ export default class Cart { @OneToMany(() => ShippingMethod, (shippingMethod) => shippingMethod.cart, { cascade: [Cascade.REMOVE], - }) shipping_methods = new Collection(this) diff --git a/packages/cart/src/models/line-item.ts b/packages/cart/src/models/line-item.ts index 4480e0877a..da54c7046d 100644 --- a/packages/cart/src/models/line-item.ts +++ b/packages/cart/src/models/line-item.ts @@ -22,6 +22,7 @@ type OptionalLineItemProps = | "is_tax_inclusive" | "compare_at_unit_price" | "requires_shipping" + | "cart" | DAL.EntityDateColumns @Entity({ tableName: "cart_line_item" }) @@ -31,12 +32,15 @@ export default class LineItem { @PrimaryKey({ columnType: "text" }) id: string + @Property({ columnType: "text" }) + cart_id: string + @ManyToOne(() => Cart, { onDelete: "cascade", index: "IDX_line_item_cart_id", - fieldName: "cart_id", + nullable: true, }) - cart!: Cart + cart?: Cart | null @Property({ columnType: "text" }) title: string @@ -47,7 +51,7 @@ export default class LineItem { @Property({ columnType: "text", nullable: true }) thumbnail?: string | null - @Property({ columnType: "text" }) + @Property({ columnType: "integer" }) quantity: number @Property({ @@ -90,13 +94,13 @@ export default class LineItem { @Property({ columnType: "jsonb", nullable: true }) variant_option_values?: Record | null - @Property({ columnType: "boolean" }) + @Property({ columnType: "boolean", default: true }) requires_shipping = true - @Property({ columnType: "boolean" }) + @Property({ columnType: "boolean", default: true }) is_discountable = true - @Property({ columnType: "boolean" }) + @Property({ columnType: "boolean", default: false }) is_tax_inclusive = false @Property({ columnType: "numeric", nullable: true }) @@ -147,7 +151,7 @@ export default class LineItem { columnType: "timestamptz", defaultRaw: "now()", }) - created_at: Date + created_at?: Date @Property({ onCreate: () => new Date(), @@ -155,7 +159,7 @@ export default class LineItem { columnType: "timestamptz", defaultRaw: "now()", }) - updated_at: Date + updated_at?: Date @BeforeCreate() onCreate() { diff --git a/packages/cart/src/repositories/address.ts b/packages/cart/src/repositories/address.ts index 53c375821e..a480877948 100644 --- a/packages/cart/src/repositories/address.ts +++ b/packages/cart/src/repositories/address.ts @@ -1,6 +1,4 @@ -import { Context } from "@medusajs/types" import { DALUtils } from "@medusajs/utils" -import { SqlEntityManager } from "@mikro-orm/postgresql" import { Address } from "@models" import { CreateAddressDTO, UpdateAddressDTO } from "../types" @@ -8,25 +6,6 @@ export class AddressRepository extends DALUtils.mikroOrmBaseRepositoryFactory< Address, { create: CreateAddressDTO + update: UpdateAddressDTO } ->(Address) { - constructor(...args: any[]) { - // @ts-ignore - super(...arguments) - } - - async update( - data: { address: Address; update: UpdateAddressDTO }[], - context: Context = {} - ): Promise { - const manager = this.getActiveManager(context) - - const entities = data.map(({ address, update }) => { - return manager.assign(address, update) - }) - - manager.persist(entities) - - return entities - } -} +>(Address) {} diff --git a/packages/cart/src/repositories/index.ts b/packages/cart/src/repositories/index.ts index d527e6f73c..fabd202b2d 100644 --- a/packages/cart/src/repositories/index.ts +++ b/packages/cart/src/repositories/index.ts @@ -1,4 +1,5 @@ export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils" export * from "./address" export * from "./cart" +export * from "./line-item" diff --git a/packages/cart/src/repositories/line-item.ts b/packages/cart/src/repositories/line-item.ts new file mode 100644 index 0000000000..6741e7ee89 --- /dev/null +++ b/packages/cart/src/repositories/line-item.ts @@ -0,0 +1,11 @@ +import { DALUtils } from "@medusajs/utils" +import { LineItem } from "@models" +import { CreateLineItemDTO, UpdateLineItemDTO } from "../types" + +export class LineItemRepository extends DALUtils.mikroOrmBaseRepositoryFactory< + LineItem, + { + create: CreateLineItemDTO + update: UpdateLineItemDTO + } +>(LineItem) {} diff --git a/packages/cart/src/services/address.ts b/packages/cart/src/services/address.ts index 24484662fe..0e4e17d2cf 100644 --- a/packages/cart/src/services/address.ts +++ b/packages/cart/src/services/address.ts @@ -1,5 +1,6 @@ import { AddressDTO, + CartAddressDTO, Context, DAL, FilterableAddressProps, @@ -46,7 +47,7 @@ export default class AddressService { @InjectManager("addressRepository_") async list( filters: FilterableAddressProps = {}, - config: FindConfig = {}, + config: FindConfig = {}, @MedusaContext() sharedContext: Context = {} ): Promise { const queryOptions = ModulesSdkUtils.buildQuery
(filters, config) @@ -97,7 +98,7 @@ export default class AddressService { existingAddresses.map<[string, Address]>((addr) => [addr.id, addr]) ) - const updates: { address: Address; update: UpdateAddressDTO }[] = [] + const updates: UpdateAddressDTO[] = [] for (const update of data) { const address = existingAddressesMap.get(update.id) @@ -109,7 +110,7 @@ export default class AddressService { ) } - updates.push({ address, update }) + updates.push({ ...update, id: address.id }) } return (await (this.addressRepository_ as AddressRepository).update( diff --git a/packages/cart/src/services/cart-module.ts b/packages/cart/src/services/cart-module.ts index 419c71b84f..5a20a5e9ea 100644 --- a/packages/cart/src/services/cart-module.ts +++ b/packages/cart/src/services/cart-module.ts @@ -1,47 +1,53 @@ import { - AddressDTO, - CartAddressDTO, - CartDTO, Context, - CreateCartDTO, DAL, FilterableCartProps, FindConfig, ICartModuleService, InternalModuleDeclaration, ModuleJoinerConfig, - UpdateCartDTO, } from "@medusajs/types" -import { FilterableAddressProps } from "@medusajs/types" +import { CartTypes } from "@medusajs/types" + import { InjectManager, InjectTransactionManager, MedusaContext, + isObject, + isString, } from "@medusajs/utils" -import { CreateAddressDTO, UpdateAddressDTO } from "@types" +import { LineItem } from "@models" +import { UpdateLineItemDTO } from "@types" import { joinerConfig } from "../joiner-config" -import AddressService from "./address" -import CartService from "./cart" +import * as services from "../services" type InjectedDependencies = { baseRepository: DAL.RepositoryService - cartService: CartService - addressService: AddressService + cartService: services.CartService + addressService: services.AddressService + lineItemService: services.LineItemService } export default class CartModuleService implements ICartModuleService { protected baseRepository_: DAL.RepositoryService - protected cartService_: CartService - protected addressService_: AddressService + protected cartService_: services.CartService + protected addressService_: services.AddressService + protected lineItemService_: services.LineItemService constructor( - { baseRepository, cartService, addressService }: InjectedDependencies, + { + baseRepository, + cartService, + addressService, + lineItemService, + }: InjectedDependencies, protected readonly moduleDeclaration: InternalModuleDeclaration ) { this.baseRepository_ = baseRepository this.cartService_ = cartService this.addressService_ = addressService + this.lineItemService_ = lineItemService } __joinerConfig(): ModuleJoinerConfig { @@ -51,25 +57,25 @@ export default class CartModuleService implements ICartModuleService { @InjectManager("baseRepository_") async retrieve( id: string, - config: FindConfig = {}, + config: FindConfig = {}, @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise { const cart = await this.cartService_.retrieve(id, config, sharedContext) - return await this.baseRepository_.serialize(cart, { + return await this.baseRepository_.serialize(cart, { populate: true, }) } @InjectManager("baseRepository_") async list( - filters: FilterableCartProps = {}, - config: FindConfig = {}, + filters: CartTypes.FilterableCartProps = {}, + config: FindConfig = {}, @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise { const carts = await this.cartService_.list(filters, config, sharedContext) - return await this.baseRepository_.serialize(carts, { + return this.baseRepository_.serialize(carts, { populate: true, }) } @@ -77,9 +83,9 @@ export default class CartModuleService implements ICartModuleService { @InjectManager("baseRepository_") async listAndCount( filters: FilterableCartProps = {}, - config: FindConfig = {}, + config: FindConfig = {}, @MedusaContext() sharedContext: Context = {} - ): Promise<[CartDTO[], number]> { + ): Promise<[CartTypes.CartDTO[], number]> { const [carts, count] = await this.cartService_.listAndCount( filters, config, @@ -87,7 +93,7 @@ export default class CartModuleService implements ICartModuleService { ) return [ - await this.baseRepository_.serialize(carts, { + await this.baseRepository_.serialize(carts, { populate: true, }), count, @@ -95,17 +101,20 @@ export default class CartModuleService implements ICartModuleService { } async create( - data: CreateCartDTO[], + data: CartTypes.CreateCartDTO[], sharedContext?: Context - ): Promise + ): Promise - async create(data: CreateCartDTO, sharedContext?: Context): Promise + async create( + data: CartTypes.CreateCartDTO, + sharedContext?: Context + ): Promise @InjectManager("baseRepository_") async create( - data: CreateCartDTO[] | CreateCartDTO, + data: CartTypes.CreateCartDTO[] | CartTypes.CreateCartDTO, @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise { const input = Array.isArray(data) ? data : [data] const carts = await this.create_(input, sharedContext) @@ -118,29 +127,34 @@ export default class CartModuleService implements ICartModuleService { sharedContext ) - return (Array.isArray(data) ? result : result[0]) as CartDTO | CartDTO[] + return (Array.isArray(data) ? result : result[0]) as + | CartTypes.CartDTO + | CartTypes.CartDTO[] } @InjectTransactionManager("baseRepository_") protected async create_( - data: CreateCartDTO[], + data: CartTypes.CreateCartDTO[], @MedusaContext() sharedContext: Context = {} ) { return await this.cartService_.create(data, sharedContext) } async update( - data: UpdateCartDTO[], + data: CartTypes.UpdateCartDTO[], sharedContext?: Context - ): Promise + ): Promise - async update(data: UpdateCartDTO, sharedContext?: Context): Promise + async update( + data: CartTypes.UpdateCartDTO, + sharedContext?: Context + ): Promise @InjectManager("baseRepository_") async update( - data: UpdateCartDTO[] | UpdateCartDTO, + data: CartTypes.UpdateCartDTO[] | CartTypes.UpdateCartDTO, @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise { const input = Array.isArray(data) ? data : [data] const carts = await this.update_(input, sharedContext) @@ -152,12 +166,14 @@ export default class CartModuleService implements ICartModuleService { sharedContext ) - return (Array.isArray(data) ? result : result[0]) as CartDTO | CartDTO[] + return (Array.isArray(data) ? result : result[0]) as + | CartTypes.CartDTO + | CartTypes.CartDTO[] } @InjectTransactionManager("baseRepository_") protected async update_( - data: UpdateCartDTO[], + data: CartTypes.UpdateCartDTO[], @MedusaContext() sharedContext: Context = {} ) { return await this.cartService_.update(data, sharedContext) @@ -178,8 +194,8 @@ export default class CartModuleService implements ICartModuleService { @InjectManager("baseRepository_") async listAddresses( - filters: FilterableAddressProps = {}, - config: FindConfig = {}, + filters: CartTypes.FilterableAddressProps = {}, + config: FindConfig = {}, @MedusaContext() sharedContext: Context = {} ) { const addresses = await this.addressService_.list( @@ -188,19 +204,260 @@ export default class CartModuleService implements ICartModuleService { sharedContext ) - return await this.baseRepository_.serialize(addresses, { - populate: true, - }) + return await this.baseRepository_.serialize( + addresses, + { + populate: true, + } + ) } - async createAddresses(data: CreateAddressDTO, sharedContext?: Context) - async createAddresses(data: CreateAddressDTO[], sharedContext?: Context) + @InjectManager("baseRepository_") + async retrieveLineItem( + itemId: string, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ) { + const item = await this.lineItemService_.retrieve( + itemId, + config, + sharedContext + ) + + return await this.baseRepository_.serialize( + item, + { + populate: true, + } + ) + } + + @InjectManager("baseRepository_") + async listLineItems( + filters: CartTypes.FilterableLineItemProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ) { + const items = await this.lineItemService_.list( + filters, + config, + sharedContext + ) + + return await this.baseRepository_.serialize( + items, + { + populate: true, + } + ) + } + + addLineItems( + data: CartTypes.CreateLineItemForCartDTO + ): Promise + addLineItems( + data: CartTypes.CreateLineItemForCartDTO[] + ): Promise + addLineItems( + cartId: string, + items: CartTypes.CreateLineItemDTO[], + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async addLineItems( + cartIdOrData: + | string + | CartTypes.CreateLineItemForCartDTO[] + | CartTypes.CreateLineItemForCartDTO, + data?: CartTypes.CreateLineItemDTO[] | CartTypes.CreateLineItemDTO, + @MedusaContext() sharedContext: Context = {} + ): Promise { + let items: LineItem[] = [] + if (isString(cartIdOrData)) { + items = await this.addLineItems_( + cartIdOrData, + data as CartTypes.CreateLineItemDTO[], + sharedContext + ) + } else { + const data = Array.isArray(cartIdOrData) ? cartIdOrData : [cartIdOrData] + items = await this.addLineItemsBulk_(data, sharedContext) + } + + return await this.baseRepository_.serialize( + items, + { + populate: true, + } + ) + } + + @InjectTransactionManager("baseRepository_") + protected async addLineItems_( + cartId: string, + items: CartTypes.CreateLineItemDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const cart = await this.retrieve(cartId, { select: ["id"] }, sharedContext) + + const toUpdate = items.map((item) => { + return { + ...item, + cart_id: cart.id, + } + }) + + return await this.addLineItemsBulk_(toUpdate, sharedContext) + } + + @InjectTransactionManager("baseRepository_") + protected async addLineItemsBulk_( + data: CartTypes.CreateLineItemForCartDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return await this.lineItemService_.create(data, sharedContext) + } + + updateLineItems( + data: CartTypes.UpdateLineItemWithSelectorDTO[] + ): Promise + updateLineItems( + selector: Partial, + data: CartTypes.UpdateLineItemDTO, + sharedContext?: Context + ): Promise + updateLineItems( + lineItemId: string, + data: Partial, + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async updateLineItems( + lineItemIdOrDataOrSelector: + | string + | CartTypes.UpdateLineItemWithSelectorDTO[] + | Partial, + data?: CartTypes.UpdateLineItemDTO | Partial, + @MedusaContext() sharedContext: Context = {} + ): Promise { + let items: LineItem[] = [] + if (isString(lineItemIdOrDataOrSelector)) { + const item = await this.updateLineItem_( + lineItemIdOrDataOrSelector, + data as Partial, + sharedContext + ) + + return await this.baseRepository_.serialize( + item, + { + populate: true, + } + ) + } + + const toUpdate = Array.isArray(lineItemIdOrDataOrSelector) + ? lineItemIdOrDataOrSelector + : [ + { + selector: lineItemIdOrDataOrSelector, + data: data, + } as CartTypes.UpdateLineItemWithSelectorDTO, + ] + + items = await this.updateLineItemsWithSelector_(toUpdate, sharedContext) + + return await this.baseRepository_.serialize( + items, + { + populate: true, + } + ) + } + + @InjectTransactionManager("baseRepository_") + protected async updateLineItem_( + lineItemId: string, + data: Partial, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const [item] = await this.lineItemService_.update( + [{ id: lineItemId, ...data }], + sharedContext + ) + + return item + } + + @InjectTransactionManager("baseRepository_") + protected async updateLineItemsWithSelector_( + updates: CartTypes.UpdateLineItemWithSelectorDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + let toUpdate: UpdateLineItemDTO[] = [] + for (const { selector, data } of updates) { + const items = await this.listLineItems({ ...selector }, {}, sharedContext) + + items.forEach((item) => { + toUpdate.push({ + ...data, + id: item.id, + }) + }) + } + + return await this.lineItemService_.update(toUpdate, sharedContext) + } + + async removeLineItems( + itemIds: string[], + sharedContext?: Context + ): Promise + async removeLineItems(itemIds: string, sharedContext?: Context): Promise + async removeLineItems( + selector: Partial, + sharedContext?: Context + ): Promise + + @InjectTransactionManager("baseRepository_") + async removeLineItems( + itemIdsOrSelector: string | string[] | Partial, + @MedusaContext() sharedContext: Context = {} + ): Promise { + let toDelete: string[] = [] + if (isObject(itemIdsOrSelector)) { + const items = await this.listLineItems( + { ...itemIdsOrSelector } as Partial, + {}, + sharedContext + ) + + toDelete = items.map((item) => item.id) + } else { + toDelete = Array.isArray(itemIdsOrSelector) + ? itemIdsOrSelector + : [itemIdsOrSelector] + } + + await this.lineItemService_.delete(toDelete, sharedContext) + } + + async createAddresses( + data: CartTypes.CreateAddressDTO, + sharedContext?: Context + ): Promise + async createAddresses( + data: CartTypes.CreateAddressDTO[], + sharedContext?: Context + ): Promise @InjectManager("baseRepository_") async createAddresses( - data: CreateAddressDTO[] | CreateAddressDTO, + data: CartTypes.CreateAddressDTO[] | CartTypes.CreateAddressDTO, @MedusaContext() sharedContext: Context = {} - ) { + ): Promise { const input = Array.isArray(data) ? data : [data] const addresses = await this.createAddresses_(input, sharedContext) @@ -211,26 +468,32 @@ export default class CartModuleService implements ICartModuleService { ) return (Array.isArray(data) ? result : result[0]) as - | AddressDTO - | AddressDTO[] + | CartTypes.CartAddressDTO + | CartTypes.CartAddressDTO[] } @InjectTransactionManager("baseRepository_") protected async createAddresses_( - data: CreateAddressDTO[], + data: CartTypes.CreateAddressDTO[], @MedusaContext() sharedContext: Context = {} ) { return await this.addressService_.create(data, sharedContext) } - async updateAddresses(data: UpdateAddressDTO, sharedContext?: Context) - async updateAddresses(data: UpdateAddressDTO[], sharedContext?: Context) + async updateAddresses( + data: CartTypes.UpdateAddressDTO, + sharedContext?: Context + ): Promise + async updateAddresses( + data: CartTypes.UpdateAddressDTO[], + sharedContext?: Context + ): Promise @InjectManager("baseRepository_") async updateAddresses( - data: UpdateAddressDTO[] | UpdateAddressDTO, + data: CartTypes.UpdateAddressDTO[] | CartTypes.UpdateAddressDTO, @MedusaContext() sharedContext: Context = {} - ) { + ): Promise { const input = Array.isArray(data) ? data : [data] const addresses = await this.updateAddresses_(input, sharedContext) @@ -241,26 +504,26 @@ export default class CartModuleService implements ICartModuleService { ) return (Array.isArray(data) ? result : result[0]) as - | AddressDTO - | AddressDTO[] + | CartTypes.CartAddressDTO + | CartTypes.CartAddressDTO[] } @InjectTransactionManager("baseRepository_") protected async updateAddresses_( - data: UpdateAddressDTO[], + data: CartTypes.UpdateAddressDTO[], @MedusaContext() sharedContext: Context = {} ) { return await this.addressService_.update(data, sharedContext) } - async deleteAddresses(ids: string[], sharedContext?: Context) - async deleteAddresses(ids: string, sharedContext?: Context) + async deleteAddresses(ids: string[], sharedContext?: Context): Promise + async deleteAddresses(ids: string, sharedContext?: Context): Promise @InjectTransactionManager("baseRepository_") async deleteAddresses( ids: string[] | string, @MedusaContext() sharedContext: Context = {} - ) { + ): Promise { const addressIds = Array.isArray(ids) ? ids : [ids] await this.addressService_.delete(addressIds, sharedContext) } diff --git a/packages/cart/src/services/index.ts b/packages/cart/src/services/index.ts index 1f70a17a34..a72d00a336 100644 --- a/packages/cart/src/services/index.ts +++ b/packages/cart/src/services/index.ts @@ -1,4 +1,5 @@ export { default as AddressService } from "./address" export { default as CartService } from "./cart" export { default as CartModuleService } from "./cart-module" +export { default as LineItemService } from "./line-item" diff --git a/packages/cart/src/services/line-item.ts b/packages/cart/src/services/line-item.ts new file mode 100644 index 0000000000..445a631722 --- /dev/null +++ b/packages/cart/src/services/line-item.ts @@ -0,0 +1,128 @@ +import { + CartLineItemDTO, + Context, + DAL, + FilterableLineItemProps, + FindConfig, +} from "@medusajs/types" +import { + InjectManager, + InjectTransactionManager, + MedusaContext, + MedusaError, + ModulesSdkUtils, + retrieveEntity, +} from "@medusajs/utils" +import { LineItem } from "@models" +import { LineItemRepository } from "@repositories" +import { CreateLineItemDTO, UpdateLineItemDTO } from "../types" + +type InjectedDependencies = { + lineItemRepository: DAL.RepositoryService +} + +export default class LineItemService { + protected readonly lineItemRepository_: DAL.RepositoryService + + constructor({ lineItemRepository }: InjectedDependencies) { + this.lineItemRepository_ = lineItemRepository + } + + @InjectManager("lineItemRepository_") + async retrieve( + id: string, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await retrieveEntity({ + id: id, + entityName: LineItem.name, + repository: this.lineItemRepository_, + config, + sharedContext, + })) as TEntity + } + + @InjectManager("lineItemRepository_") + async list( + filters: FilterableLineItemProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const queryOptions = ModulesSdkUtils.buildQuery(filters, config) + + return (await this.lineItemRepository_.find( + queryOptions, + sharedContext + )) as TEntity[] + } + + @InjectManager("lineItemRepository_") + async listAndCount( + filters: FilterableLineItemProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise<[TEntity[], number]> { + const queryOptions = ModulesSdkUtils.buildQuery(filters, config) + + return (await this.lineItemRepository_.findAndCount( + queryOptions, + sharedContext + )) as [TEntity[], number] + } + + @InjectTransactionManager("lineItemRepository_") + async create( + data: CreateLineItemDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await (this.lineItemRepository_ as LineItemRepository).create( + data, + sharedContext + )) as TEntity[] + } + + @InjectTransactionManager("lineItemRepository_") + async update( + data: UpdateLineItemDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const existingLines = await this.list( + { id: [...data.map((d) => d.id)] }, + {}, + sharedContext + ) + + const existingLinesMap = new Map( + existingLines.map<[string, LineItem]>((li) => [li.id, li]) + ) + + const updates: UpdateLineItemDTO[] = [] + + for (const update of data) { + const lineItem = existingLinesMap.get(update.id) + + if (!lineItem) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Line item with id "${update.id}" not found` + ) + } + + updates.push({ ...update, id: lineItem.id }) + } + + return (await (this.lineItemRepository_ as LineItemRepository).update( + updates, + sharedContext + )) as TEntity[] + } + + @InjectTransactionManager("lineItemRepository_") + async delete( + ids: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + await this.lineItemRepository_.delete(ids, sharedContext) + } +} diff --git a/packages/cart/src/types/index.ts b/packages/cart/src/types/index.ts index 8b97276103..e9e853846c 100644 --- a/packages/cart/src/types/index.ts +++ b/packages/cart/src/types/index.ts @@ -1,6 +1,7 @@ import { Logger } from "@medusajs/types" export * from "./address" export * from "./cart" +export * from "./line-item" export type InitializeModuleInjectableDependencies = { logger?: Logger diff --git a/packages/cart/src/types/line-item.ts b/packages/cart/src/types/line-item.ts new file mode 100644 index 0000000000..b7fd239713 --- /dev/null +++ b/packages/cart/src/types/line-item.ts @@ -0,0 +1,37 @@ +interface PartialUpsertLineItemDTO { + subtitle?: string + thumbnail?: string + + product_id?: string + product_title?: string + product_description?: string + product_subtitle?: string + product_type?: string + product_collection?: string + product_handle?: string + + variant_id?: string + variant_sku?: string + variant_barcode?: string + variant_title?: string + variant_option_values?: Record + + requires_shipping?: boolean + is_discountable?: boolean + is_tax_inclusive?: boolean + + compare_at_unit_price?: number +} + +export interface CreateLineItemDTO extends PartialUpsertLineItemDTO { + title: string + quantity: number + unit_price: number + cart_id: string +} + +export interface UpdateLineItemDTO + extends PartialUpsertLineItemDTO, + Partial { + id: string +} diff --git a/packages/core-flows/src/handlers/cart/create-cart.ts b/packages/core-flows/src/handlers/cart/create-cart.ts index 198f5e33fb..bb926d211b 100644 --- a/packages/core-flows/src/handlers/cart/create-cart.ts +++ b/packages/core-flows/src/handlers/cart/create-cart.ts @@ -1,4 +1,4 @@ -import { AddressDTO, CustomerDTO, RegionDTO, legacy__CartDTO } from "@medusajs/types" +import { AddressDTO, CustomerDTO, RegionDTO, legacy_CartDTO } from "@medusajs/types" import { WorkflowArguments } from "@medusajs/workflows-sdk" enum Aliases { @@ -34,7 +34,7 @@ type HandlerInputData = { } type HandlerOutputData = { - cart: legacy__CartDTO + cart: legacy_CartDTO } export async function createCart({ diff --git a/packages/core-flows/src/handlers/cart/retrieve-cart.ts b/packages/core-flows/src/handlers/cart/retrieve-cart.ts index 7f3f2884b8..8a5e3a2d01 100644 --- a/packages/core-flows/src/handlers/cart/retrieve-cart.ts +++ b/packages/core-flows/src/handlers/cart/retrieve-cart.ts @@ -1,4 +1,4 @@ -import { legacy__CartDTO } from "@medusajs/types" +import { legacy_CartDTO } from "@medusajs/types" import { WorkflowArguments } from "@medusajs/workflows-sdk" type HandlerInputData = { @@ -22,7 +22,7 @@ export async function retrieveCart({ container, context, data, -}: WorkflowArguments): Promise { +}: WorkflowArguments): Promise { const { manager } = context const cartService = container.resolve("cartService") diff --git a/packages/types/src/cart/common.ts b/packages/types/src/cart/common.ts index 1e17abf889..6c345d5945 100644 --- a/packages/types/src/cart/common.ts +++ b/packages/types/src/cart/common.ts @@ -336,6 +336,16 @@ export interface CartLineItemDTO { * @expandable */ adjustments?: LineItemAdjustmentLineDTO[] + /** + * The associated cart. + * + * @expandable + */ + cart: CartDTO + /** + * The ID of the associated cart. + */ + cart_id: string /** * Holds custom data in key-value pairs. */ @@ -470,12 +480,20 @@ export interface FilterableAddressProps id?: string | string[] } +export interface FilterableLineItemProps + extends BaseFilterable { + id?: string | string[] + cart_id?: string | string[] + title?: string + variant_id?: string | string[] + product_id?: string | string[] +} + /** * TODO: Remove this in favor of CartDTO, when module is released * @deprecated Use CartDTO instead */ - -export type legacy__CartDTO = { +export type legacy_CartDTO = { id?: string email?: string billing_address_id?: string diff --git a/packages/types/src/cart/mutations.ts b/packages/types/src/cart/mutations.ts index 0ed7f581aa..f128d819c9 100644 --- a/packages/types/src/cart/mutations.ts +++ b/packages/types/src/cart/mutations.ts @@ -1,3 +1,5 @@ +import { CartLineItemDTO } from "./common" + export interface UpsertAddressDTO { customer_id?: string company?: string @@ -93,6 +95,8 @@ export interface CreateLineItemDTO { subtitle?: string thumbnail?: string + cart_id?: string + quantity: number product_id?: string @@ -116,24 +120,30 @@ export interface CreateLineItemDTO { compare_at_unit_price?: number unit_price: number - tax_lines: CreateLineItemTaxLineDTO[] - adjustments: CreateLineItemAdjustmentDTO[] + tax_lines?: CreateLineItemTaxLineDTO[] + adjustments?: CreateLineItemAdjustmentDTO[] +} + +export interface CreateLineItemForCartDTO extends CreateLineItemDTO { + cart_id: string +} + +export interface UpdateLineItemWithSelectorDTO { + selector: Partial + data: Partial } export interface UpdateLineItemDTO - extends Omit { + extends Omit< + CreateLineItemDTO, + "tax_lines" | "adjustments" | "title" | "quantity" | "unit_price" + > { id: string - tax_lines: UpdateLineItemTaxLineDTO[] | CreateLineItemTaxLineDTO[] - adjustments: UpdateLineItemAdjustmentDTO[] | CreateLineItemAdjustmentDTO[] -} + title?: string + quantity?: number + unit_price?: number -export interface AddLineItemsDTO { - cart_id: string - items: CreateLineItemDTO[] -} - -export interface UpdateLineItemsDTO { - cart_id: string - items: UpdateLineItemDTO[] + tax_lines?: UpdateLineItemTaxLineDTO[] | CreateLineItemTaxLineDTO[] + adjustments?: UpdateLineItemAdjustmentDTO[] | CreateLineItemAdjustmentDTO[] } diff --git a/packages/types/src/cart/service.ts b/packages/types/src/cart/service.ts index caba8cfb1d..9f7f413acc 100644 --- a/packages/types/src/cart/service.ts +++ b/packages/types/src/cart/service.ts @@ -1,13 +1,22 @@ -import { AddressDTO } from "../address" import { FindConfig } from "../common" import { IModuleService } from "../modules-sdk" import { Context } from "../shared-context" -import { CartAddressDTO, CartDTO, FilterableAddressProps, FilterableCartProps } from "./common" +import { + CartAddressDTO, + CartDTO, + CartLineItemDTO, + FilterableAddressProps, + FilterableCartProps, +} from "./common" import { CreateAddressDTO, CreateCartDTO, + CreateLineItemDTO, + CreateLineItemForCartDTO, UpdateAddressDTO, UpdateCartDTO, + UpdateLineItemDTO, + UpdateLineItemWithSelectorDTO, } from "./mutations" export interface ICartModuleService extends IModuleService { @@ -40,7 +49,7 @@ export interface ICartModuleService extends IModuleService { listAddresses( filters?: FilterableAddressProps, - config?: FindConfig, + config?: FindConfig, sharedContext?: Context ): Promise @@ -65,20 +74,32 @@ export interface ICartModuleService extends IModuleService { deleteAddresses(ids: string[], sharedContext?: Context): Promise deleteAddresses(ids: string, sharedContext?: Context): Promise - // addLineItems(data: AddLineItemsDTO, sharedContext?: Context): Promise - // addLineItems( - // data: AddLineItemsDTO[], - // sharedContext?: Context - // ): Promise + addLineItems(data: CreateLineItemForCartDTO): Promise + addLineItems(data: CreateLineItemForCartDTO[]): Promise + addLineItems( + cartId: string, + items: CreateLineItemDTO[], + sharedContext?: Context + ): Promise - // updateLineItems( - // data: UpdateLineItemsDTO, - // sharedContext?: Context - // ): Promise - // updateLineItems( - // data: UpdateLineItemsDTO[], - // sharedContext?: Context - // ): Promise + updateLineItems( + data: UpdateLineItemWithSelectorDTO[] + ): Promise + updateLineItems( + selector: Partial, + data: Partial, + sharedContext?: Context + ): Promise + updateLineItems( + lineId: string, + data: Partial, + sharedContext?: Context + ): Promise - // removeLineItems(lineItemIds: string[], sharedContext?: Context): Promise + removeLineItems(itemIds: string[], sharedContext?: Context): Promise + removeLineItems(itemIds: string, sharedContext?: Context): Promise + removeLineItems( + selector: Partial, + sharedContext?: Context + ): Promise }