diff --git a/integration-tests/api/__tests__/admin/sales-channels.js b/integration-tests/api/__tests__/admin/sales-channels.js index 79c634d645..aeb5ce77e2 100644 --- a/integration-tests/api/__tests__/admin/sales-channels.js +++ b/integration-tests/api/__tests__/admin/sales-channels.js @@ -6,7 +6,6 @@ const { useApi } = require("../../../helpers/use-api") const { useDb } = require("../../../helpers/use-db") const adminSeeder = require("../../helpers/admin-seeder") - const { simpleSalesChannelFactory, simpleProductFactory, @@ -744,4 +743,72 @@ describe("sales channels", () => { expect(attachedProduct.sales_channels.length).toBe(0) }) }) + + describe("POST /admin/sales-channels/:id/products/batch", () => { + let salesChannel + let product + + beforeEach(async() => { + try { + await adminSeeder(dbConnection) + salesChannel = await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + }) + product = await simpleProductFactory(dbConnection, { + id: "product_1", + title: "test title", + }) + } catch (e) { + console.error(e) + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should add products to a sales channel", async() => { + const api = useApi() + + const payload = { + product_ids: [{ id: product.id }] + } + + let response = await api.post( + `/admin/sales-channels/${salesChannel.id}/products/batch`, + payload, + adminReqConfig + ) + + expect(response.status).toEqual(200) + expect(response.data.sales_channel).toEqual({ + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }) + + let attachedProduct = await dbConnection.manager.findOne(Product, { + where: { id: product.id }, + relations: ["sales_channels"] + }) + + expect(attachedProduct.sales_channels.length).toBe(1) + expect(attachedProduct.sales_channels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + }) + ]) + ) + }) + }) }) diff --git a/packages/medusa-js/src/resources/admin/sales-channels.ts b/packages/medusa-js/src/resources/admin/sales-channels.ts index 0e1a0cad06..e4d0f796b2 100644 --- a/packages/medusa-js/src/resources/admin/sales-channels.ts +++ b/packages/medusa-js/src/resources/admin/sales-channels.ts @@ -6,6 +6,7 @@ import { AdminSalesChannelsDeleteRes, AdminSalesChannelsListRes, AdminDeleteSalesChannelsChannelProductsBatchReq, + AdminPostSalesChannelsChannelProductsBatchReq, } from "@medusajs/medusa" import { ResponsePromise } from "../../typings" import BaseResource from "../base" @@ -104,6 +105,22 @@ class AdminSalesChannelsResource extends BaseResource { const path = `/admin/sales-channels/${salesChannelId}/products/batch` return this.client.request("DELETE", path, payload, {}, customHeaders) } + + /** + * Add products to a sales channel + * @experimental This feature is under development and may change in the future. + * To use this feature please enable featureflag `sales_channels` in your medusa backend project. + * @description Add products to a sales channel + * @returns a medusa sales channel + */ + addProducts( + salesChannelId: string, + payload: AdminPostSalesChannelsChannelProductsBatchReq, + customHeaders: Record = {} + ): ResponsePromise { + const path = `/admin/sales-channels/${salesChannelId}/products/batch` + return this.client.request("POST", path, payload, {}, customHeaders) + } } export default AdminSalesChannelsResource diff --git a/packages/medusa-react/mocks/handlers/admin.ts b/packages/medusa-react/mocks/handlers/admin.ts index bd6a4bb09a..bd98e0dcf5 100644 --- a/packages/medusa-react/mocks/handlers/admin.ts +++ b/packages/medusa-react/mocks/handlers/admin.ts @@ -1735,4 +1735,13 @@ export const adminHandlers = [ }) ) }), + + rest.post("/admin/sales-channels/:id/products/batch", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + sales_channel: fixtures.get("sales_channel"), + }) + ) + }), ] diff --git a/packages/medusa-react/src/hooks/admin/sales-channels/mutations.ts b/packages/medusa-react/src/hooks/admin/sales-channels/mutations.ts index 357e5b2cc3..ee914287b7 100644 --- a/packages/medusa-react/src/hooks/admin/sales-channels/mutations.ts +++ b/packages/medusa-react/src/hooks/admin/sales-channels/mutations.ts @@ -3,7 +3,8 @@ import { AdminSalesChannelsRes, AdminPostSalesChannelsSalesChannelReq, AdminSalesChannelsDeleteRes, - AdminDeleteSalesChannelsChannelProductsBatchReq + AdminDeleteSalesChannelsChannelProductsBatchReq, + AdminPostSalesChannelsChannelProductsBatchReq, } from "@medusajs/medusa" import { Response } from "@medusajs/medusa-js" import { useMutation, UseMutationOptions, useQueryClient } from "react-query" @@ -117,4 +118,34 @@ export const useAdminDeleteProductsFromSalesChannel = ( options ) ) -} \ No newline at end of file +} + +/** + * Add products to a sales channel + * @experimental This feature is under development and may change in the future. + * To use this feature please enable featureflag `sales_channels` in your medusa backend project. + * @description Add products to a sales channel + * @param id + * @param options + */ +export const useAdminAddProductsToSalesChannel = ( + id: string, + options?: UseMutationOptions< + Response, + Error, + AdminPostSalesChannelsChannelProductsBatchReq + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + return useMutation( + (payload: AdminPostSalesChannelsChannelProductsBatchReq) => { + return client.admin.salesChannels.addProducts(id, payload) + }, + buildOptions( + queryClient, + [adminSalesChannelsKeys.lists(), adminSalesChannelsKeys.detail(id)], + options + ) + ) +} diff --git a/packages/medusa-react/test/hooks/admin/sales-channels/mutations.test.ts b/packages/medusa-react/test/hooks/admin/sales-channels/mutations.test.ts index 28a0bc58d8..c4cac5e005 100644 --- a/packages/medusa-react/test/hooks/admin/sales-channels/mutations.test.ts +++ b/packages/medusa-react/test/hooks/admin/sales-channels/mutations.test.ts @@ -5,6 +5,7 @@ import { useAdminCreateSalesChannel, useAdminUpdateSalesChannel, useAdminDeleteProductsFromSalesChannel, + useAdminAddProductsToSalesChannel, } from "../../../../src" import { fixtures } from "../../../../mocks/data" import { createWrapper } from "../../../utils" @@ -107,3 +108,25 @@ describe("useAdminDeleteProductsFromSalesChannel hook", () => { })) }) }) + +describe("useAdminAddProductsToSalesChannel hook", () => { + test("add products to a sales channel", async () => { + const id = fixtures.get("sales_channel").id + const productId = fixtures.get("product").id + + const { result, waitFor } = renderHook( + () => useAdminAddProductsToSalesChannel(id), + { wrapper: createWrapper() } + ) + + result.current.mutate({ product_ids: [ + { id: productId } + ]}) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data).toEqual(expect.objectContaining({ + sales_channel: fixtures.get("sales_channel"), + })) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/sales-channels/__tests__/add-product-batch.js b/packages/medusa/src/api/routes/admin/sales-channels/__tests__/add-product-batch.js new file mode 100644 index 0000000000..39e2c6420b --- /dev/null +++ b/packages/medusa/src/api/routes/admin/sales-channels/__tests__/add-product-batch.js @@ -0,0 +1,39 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel" + +describe("POST /admin/sales-channels/:id/products/batch", () => { + describe("add product to a sales channel", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/admin/sales-channels/${IdMap.getId("sales_channel_1")}/products/batch`, + { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + payload: { + product_ids: [{ id: IdMap.getId("sales_channel_1_product_1") }] + }, + flags: ["sales_channels"], + } + ) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls the retrieve method from the sales channel service", () => { + expect(SalesChannelServiceMock.addProducts).toHaveBeenCalledTimes(1) + expect(SalesChannelServiceMock.addProducts).toHaveBeenCalledWith( + IdMap.getId("sales_channel_1"), + [IdMap.getId("sales_channel_1_product_1")] + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/sales-channels/add-product-batch.ts b/packages/medusa/src/api/routes/admin/sales-channels/add-product-batch.ts new file mode 100644 index 0000000000..98e7b6718c --- /dev/null +++ b/packages/medusa/src/api/routes/admin/sales-channels/add-product-batch.ts @@ -0,0 +1,49 @@ +import { Request, Response } from "express" +import { SalesChannelService } from "../../../../services" +import { IsArray, ValidateNested } from "class-validator" +import { Type } from "class-transformer" +import { ProductBatchSalesChannel } from "../../../../types/sales-channels" + +/** + * @oas [post] /sales-channels/{id}/products/batch + * operationId: "PostSalesChannelsChannelProductsBatch" + * summary: "Assign a batch of product to a sales channel" + * description: "Assign a batch of product to a sales channel." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The id of the Sales channel. + * - (body) product_ids=* {ProductBatchSalesChannel} The product ids that must be assigned to the sales channel. + * tags: + * - Sales Channel + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * sales_channel: + * $ref: "#/components/schemas/sales_channel" + */ +export default async (req: Request, res: Response): Promise => { + const { id } = req.params + + const salesChannelService: SalesChannelService = req.scope.resolve( + "salesChannelService" + ) + + const validatedBody = + req.validatedBody as AdminPostSalesChannelsChannelProductsBatchReq + const salesChannel = await salesChannelService.addProducts( + id, + validatedBody.product_ids.map((p) => p.id) + ) + res.status(200).json({ sales_channel: salesChannel }) +} + +export class AdminPostSalesChannelsChannelProductsBatchReq { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProductBatchSalesChannel) + product_ids: ProductBatchSalesChannel[] +} diff --git a/packages/medusa/src/api/routes/admin/sales-channels/index.ts b/packages/medusa/src/api/routes/admin/sales-channels/index.ts index a346adda1a..d25bb3de04 100644 --- a/packages/medusa/src/api/routes/admin/sales-channels/index.ts +++ b/packages/medusa/src/api/routes/admin/sales-channels/index.ts @@ -11,6 +11,7 @@ import { AdminPostSalesChannelsSalesChannelReq } from "./update-sales-channel" import { AdminPostSalesChannelsReq } from "./create-sales-channel" import { AdminGetSalesChannelsParams } from "./list-sales-channels" import { AdminDeleteSalesChannelsChannelProductsBatchReq } from "./delete-products-batch" +import { AdminPostSalesChannelsChannelProductsBatchReq } from "./add-product-batch" const route = Router() @@ -32,6 +33,11 @@ export default (app) => { "/", middlewares.wrap(require("./get-sales-channel").default) ) + salesChannelRouter.post( + "/", + transformBody(AdminPostSalesChannelsSalesChannelReq), + middlewares.wrap(require("./update-sales-channel").default) + ) salesChannelRouter.delete( "/", middlewares.wrap(require("./delete-sales-channel").default) @@ -46,6 +52,11 @@ export default (app) => { transformBody(AdminDeleteSalesChannelsChannelProductsBatchReq), middlewares.wrap(require("./delete-products-batch").default) ) + salesChannelRouter.post( + "/products/batch", + transformBody(AdminPostSalesChannelsChannelProductsBatchReq), + middlewares.wrap(require("./add-product-batch").default) + ) route.post( "/", @@ -53,12 +64,6 @@ export default (app) => { middlewares.wrap(require("./create-sales-channel").default) ) - route.post( - "/:id", - transformBody(AdminPostSalesChannelsSalesChannelReq), - middlewares.wrap(require("./update-sales-channel").default) - ) - return app } @@ -68,10 +73,6 @@ export type AdminSalesChannelsRes = { export type AdminSalesChannelsDeleteRes = DeleteResponse -export type AdminSalesChannelListRes = PaginatedResponse & { - sales_channels: SalesChannel[] -} - export type AdminSalesChannelsListRes = PaginatedResponse & { sales_channels: SalesChannel[] } @@ -82,3 +83,4 @@ export * from "./list-sales-channels" export * from "./update-sales-channel" export * from "./delete-sales-channel" export * from "./delete-products-batch" +export * from "./add-product-batch" diff --git a/packages/medusa/src/repositories/sales-channel.ts b/packages/medusa/src/repositories/sales-channel.ts index 6dec47a9bb..a2d97b3c2c 100644 --- a/packages/medusa/src/repositories/sales-channel.ts +++ b/packages/medusa/src/repositories/sales-channel.ts @@ -44,4 +44,21 @@ export class SalesChannelRepository extends Repository { }) .execute() } + + async addProducts( + salesChannelId: string, + productIds: string[] + ): Promise { + await this.createQueryBuilder() + .insert() + .into("product_sales_channel") + .values( + productIds.map((id) => ({ + sales_channel_id: salesChannelId, + product_id: id, + })) + ) + .orIgnore() + .execute() + } } diff --git a/packages/medusa/src/services/__mocks__/sales-channel.js b/packages/medusa/src/services/__mocks__/sales-channel.js index 6cfd4fef17..9c3310051c 100644 --- a/packages/medusa/src/services/__mocks__/sales-channel.js +++ b/packages/medusa/src/services/__mocks__/sales-channel.js @@ -51,6 +51,10 @@ export const SalesChannelServiceMock = { removeProducts: jest.fn().mockImplementation((id, productIds) => { return Promise.resolve() }), + + addProducts: jest.fn().mockImplementation((id, productIds) => { + return Promise.resolve() + }), } const mock = jest.fn().mockImplementation(() => { diff --git a/packages/medusa/src/services/__tests__/sales-channel.ts b/packages/medusa/src/services/__tests__/sales-channel.ts index bb90e96707..8832bf4ef9 100644 --- a/packages/medusa/src/services/__tests__/sales-channel.ts +++ b/packages/medusa/src/services/__tests__/sales-channel.ts @@ -1,10 +1,11 @@ import { IdMap, MockManager, MockRepository } from "medusa-test-utils" -import { FindConditions, FindOneOptions } from "typeorm" -import { SalesChannel } from "../../models" -import { EventBusService, StoreService } from "../index" import SalesChannelService from "../sales-channel" import { EventBusServiceMock } from "../__mocks__/event-bus" -import { store, StoreServiceMock } from "../__mocks__/store" +import { EventBusService, ProductService, StoreService } from "../index" +import { FindConditions, FindOneOptions } from "typeorm" +import { SalesChannel } from "../../models" +import { ProductServiceMock } from "../__mocks__/product"; +import { store, StoreServiceMock } from "../__mocks__/store"; describe("SalesChannelService", () => { const salesChannelData = { @@ -15,7 +16,10 @@ describe("SalesChannelService", () => { const salesChannelRepositoryMock = { ...MockRepository({ - findOne: jest.fn().mockImplementation((queryOrId: string | FindOneOptions): any => { + findOne: jest + .fn() + .mockImplementation( + (queryOrId: string | FindOneOptions): any => { return Promise.resolve({ id: typeof queryOrId === "string" @@ -40,7 +44,7 @@ describe("SalesChannelService", () => { ...salesChannel }), softRemove: jest.fn().mockImplementation((id: string): any => { - return Promise.resolve() + return Promise.resolve() }), }), getFreeTextSearchResultsAndCount: jest.fn().mockImplementation(() => @@ -49,9 +53,17 @@ describe("SalesChannelService", () => { id: IdMap.getId("sales_channel_1"), ...salesChannelData }, - ]), + ]) ), removeProducts: jest.fn().mockImplementation((id: string, productIds: string[]): any => { + Promise.resolve([ + { + id: IdMap.getId("sales_channel_1"), + ...salesChannelData + }, + ]) + }), + addProducts: jest.fn().mockImplementation((id: string, productIds: string[]): any => { return Promise.resolve() }), } @@ -62,6 +74,7 @@ describe("SalesChannelService", () => { eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, + productService: ProductServiceMock as unknown as ProductService, }) beforeEach(() => { @@ -84,6 +97,7 @@ describe("SalesChannelService", () => { manager: MockManager, eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, + productService: ProductServiceMock as unknown as ProductService, storeService: { ...StoreServiceMock, retrieve: jest.fn().mockImplementation(() => { @@ -116,6 +130,7 @@ describe("SalesChannelService", () => { eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, + productService: ProductServiceMock as unknown as ProductService, }) beforeEach(() => { @@ -146,6 +161,7 @@ describe("SalesChannelService", () => { eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, + productService: ProductServiceMock as unknown as ProductService, }) const update = { @@ -181,6 +197,7 @@ describe("SalesChannelService", () => { eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, + productService: ProductServiceMock as unknown as ProductService, }) afterEach(() => { @@ -244,6 +261,7 @@ describe("SalesChannelService", () => { manager: MockManager, eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, + productService: ProductServiceMock as unknown as ProductService, storeService: { ...StoreServiceMock, retrieve: jest.fn().mockImplementation(() => { @@ -299,6 +317,7 @@ describe("SalesChannelService", () => { manager: MockManager, eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, + productService: ProductServiceMock as unknown as ProductService, storeService: StoreServiceMock as unknown as StoreService, }) @@ -324,4 +343,36 @@ describe("SalesChannelService", () => { }) }) }) + + describe("Add products", () => { + const salesChannelService = new SalesChannelService({ + manager: MockManager, + eventBusService: EventBusServiceMock as unknown as EventBusService, + salesChannelRepository: salesChannelRepositoryMock, + storeService: StoreServiceMock as unknown as StoreService, + productService: ProductServiceMock as unknown as ProductService, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should add a list of product to a sales channel', async () => { + const salesChannel = await salesChannelService.addProducts( + IdMap.getId("sales_channel_1"), + [IdMap.getId("sales_channel_1_product_1")] + ) + + expect(salesChannelRepositoryMock.addProducts).toHaveBeenCalledTimes(1) + expect(salesChannelRepositoryMock.addProducts).toHaveBeenCalledWith( + IdMap.getId("sales_channel_1"), + [IdMap.getId("sales_channel_1_product_1")] + ) + expect(salesChannel).toBeTruthy() + expect(salesChannel).toEqual({ + id: IdMap.getId("sales_channel_1"), + ...salesChannelData, + }) + }) + }) }) diff --git a/packages/medusa/src/services/sales-channel.ts b/packages/medusa/src/services/sales-channel.ts index 8abd81128c..90cd5ba89a 100644 --- a/packages/medusa/src/services/sales-channel.ts +++ b/packages/medusa/src/services/sales-channel.ts @@ -12,12 +12,15 @@ import { import { buildQuery } from "../utils" import EventBusService from "./event-bus" import StoreService from "./store" +import { formatException, PostgresError } from "../utils/exception-formatter" +import ProductService from "./product" type InjectedDependencies = { salesChannelRepository: typeof SalesChannelRepository eventBusService: EventBusService manager: EntityManager storeService: StoreService + productService: ProductService } class SalesChannelService extends TransactionBaseService { @@ -33,12 +36,14 @@ class SalesChannelService extends TransactionBaseService { protected readonly salesChannelRepository_: typeof SalesChannelRepository protected readonly eventBusService_: EventBusService protected readonly storeService_: StoreService + protected readonly productService_: ProductService constructor({ salesChannelRepository, eventBusService, manager, storeService, + productService, }: InjectedDependencies) { // eslint-disable-next-line prefer-rest-params super(arguments[0]) @@ -47,6 +52,7 @@ class SalesChannelService extends TransactionBaseService { this.salesChannelRepository_ = salesChannelRepository this.eventBusService_ = eventBusService this.storeService_ = storeService + this.productService_ = productService } /** @@ -265,6 +271,48 @@ class SalesChannelService extends TransactionBaseService { return await this.retrieve(salesChannelId) }) } + + /** + * Add a batch of product to a sales channel + * @param salesChannelId - The id of the sales channel on which to add the products + * @param productIds - The products ids to attach to the sales channel + * @return the sales channel on which the products have been added + */ + async addProducts( + salesChannelId: string, + productIds: string[] + ): Promise { + return await this.atomicPhase_( + async (transactionManager) => { + const salesChannelRepo = transactionManager.getCustomRepository( + this.salesChannelRepository_ + ) + + await salesChannelRepo.addProducts(salesChannelId, productIds) + + return await this.retrieve(salesChannelId) + }, + async (error: { code: string }) => { + if (error.code === PostgresError.FOREIGN_KEY_ERROR) { + const existingProducts = await this.productService_.list({ + id: productIds, + }) + + const nonExistingProducts = productIds.filter( + (cId) => existingProducts.findIndex((el) => el.id === cId) === -1 + ) + + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `The following product ids do not exist: ${JSON.stringify( + nonExistingProducts.join(", ") + )}` + ) + } + throw formatException(error) + } + ) + } } export default SalesChannelService