From 0219a8677ba18caa5ab4b3cd65efd9cb48805c52 Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Mon, 18 Mar 2024 21:57:29 +0100 Subject: [PATCH] feat: Sales Channels API routes + workflows (#6722) **What** - Admin sales channels API routes - Workflows for creating, updating, and deleting sales channels - Align `ISalesChannelModuleService` interface with update + upsert conventions - Integrate v2 tests into v1 sales channels integration test suite - Add metadata to sales channel entity Sales channel <> Product management will come in a follow-up PR. On the integration tests, creating, updating, and deleting are passing with the V2 flag enabled. You'll have to take my word for it (or run them yourself), as they are not included in the CI. --- .../__snapshots__/sales-channels.js.snap | 6 +- .../api/__tests__/admin/sales-channels.js | 2043 ++++++++--------- .../src/customer/steps/delete-customers.ts | 2 +- packages/core-flows/src/index.ts | 3 +- .../core-flows/src/sales-channel/index.ts | 2 + .../steps/create-sales-channels.ts | 38 + .../steps/delete-sales-channels.ts | 30 + .../src/sales-channel/steps/index.ts | 3 + .../steps/update-sales-channels.ts | 55 + .../workflows/create-sales-channels.ts | 13 + .../workflows/delete-sales-channels.ts | 12 + .../src/sales-channel/workflows/index.ts | 3 + .../workflows/update-sales-channels.ts | 22 + .../api-v2/admin/sales-channels/[id]/route.ts | 85 + .../admin/sales-channels/middlewares.ts | 59 + .../admin/sales-channels/query-config.ts | 19 + .../src/api-v2/admin/sales-channels/route.ts | 65 + .../api-v2/admin/sales-channels/validators.ts | 102 + packages/medusa/src/api-v2/middlewares.ts | 2 + .../services/sales-channel-module.spec.ts | 21 +- .../.snapshot-medusa-sales-channel-tst.json | 9 + .../src/migrations/Migration20240115152146.ts | 2 +- .../sales-channel/src/models/sales-channel.ts | 3 + .../src/services/sales-channel-module.ts | 96 +- packages/sales-channel/src/types/index.ts | 4 +- packages/types/src/sales-channel/common.ts | 4 +- packages/types/src/sales-channel/mutations.ts | 35 +- packages/types/src/sales-channel/service.ts | 48 +- 28 files changed, 1640 insertions(+), 1146 deletions(-) create mode 100644 packages/core-flows/src/sales-channel/index.ts create mode 100644 packages/core-flows/src/sales-channel/steps/create-sales-channels.ts create mode 100644 packages/core-flows/src/sales-channel/steps/delete-sales-channels.ts create mode 100644 packages/core-flows/src/sales-channel/steps/index.ts create mode 100644 packages/core-flows/src/sales-channel/steps/update-sales-channels.ts create mode 100644 packages/core-flows/src/sales-channel/workflows/create-sales-channels.ts create mode 100644 packages/core-flows/src/sales-channel/workflows/delete-sales-channels.ts create mode 100644 packages/core-flows/src/sales-channel/workflows/index.ts create mode 100644 packages/core-flows/src/sales-channel/workflows/update-sales-channels.ts create mode 100644 packages/medusa/src/api-v2/admin/sales-channels/[id]/route.ts create mode 100644 packages/medusa/src/api-v2/admin/sales-channels/middlewares.ts create mode 100644 packages/medusa/src/api-v2/admin/sales-channels/query-config.ts create mode 100644 packages/medusa/src/api-v2/admin/sales-channels/route.ts create mode 100644 packages/medusa/src/api-v2/admin/sales-channels/validators.ts diff --git a/integration-tests/api/__tests__/admin/__snapshots__/sales-channels.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/sales-channels.js.snap index cff285c22f..76a8c7df39 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/sales-channels.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/sales-channels.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`sales channels DELETE /admin/sales-channels/:id should delete the requested sales channel 1`] = ` +exports[` DELETE /admin/sales-channels/:id should delete the requested sales channel 1`] = ` Object { "deleted": true, "id": Any, @@ -8,7 +8,7 @@ Object { } `; -exports[`sales channels POST /admin/sales-channels successfully creates a disabled sales channel 1`] = ` +exports[` POST /admin/sales-channels successfully creates a disabled sales channel 1`] = ` Object { "sales_channel": ObjectContaining { "is_disabled": true, @@ -17,7 +17,7 @@ Object { } `; -exports[`sales channels POST /admin/sales-channels successfully creates a sales channel 1`] = ` +exports[` POST /admin/sales-channels successfully creates a sales channel 1`] = ` Object { "sales_channel": ObjectContaining { "description": "sales channel description", diff --git a/integration-tests/api/__tests__/admin/sales-channels.js b/integration-tests/api/__tests__/admin/sales-channels.js index 15cd0d357e..663c0837c2 100644 --- a/integration-tests/api/__tests__/admin/sales-channels.js +++ b/integration-tests/api/__tests__/admin/sales-channels.js @@ -1,21 +1,7 @@ -const path = require("path") - -const { SalesChannel, Product } = require("@medusajs/medusa") - -const { useApi } = require("../../../environment-helpers/use-api") -const { useDb } = require("../../../environment-helpers/use-db") - -const adminSeeder = require("../../../helpers/admin-seeder") -const { - simpleSalesChannelFactory, - simpleProductFactory, -} = require("../../../factories") -const { simpleOrderFactory } = require("../../../factories") -const orderSeeder = require("../../../helpers/order-seeder") -const productSeeder = require("../../../helpers/product-seeder") - -const startServerWithEnvironment = - require("../../../environment-helpers/start-server-with-environment").default +const { ModuleRegistrationName } = require("@medusajs/modules-sdk") +const { medusaIntegrationTestRunner } = require("medusa-test-utils") +const { createAdminUser } = require("../../../helpers/create-admin-user") +const { breaking } = require("../../../helpers/breaking") const adminReqConfig = { headers: { @@ -23,869 +9,614 @@ const adminReqConfig = { }, } +let { simpleSalesChannelFactory, simpleProductFactory, simpleOrderFactory } = {} +let { SalesChannel, Product } = {} +let orderSeeder +let productSeeder + jest.setTimeout(60000) -describe("sales channels", () => { - let medusaProcess - let dbConnection +medusaIntegrationTestRunner({ + // env: { MEDUSA_FF_MEDUSA_V2: true }, + testSuite: ({ dbConnection, getContainer, api }) => { + let container + let salesChannelService - beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")) - const [process, connection] = await startServerWithEnvironment({ - cwd, - env: { MEDUSA_FF_SALES_CHANNELS: true }, + beforeAll(() => { + ;({ + simpleProductFactory, + simpleSalesChannelFactory, + simpleOrderFactory, + } = require("../../../factories")) + ;({ SalesChannel, Product } = require("@medusajs/medusa")) + + orderSeeder = require("../../../helpers/order-seeder") + productSeeder = require("../../../helpers/product-seeder") }) - dbConnection = connection - medusaProcess = process - }) - - afterAll(async () => { - const db = useDb() - await db.shutdown() - - medusaProcess.kill() - }) - - describe("GET /admin/sales-channels/:id", () => { - let salesChannel beforeEach(async () => { - await adminSeeder(dbConnection) - salesChannel = await simpleSalesChannelFactory(dbConnection, { - name: "test name", - description: "test description", - }) - }) + container = getContainer() + await createAdminUser(dbConnection, adminReqConfig, container) - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("should retrieve the requested sales channel", async () => { - const api = useApi() - const response = await api.get( - `/admin/sales-channels/${salesChannel.id}`, - adminReqConfig - ) - - expect(response.status).toEqual(200) - expect(response.data.sales_channel).toBeTruthy() - expect(response.data.sales_channel).toEqual( - expect.objectContaining({ - id: expect.any(String), - name: salesChannel.name, - description: salesChannel.description, - created_at: expect.any(String), - updated_at: expect.any(String), - }) + salesChannelService = container.resolve( + ModuleRegistrationName.SALES_CHANNEL ) }) - }) - describe("GET /admin/sales-channels", () => { - let salesChannel1 - let salesChannel2 + describe("GET /admin/sales-channels/:id", () => { + let salesChannel - beforeEach(async () => { - await adminSeeder(dbConnection) - salesChannel1 = await simpleSalesChannelFactory(dbConnection, { - name: "test name", - description: "test description", + beforeEach(async () => { + salesChannel = await breaking( + async () => { + return await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + }) + }, + async () => { + return await salesChannelService.create({ + name: "test name", + description: "test description", + }) + } + ) }) - salesChannel2 = await simpleSalesChannelFactory(dbConnection, { - name: "test name 2", - description: "test description 2", + + it("should retrieve the requested sales channel", async () => { + const response = await api.get( + `/admin/sales-channels/${salesChannel.id}`, + adminReqConfig + ) + + expect(response.status).toEqual(200) + expect(response.data.sales_channel).toBeTruthy() + expect(response.data.sales_channel).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: salesChannel.name, + description: salesChannel.description, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) }) }) - afterEach(async () => { - const db = useDb() - await db.teardown() - }) + describe("GET /admin/sales-channels", () => { + let salesChannel1 + let salesChannel2 - it("should list the sales channel", async () => { - const api = useApi() - const response = await api.get(`/admin/sales-channels/`, adminReqConfig) + beforeEach(async () => { + salesChannel1 = await breaking( + async () => { + return await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + }) + }, + async () => { + return await salesChannelService.create({ + name: "test name", + description: "test description", + }) + } + ) - expect(response.status).toEqual(200) - expect(response.data.sales_channels).toBeTruthy() - expect(response.data.sales_channels.length).toBe(2) - expect(response.data).toEqual( - expect.objectContaining({ + salesChannel2 = await breaking( + async () => { + return await simpleSalesChannelFactory(dbConnection, { + name: "test name 2", + description: "test description 2", + }) + }, + async () => { + return await salesChannelService.create({ + name: "test name 2", + description: "test description 2", + }) + } + ) + }) + + it("should list the sales channel", async () => { + const response = await api.get(`/admin/sales-channels`, adminReqConfig) + + expect(response.status).toEqual(200) + expect(response.data.sales_channels).toBeTruthy() + expect(response.data.sales_channels.length).toBe( + breaking( + () => 3, // Two created + the default one + () => 2 // This will not be breaking once the default sales channel is created in the new API + ) + ) + expect(response.data).toEqual( + expect.objectContaining({ + sales_channels: expect.arrayContaining([ + expect.objectContaining({ + name: salesChannel1.name, + description: salesChannel1.description, + }), + expect.objectContaining({ + name: salesChannel2.name, + description: salesChannel2.description, + }), + ]), + }) + ) + }) + + it("should list the sales channel using filters", async () => { + const response = await breaking( + async () => { + return await api.get(`/admin/sales-channels?q=2`, adminReqConfig) + }, + () => undefined + ) + + breaking( + () => { + expect(response.status).toEqual(200) + expect(response.data.sales_channels).toBeTruthy() + expect(response.data.sales_channels.length).toBe(1) + expect(response.data).toEqual({ + count: 1, + limit: 20, + offset: 0, + sales_channels: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + name: salesChannel2.name, + description: salesChannel2.description, + is_disabled: false, + deleted_at: null, + created_at: expect.any(String), + updated_at: expect.any(String), + }), + ]), + }) + }, + () => { + // TODO: Free text search is not supported in the new sales channel API (yet) + expect(response).toBeUndefined() + } + ) + }) + + it("should list the sales channel using properties filters", async () => { + const response = await api.get( + `/admin/sales-channels?name=test+name`, + adminReqConfig + ) + + expect(response.status).toEqual(200) + expect(response.data.sales_channels).toBeTruthy() + expect(response.data.sales_channels.length).toBe(1) + expect(response.data).toEqual({ + count: 1, + limit: 20, + offset: 0, sales_channels: expect.arrayContaining([ expect.objectContaining({ + id: expect.any(String), name: salesChannel1.name, description: salesChannel1.description, - }), - expect.objectContaining({ - name: salesChannel2.name, - description: salesChannel2.description, + is_disabled: false, + deleted_at: null, + created_at: expect.any(String), + updated_at: expect.any(String), }), ]), }) - ) - }) - - it("should list the sales channel using free text search", async () => { - const api = useApi() - const response = await api.get( - `/admin/sales-channels/?q=2`, - adminReqConfig - ) - - expect(response.status).toEqual(200) - expect(response.data.sales_channels).toBeTruthy() - expect(response.data.sales_channels.length).toBe(1) - expect(response.data).toEqual({ - count: 1, - limit: 20, - offset: 0, - sales_channels: expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(String), - name: salesChannel2.name, - description: salesChannel2.description, - is_disabled: false, - deleted_at: null, - created_at: expect.any(String), - updated_at: expect.any(String), - }), - ]), }) }) - it("should list the sales channel using properties filters", async () => { - const api = useApi() - const response = await api.get( - `/admin/sales-channels/?name=test+name`, - adminReqConfig - ) - - expect(response.status).toEqual(200) - expect(response.data.sales_channels).toBeTruthy() - expect(response.data.sales_channels.length).toBe(1) - expect(response.data).toEqual({ - count: 1, - limit: 20, - offset: 0, - sales_channels: expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(String), - name: salesChannel1.name, - description: salesChannel1.description, - is_disabled: false, - deleted_at: null, - created_at: expect.any(String), - updated_at: expect.any(String), - }), - ]), - }) - }) - }) - - describe("POST /admin/sales-channels/:id", () => { - let sc - - beforeEach(async () => { - await adminSeeder(dbConnection) - sc = await simpleSalesChannelFactory(dbConnection, { - name: "test name", - description: "test description", - }) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("updates sales channel properties", async () => { - const api = useApi() - - const payload = { - name: "updated name", - description: "updated description", - is_disabled: true, - } - - const response = await api.post( - `/admin/sales-channels/${sc.id}`, - payload, - { - headers: { - "x-medusa-access-token": "test_token", - }, - } - ) - - expect(response.status).toEqual(200) - expect(response.data.sales_channel).toEqual( - expect.objectContaining({ - id: expect.any(String), - name: payload.name, - description: payload.description, - is_disabled: payload.is_disabled, - created_at: expect.any(String), - updated_at: expect.any(String), - }) - ) - }) - }) - - describe("POST /admin/sales-channels", () => { - beforeEach(async () => { - await adminSeeder(dbConnection) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("successfully creates a disabled sales channel", async () => { - const api = useApi() - - const newSalesChannel = { - name: "sales channel name", - is_disabled: true, - } - - const response = await api - .post("/admin/sales-channels", newSalesChannel, adminReqConfig) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.sales_channel).toBeTruthy() - - expect(response.data).toMatchSnapshot({ - sales_channel: expect.objectContaining({ - name: newSalesChannel.name, - is_disabled: true, - }), - }) - }) - - it("successfully creates a sales channel", async () => { - const api = useApi() - - const newSalesChannel = { - name: "sales channel name", - description: "sales channel description", - } - - const response = await api - .post("/admin/sales-channels", newSalesChannel, adminReqConfig) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.sales_channel).toBeTruthy() - - expect(response.data).toMatchSnapshot({ - sales_channel: expect.objectContaining({ - name: newSalesChannel.name, - description: newSalesChannel.description, - is_disabled: false, - }), - }) - }) - }) - - describe("DELETE /admin/sales-channels/:id", () => { - let salesChannel - - beforeEach(async () => { - await adminSeeder(dbConnection) - salesChannel = await simpleSalesChannelFactory(dbConnection, { - name: "test name", - description: "test description", - }) - - await simpleSalesChannelFactory(dbConnection, { - name: "Default channel", - id: "test-channel", - is_default: true, - }) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("should delete the requested sales channel", async () => { - const api = useApi() - - let deletedSalesChannel = await dbConnection.manager.findOne( - SalesChannel, - { - where: { id: salesChannel.id }, - withDeleted: true, - } - ) - - expect(deletedSalesChannel.id).toEqual(salesChannel.id) - expect(deletedSalesChannel.deleted_at).toEqual(null) - - const response = await api.delete( - `/admin/sales-channels/${salesChannel.id}`, - adminReqConfig - ) - - expect(response.status).toEqual(200) - expect(response.data).toMatchSnapshot({ - deleted: true, - id: expect.any(String), - object: "sales-channel", - }) - - deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, { - where: { id: salesChannel.id }, - withDeleted: true, - }) - - expect(deletedSalesChannel.id).toEqual(salesChannel.id) - expect(deletedSalesChannel.deleted_at).not.toEqual(null) - }) - - it("should delete the requested sales channel idempotently", async () => { - const api = useApi() - - let deletedSalesChannel = await dbConnection.manager.findOne( - SalesChannel, - { - where: { id: salesChannel.id }, - withDeleted: true, - } - ) - - expect(deletedSalesChannel.id).toEqual(salesChannel.id) - expect(deletedSalesChannel.deleted_at).toEqual(null) - - let response = await api.delete( - `/admin/sales-channels/${salesChannel.id}`, - adminReqConfig - ) - - expect(response.status).toEqual(200) - expect(response.data).toEqual({ - id: expect.any(String), - object: "sales-channel", - deleted: true, - }) - - deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, { - where: { id: salesChannel.id }, - withDeleted: true, - }) - - expect(deletedSalesChannel.id).toEqual(salesChannel.id) - expect(deletedSalesChannel.deleted_at).not.toEqual(null) - - response = await api.delete( - `/admin/sales-channels/${salesChannel.id}`, - adminReqConfig - ) - - expect(response.status).toEqual(200) - expect(response.data).toEqual({ - id: expect.any(String), - object: "sales-channel", - deleted: true, - }) - - deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, { - where: { id: salesChannel.id }, - withDeleted: true, - }) - - expect(deletedSalesChannel.id).toEqual(salesChannel.id) - expect(deletedSalesChannel.deleted_at).not.toEqual(null) - }) - - it("should throw if we attempt to delete default channel", async () => { - const api = useApi() - expect.assertions(2) - - const res = await api - .delete(`/admin/sales-channels/test-channel`, adminReqConfig) - .catch((err) => err) - - expect(res.response.status).toEqual(400) - expect(res.response.data.message).toEqual( - "You cannot delete the default sales channel" - ) - }) - }) - - describe("GET /admin/orders/:id", () => { - let order - beforeEach(async () => { - await adminSeeder(dbConnection) - - order = await simpleOrderFactory(dbConnection, { - sales_channel: { - name: "test name", - description: "test description", - }, - payment_status: "captured", - fulfillment_status: "fulfilled", - line_items: [ - { - id: "line-item", - quantity: 2, - }, - ], - }) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("expands sales channel for single", async () => { - const api = useApi() - - const response = await api.get( - `/admin/orders/${order.id}`, - adminReqConfig - ) - - expect(response.data.order.sales_channel).toBeTruthy() - expect(response.data.order.sales_channel).toEqual( - expect.objectContaining({ - id: expect.any(String), - name: "test name", - description: "test description", - is_disabled: false, - created_at: expect.any(String), - updated_at: expect.any(String), - }) - ) - }) - - it("creates swap with order sales channel", async () => { - const api = useApi() - - const product = await simpleProductFactory(dbConnection, { - variants: [{ id: "test-variant", inventory_quantity: 100 }], - }) - - const swap = await api.post( - `/admin/orders/${order.id}/swaps`, - { - return_items: [ - { - item_id: "line-item", - quantity: 1, - }, - ], - additional_items: [{ variant_id: "test-variant", quantity: 1 }], - }, - adminReqConfig - ) - - expect(swap.status).toEqual(200) - - const cartId = swap.data.order.swaps[0].cart_id - - const swapCart = await api.get(`/store/carts/${cartId}`) - - expect(swapCart.data.cart.sales_channel_id).toEqual( - order.sales_channel_id - ) - }) - - it("creates swap with provided sales channel", async () => { - const api = useApi() - - const sc = await simpleSalesChannelFactory(dbConnection, {}) - - const product = await simpleProductFactory(dbConnection, { - variants: [{ id: "test-variant", inventory_quantity: 100 }], - }) - - const swap = await api.post( - `/admin/orders/${order.id}/swaps`, - { - return_items: [ - { - item_id: "line-item", - quantity: 1, - }, - ], - sales_channel_id: sc.id, - additional_items: [{ variant_id: "test-variant", quantity: 1 }], - }, - adminReqConfig - ) - - expect(swap.status).toEqual(200) - - const cartId = swap.data.order.swaps[0].cart_id - - const swapCart = await api.get(`/store/carts/${cartId}`) - - expect(swapCart.data.cart.sales_channel_id).toEqual(sc.id) - }) - }) - - describe("GET /admin/orders?expand=sales_channels", () => { - beforeEach(async () => { - await adminSeeder(dbConnection) - - await simpleOrderFactory(dbConnection, { - sales_channel: { - name: "test name", - description: "test description", - }, - }) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("expands sales channel with parameter", async () => { - const api = useApi() - - const response = await api.get( - "/admin/orders?expand=sales_channel", - adminReqConfig - ) - - expect(response.data.orders[0].sales_channel).toBeTruthy() - expect(response.data.orders[0].sales_channel).toEqual( - expect.objectContaining({ - id: expect.any(String), - name: "test name", - description: "test description", - is_disabled: false, - created_at: expect.any(String), - updated_at: expect.any(String), - }) - ) - }) - }) - - describe("GET /admin/product/:id", () => { - let product - beforeEach(async () => { - await adminSeeder(dbConnection) - - product = await simpleProductFactory(dbConnection, { - sales_channels: [ - { - name: "webshop", - description: "Webshop sales channel", - }, - { - name: "amazon", - description: "Amazon sales channel", - }, - ], - }) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("returns product with sales channel", async () => { - const api = useApi() - - const response = await api - .get(`/admin/products/${product.id}`, adminReqConfig) - .catch((err) => console.log(err)) - - expect(response.data.product.sales_channels).toBeTruthy() - expect(response.data.product.sales_channels).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: "webshop", - description: "Webshop sales channel", - is_disabled: false, - }), - expect.objectContaining({ - name: "amazon", - description: "Amazon sales channel", - is_disabled: false, - }), - ]) - ) - }) - }) - - describe("GET /admin/products?expand[]=sales_channels", () => { - beforeEach(async () => { - await adminSeeder(dbConnection) - - await simpleProductFactory(dbConnection, { - sales_channels: [ - { - name: "webshop", - description: "Webshop sales channel", - }, - { - name: "amazon", - description: "Amazon sales channel", - }, - ], - }) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("expands sales channel with parameter", async () => { - const api = useApi() - - const response = await api.get( - "/admin/products?expand=sales_channels", - adminReqConfig - ) - - expect(response.data.products[0].sales_channels).toBeTruthy() - expect(response.data.products[0].sales_channels).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: "webshop", - description: "Webshop sales channel", - is_disabled: false, - }), - expect.objectContaining({ - name: "amazon", - description: "Amazon sales channel", - is_disabled: false, - }), - ]) - ) - }) - }) - - describe("DELETE /admin/sales-channels/:id/products/batch", () => { - let salesChannel - let product - - beforeEach(async () => { - await adminSeeder(dbConnection) - product = await simpleProductFactory(dbConnection, { - id: "product_1", - title: "test title", - }) - salesChannel = await simpleSalesChannelFactory(dbConnection, { - name: "test name", - description: "test description", - products: [product], - }) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("should remove products from a sales channel", async () => { - const api = useApi() - - let attachedProduct = await dbConnection.manager.findOne(Product, { - where: { id: product.id }, - relations: ["sales_channels"], - }) - - expect(attachedProduct.sales_channels.length).toBe(2) - expect(attachedProduct.sales_channels).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(String), - name: "test name", - description: "test description", - is_disabled: false, - }), - expect.objectContaining({ - id: expect.any(String), - is_disabled: false, - }), - ]) - ) - - const payload = { - product_ids: [{ id: product.id }], - } - - await api.delete( - `/admin/sales-channels/${salesChannel.id}/products/batch`, - { - ...adminReqConfig, - data: payload, - } - ) - // Validate idempotency - const response = await api.delete( - `/admin/sales-channels/${salesChannel.id}/products/batch`, - { - ...adminReqConfig, - data: payload, - } - ) - - expect(response.status).toEqual(200) - expect(response.data.sales_channel).toEqual( - expect.objectContaining({ - id: expect.any(String), - name: "test name", - description: "test description", - is_disabled: false, - }) - ) - - attachedProduct = await dbConnection.manager.findOne(Product, { - where: { id: product.id }, - relations: ["sales_channels"], - }) - - // default sales channel - expect(attachedProduct.sales_channels.length).toBe(1) - }) - }) - - describe("POST /admin/sales-channels/:id/products/batch", () => { - let salesChannel - let product - - beforeEach(async () => { - await adminSeeder(dbConnection) - salesChannel = await simpleSalesChannelFactory(dbConnection, { - name: "test name", - description: "test description", - }) - product = await simpleProductFactory(dbConnection, { - id: "product_1", - title: "test title", - }) - }) - - 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 }], - } - - const response = await api.post( - `/admin/sales-channels/${salesChannel.id}/products/batch`, - payload, - adminReqConfig - ) - - expect(response.status).toEqual(200) - expect(response.data.sales_channel).toEqual( - expect.objectContaining({ - 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, - }) - ) - - const attachedProduct = await dbConnection.manager.findOne(Product, { - where: { id: product.id }, - relations: ["sales_channels"], - }) - - // + default sales channel - expect(attachedProduct.sales_channels.length).toBe(2) - expect(attachedProduct.sales_channels).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(String), - name: "test name", - description: "test description", - is_disabled: false, - }), - expect.objectContaining({ - id: expect.any(String), - is_disabled: false, - }), - ]) - ) - }) - }) - - describe("/admin/orders using sales channels", () => { - describe("GET /admin/orders", () => { - let order + describe("POST /admin/sales-channels/:id", () => { + let sc beforeEach(async () => { - await adminSeeder(dbConnection) + sc = await breaking( + async () => { + return await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + }) + }, + async () => { + return await salesChannelService.create({ + name: "test name", + description: "test description", + }) + } + ) + }) + + it("updates sales channel properties", async () => { + const payload = { + name: "updated name", + description: "updated description", + is_disabled: true, + } + + const response = await api.post( + `/admin/sales-channels/${sc.id}`, + payload, + adminReqConfig + ) + + expect(response.status).toEqual(200) + expect(response.data.sales_channel).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: payload.name, + description: payload.description, + is_disabled: payload.is_disabled, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) + }) + }) + + describe("POST /admin/sales-channels", () => { + beforeEach(async () => {}) + + it("successfully creates a disabled sales channel", async () => { + const newSalesChannel = { + name: "sales channel name", + is_disabled: true, + } + + const response = await api + .post("/admin/sales-channels", newSalesChannel, adminReqConfig) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.sales_channel).toBeTruthy() + + expect(response.data).toMatchSnapshot({ + sales_channel: expect.objectContaining({ + name: newSalesChannel.name, + is_disabled: true, + }), + }) + }) + + it("successfully creates a sales channel", async () => { + const newSalesChannel = { + name: "sales channel name", + description: "sales channel description", + } + + const response = await api + .post("/admin/sales-channels", newSalesChannel, adminReqConfig) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.sales_channel).toBeTruthy() + + expect(response.data).toMatchSnapshot({ + sales_channel: expect.objectContaining({ + name: newSalesChannel.name, + description: newSalesChannel.description, + is_disabled: false, + }), + }) + }) + }) + + describe("DELETE /admin/sales-channels/:id", () => { + let salesChannel + + beforeEach(async () => { + salesChannel = await breaking( + async () => { + return await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + }) + }, + async () => { + return await salesChannelService.create({ + name: "test name", + description: "test description", + }) + } + ) + + await breaking(async () => { + await simpleSalesChannelFactory(dbConnection, { + name: "Default channel", + id: "test-channel", + is_default: true, + }) + }) + }) + + it("should delete the requested sales channel", async () => { + let toDelete = await breaking( + async () => { + return await dbConnection.manager.findOne(SalesChannel, { + where: { id: salesChannel.id }, + }) + }, + async () => { + return await salesChannelService.retrieve(salesChannel.id) + } + ) + + expect(toDelete.id).toEqual(salesChannel.id) + expect(toDelete.deleted_at).toEqual(null) + + const response = await api.delete( + `/admin/sales-channels/${salesChannel.id}`, + adminReqConfig + ) + + expect(response.status).toEqual(200) + expect(response.data).toMatchSnapshot({ + deleted: true, + id: expect.any(String), + object: "sales-channel", + }) + + const deleted = await breaking( + async () => { + return await dbConnection.manager.findOne(SalesChannel, { + where: { id: salesChannel.id }, + withDeleted: true, + }) + }, + async () => { + return await salesChannelService.retrieve(salesChannel.id, { + withDeleted: true, + }) + } + ) + + expect(deleted.id).toEqual(salesChannel.id) + expect(deleted.deleted_at).not.toEqual(null) + }) + + it("should throw if we attempt to delete default channel", async () => { + await breaking(async () => { + expect.assertions(2) + + const res = await api + .delete(`/admin/sales-channels/test-channel`, adminReqConfig) + .catch((err) => err) + + expect(res.response.status).toEqual(400) + expect(res.response.data.message).toEqual( + "You cannot delete the default sales channel" + ) + }) + }) + }) + + describe("GET /admin/orders/:id", () => { + let order + beforeEach(async () => { order = await simpleOrderFactory(dbConnection, { sales_channel: { name: "test name", description: "test description", }, - }) - await orderSeeder(dbConnection) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("should successfully lists orders that belongs to the requested sales channels", async () => { - const api = useApi() - - const response = await api.get( - `/admin/orders?sales_channel_id[]=${order.sales_channel_id}`, - { - headers: { - "x-medusa-access-token": "test_token", + payment_status: "captured", + fulfillment_status: "fulfilled", + line_items: [ + { + id: "line-item", + quantity: 2, }, - } + ], + }) + }) + + it("expands sales channel for single", async () => { + const response = await api.get( + `/admin/orders/${order.id}`, + adminReqConfig ) - expect(response.status).toEqual(200) - expect(response.data.orders.length).toEqual(1) - expect(response.data.orders).toEqual( + expect(response.data.order.sales_channel).toBeTruthy() + expect(response.data.order.sales_channel).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) + }) + + it("creates swap with order sales channel", async () => { + const product = await simpleProductFactory(dbConnection, { + variants: [{ id: "test-variant", inventory_quantity: 100 }], + }) + + const swap = await api.post( + `/admin/orders/${order.id}/swaps`, + { + return_items: [ + { + item_id: "line-item", + quantity: 1, + }, + ], + additional_items: [{ variant_id: "test-variant", quantity: 1 }], + }, + adminReqConfig + ) + + expect(swap.status).toEqual(200) + + const cartId = swap.data.order.swaps[0].cart_id + + const swapCart = await api.get(`/store/carts/${cartId}`) + + expect(swapCart.data.cart.sales_channel_id).toEqual( + order.sales_channel_id + ) + }) + + it("creates swap with provided sales channel", async () => { + const sc = await simpleSalesChannelFactory(dbConnection, {}) + + const product = await simpleProductFactory(dbConnection, { + variants: [{ id: "test-variant", inventory_quantity: 100 }], + }) + + const swap = await api.post( + `/admin/orders/${order.id}/swaps`, + { + return_items: [ + { + item_id: "line-item", + quantity: 1, + }, + ], + sales_channel_id: sc.id, + additional_items: [{ variant_id: "test-variant", quantity: 1 }], + }, + adminReqConfig + ) + + expect(swap.status).toEqual(200) + + const cartId = swap.data.order.swaps[0].cart_id + + const swapCart = await api.get(`/store/carts/${cartId}`) + + expect(swapCart.data.cart.sales_channel_id).toEqual(sc.id) + }) + }) + + describe("GET /admin/orders?expand=sales_channels", () => { + beforeEach(async () => { + await simpleOrderFactory(dbConnection, { + sales_channel: { + name: "test name", + description: "test description", + }, + }) + }) + + it("expands sales channel with parameter", async () => { + const response = await api.get( + "/admin/orders?expand=sales_channel", + adminReqConfig + ) + + expect(response.data.orders[0].sales_channel).toBeTruthy() + expect(response.data.orders[0].sales_channel).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) + }) + }) + + describe("GET /admin/product/:id", () => { + let product + beforeEach(async () => { + product = await simpleProductFactory(dbConnection, { + sales_channels: [ + { + name: "webshop", + description: "Webshop sales channel", + }, + { + name: "amazon", + description: "Amazon sales channel", + }, + ], + }) + }) + + it("returns product with sales channel", async () => { + const response = await api + .get(`/admin/products/${product.id}`, adminReqConfig) + .catch((err) => console.log(err)) + + expect(response.data.product.sales_channels).toBeTruthy() + expect(response.data.product.sales_channels).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: order.id, + name: "webshop", + description: "Webshop sales channel", + is_disabled: false, + }), + expect.objectContaining({ + name: "amazon", + description: "Amazon sales channel", + is_disabled: false, }), ]) ) }) }) - }) - describe("/admin/products using sales channels", () => { - describe("GET /admin/products", () => { - const productData = { - id: "product-sales-channel-1", - title: "test description", - } + describe("GET /admin/products?expand[]=sales_channels", () => { + beforeEach(async () => { + await simpleProductFactory(dbConnection, { + sales_channels: [ + { + name: "webshop", + description: "Webshop sales channel", + }, + { + name: "amazon", + description: "Amazon sales channel", + }, + ], + }) + }) + + it("expands sales channel with parameter", async () => { + const response = await api.get( + "/admin/products?expand=sales_channels", + adminReqConfig + ) + + expect(response.data.products[0].sales_channels).toBeTruthy() + expect(response.data.products[0].sales_channels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "webshop", + description: "Webshop sales channel", + is_disabled: false, + }), + expect.objectContaining({ + name: "amazon", + description: "Amazon sales channel", + is_disabled: false, + }), + ]) + ) + }) + }) + + describe("DELETE /admin/sales-channels/:id/products/batch", () => { let salesChannel + let product beforeEach(async () => { - await productSeeder(dbConnection) - await adminSeeder(dbConnection) - const product = await simpleProductFactory(dbConnection, productData) + product = await simpleProductFactory(dbConnection, { + id: "product_1", + title: "test title", + }) salesChannel = await simpleSalesChannelFactory(dbConnection, { name: "test name", description: "test description", @@ -893,285 +624,439 @@ describe("sales channels", () => { }) }) - afterEach(async () => { - const db = useDb() - await db.teardown() - }) + it("should remove products from a sales channel", async () => { + let attachedProduct = await dbConnection.manager.findOne(Product, { + where: { id: product.id }, + relations: ["sales_channels"], + }) - it("should returns a list of products that belongs to the requested sales channels", async () => { - const api = useApi() - - const response = await api - .get(`/admin/products?sales_channel_id[]=${salesChannel.id}`, { - headers: { - "x-medusa-access-token": "test_token", - }, - }) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.products.length).toEqual(1) - expect(response.data.products).toEqual( + expect(attachedProduct.sales_channels.length).toBe(2) + expect(attachedProduct.sales_channels).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: productData.id, - title: productData.title, + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + }), + expect.objectContaining({ + id: expect.any(String), + is_disabled: false, + }), + ]) + ) + + const payload = { + product_ids: [{ id: product.id }], + } + + await api.delete( + `/admin/sales-channels/${salesChannel.id}/products/batch`, + { + ...adminReqConfig, + data: payload, + } + ) + // Validate idempotency + const response = await api.delete( + `/admin/sales-channels/${salesChannel.id}/products/batch`, + { + ...adminReqConfig, + data: payload, + } + ) + + expect(response.status).toEqual(200) + expect(response.data.sales_channel).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + }) + ) + + attachedProduct = await dbConnection.manager.findOne(Product, { + where: { id: product.id }, + relations: ["sales_channels"], + }) + + // default sales channel + expect(attachedProduct.sales_channels.length).toBe(1) + }) + }) + + describe("POST /admin/sales-channels/:id/products/batch", () => { + let salesChannel + let product + + beforeEach(async () => { + salesChannel = await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + }) + product = await simpleProductFactory(dbConnection, { + id: "product_1", + title: "test title", + }) + }) + + it("should add products to a sales channel", async () => { + const payload = { + product_ids: [{ id: product.id }], + } + + const response = await api.post( + `/admin/sales-channels/${salesChannel.id}/products/batch`, + payload, + adminReqConfig + ) + + expect(response.status).toEqual(200) + expect(response.data.sales_channel).toEqual( + expect.objectContaining({ + 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, + }) + ) + + const attachedProduct = await dbConnection.manager.findOne(Product, { + where: { id: product.id }, + relations: ["sales_channels"], + }) + + // + default sales channel + expect(attachedProduct.sales_channels.length).toBe(2) + expect(attachedProduct.sales_channels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + }), + expect.objectContaining({ + id: expect.any(String), + is_disabled: false, }), ]) ) }) }) - describe("POST /admin/products", () => { - let salesChannel + describe("/admin/orders using sales channels", () => { + describe("GET /admin/orders", () => { + let order - beforeEach(async () => { - await productSeeder(dbConnection) - await adminSeeder(dbConnection) - salesChannel = await simpleSalesChannelFactory(dbConnection, { - name: "test name", - description: "test description", - is_default: true, + beforeEach(async () => { + order = await simpleOrderFactory(dbConnection, { + sales_channel: { + name: "test name", + description: "test description", + }, + }) + await orderSeeder(dbConnection) }) - }) - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("should creates a product that is assigned to a sales_channel", async () => { - const api = useApi() - - const payload = { - title: "Test", - description: "test-product-description", - type: { value: "test-type" }, - options: [{ title: "size" }, { title: "color" }], - variants: [ + it("should successfully lists orders that belongs to the requested sales channels", async () => { + const response = await api.get( + `/admin/orders?sales_channel_id[]=${order.sales_channel_id}`, { - title: "Test variant", - inventory_quantity: 10, - prices: [{ currency_code: "usd", amount: 100 }], - options: [{ value: "large" }, { value: "green" }], - }, - ], - sales_channels: [{ id: salesChannel.id }], - } + headers: { + "x-medusa-access-token": "test_token", + }, + } + ) - const response = await api - .post("/admin/products", payload, { - headers: { - "x-medusa-access-token": "test_token", - }, - }) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.product).toEqual( - expect.objectContaining({ - sales_channels: [ + expect(response.status).toEqual(200) + expect(response.data.orders.length).toEqual(1) + expect(response.data.orders).toEqual( + expect.arrayContaining([ expect.objectContaining({ - id: salesChannel.id, - name: salesChannel.name, + id: order.id, }), - ], - }) - ) - }) - - it("should assign the default sales channel to a product if none is provided when creating it", async () => { - const api = useApi() - - const payload = { - title: "Product-no-saleschannel", - description: "test-product-description", - type: { value: "test-type" }, - options: [{ title: "size" }], - variants: [ - { - title: "Test variant", - inventory_quantity: 10, - prices: [{ currency_code: "usd", amount: 100 }], - options: [{ value: "large" }], - }, - ], - } - - const response = await api - .post("/admin/products", payload, { - headers: { - "x-medusa-access-token": "test_token", - }, - }) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.product).toEqual( - expect.objectContaining({ - sales_channels: [ - expect.objectContaining({ - id: salesChannel.id, - name: salesChannel.name, - }), - ], - }) - ) + ]) + ) + }) }) }) - describe("POST /admin/products/:id", () => { - let salesChannel + describe("/admin/products using sales channels", () => { + describe("GET /admin/products", () => { + const productData = { + id: "product-sales-channel-1", + title: "test description", + } + let salesChannel - beforeEach(async () => { - await productSeeder(dbConnection) - await adminSeeder(dbConnection) - salesChannel = await simpleSalesChannelFactory(dbConnection, { - name: "test name", - description: "test description", + beforeEach(async () => { + await productSeeder(dbConnection) + const product = await simpleProductFactory(dbConnection, productData) + salesChannel = await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + products: [product], + }) + }) + + it("should returns a list of products that belongs to the requested sales channels", async () => { + const response = await api + .get(`/admin/products?sales_channel_id[]=${salesChannel.id}`, { + headers: { + "x-medusa-access-token": "test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.products.length).toEqual(1) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: productData.id, + title: productData.title, + }), + ]) + ) }) }) - afterEach(async () => { - const db = useDb() - await db.teardown() + describe("POST /admin/products", () => { + let salesChannel + + beforeEach(async () => { + await productSeeder(dbConnection) + salesChannel = await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + is_default: true, + }) + }) + + it("should creates a product that is assigned to a sales_channel", async () => { + const payload = { + title: "Test", + description: "test-product-description", + type: { value: "test-type" }, + options: [{ title: "size" }, { title: "color" }], + variants: [ + { + title: "Test variant", + inventory_quantity: 10, + prices: [{ currency_code: "usd", amount: 100 }], + options: [{ value: "large" }, { value: "green" }], + }, + ], + sales_channels: [{ id: salesChannel.id }], + } + + const response = await api + .post("/admin/products", payload, { + headers: { + "x-medusa-access-token": "test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + sales_channels: [ + expect.objectContaining({ + id: salesChannel.id, + name: salesChannel.name, + }), + ], + }) + ) + }) + + it("should assign the default sales channel to a product if none is provided when creating it", async () => { + const payload = { + title: "Product-no-saleschannel", + description: "test-product-description", + type: { value: "test-type" }, + options: [{ title: "size" }], + variants: [ + { + title: "Test variant", + inventory_quantity: 10, + prices: [{ currency_code: "usd", amount: 100 }], + options: [{ value: "large" }], + }, + ], + } + + const response = await api + .post("/admin/products", payload, { + headers: { + "x-medusa-access-token": "test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + sales_channels: [ + expect.objectContaining({ + id: salesChannel.id, + name: salesChannel.name, + }), + ], + }) + ) + }) }) - it("should update a product sales channels assignation with either a sales channel, null, [] or undefined", async () => { - const api = useApi() + describe("POST /admin/products/:id", () => { + let salesChannel - let response = await api - .post( - "/admin/products/test-product", - { - sales_channels: null, - }, - { - headers: { - "x-medusa-access-token": "test_token", + beforeEach(async () => { + await productSeeder(dbConnection) + salesChannel = await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + }) + }) + + it("should update a product sales channels assignation with either a sales channel, null, [] or undefined", async () => { + let response = await api + .post( + "/admin/products/test-product", + { + sales_channels: null, }, - } - ) - .catch((err) => { - console.log(err) - }) + { + headers: { + "x-medusa-access-token": "test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) - expect(response.status).toEqual(200) - expect(response.data.product).toEqual( - expect.objectContaining({ - sales_channels: [], - }) - ) - - response = await api - .post( - "/admin/products/test-product", - { - sales_channels: [{ id: salesChannel.id }], - }, - { - headers: { - "x-medusa-access-token": "test_token", - }, - } - ) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.product).toEqual( - expect.objectContaining({ - sales_channels: [ - expect.objectContaining({ - id: salesChannel.id, - name: salesChannel.name, - }), - ], - }) - ) - - response = await api - .post( - "/admin/products/test-product", - {}, - { - headers: { - "x-medusa-access-token": "test_token", - }, - } - ) - .catch((err) => { - console.log(err) - }) - - expect(response.status).toEqual(200) - expect(response.data.product).toEqual( - expect.objectContaining({ - sales_channels: [ - expect.objectContaining({ - id: salesChannel.id, - name: salesChannel.name, - }), - ], - }) - ) - - response = await api - .post( - "/admin/products/test-product", - { + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ sales_channels: [], - }, - { - headers: { - "x-medusa-access-token": "test_token", - }, - } + }) ) - .catch((err) => { - console.log(err) - }) - expect(response.status).toEqual(200) - expect(response.data.product).toEqual( - expect.objectContaining({ - sales_channels: [], - }) - ) - }) - - it("should throw on update if the sales channels does not exists", async () => { - const api = useApi() - - const err = await api - .post( - "/admin/products/test-product", - { - sales_channels: [{ id: "fake_id" }, { id: "fake_id_2" }], - }, - { - headers: { - "x-medusa-access-token": "test_token", + response = await api + .post( + "/admin/products/test-product", + { + sales_channels: [{ id: salesChannel.id }], }, - } - ) - .catch((err) => err) + { + headers: { + "x-medusa-access-token": "test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) - expect(err.response.status).toEqual(400) - expect(err.response.data.message).toBe( - "Provided request body contains errors. Please check the data and retry the request" - ) - expect(err.response.data.errors).toEqual([ - "Sales Channels fake_id, fake_id_2 do not exist", - ]) + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + sales_channels: [ + expect.objectContaining({ + id: salesChannel.id, + name: salesChannel.name, + }), + ], + }) + ) + + response = await api + .post( + "/admin/products/test-product", + {}, + { + headers: { + "x-medusa-access-token": "test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + sales_channels: [ + expect.objectContaining({ + id: salesChannel.id, + name: salesChannel.name, + }), + ], + }) + ) + + response = await api + .post( + "/admin/products/test-product", + { + sales_channels: [], + }, + { + headers: { + "x-medusa-access-token": "test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + sales_channels: [], + }) + ) + }) + + it("should throw on update if the sales channels does not exists", async () => { + const err = await api + .post( + "/admin/products/test-product", + { + sales_channels: [{ id: "fake_id" }, { id: "fake_id_2" }], + }, + { + headers: { + "x-medusa-access-token": "test_token", + }, + } + ) + .catch((err) => err) + + expect(err.response.status).toEqual(400) + expect(err.response.data.message).toBe( + "Provided request body contains errors. Please check the data and retry the request" + ) + expect(err.response.data.errors).toEqual([ + "Sales Channels fake_id, fake_id_2 do not exist", + ]) + }) }) }) - }) + }, }) diff --git a/packages/core-flows/src/customer/steps/delete-customers.ts b/packages/core-flows/src/customer/steps/delete-customers.ts index c2917d26c8..d16bf306bb 100644 --- a/packages/core-flows/src/customer/steps/delete-customers.ts +++ b/packages/core-flows/src/customer/steps/delete-customers.ts @@ -1,6 +1,6 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { ICustomerModuleService } from "@medusajs/types" import { createStep, StepResponse } from "@medusajs/workflows-sdk" -import { ModuleRegistrationName } from "@medusajs/modules-sdk" type DeleteCustomerStepInput = string[] diff --git a/packages/core-flows/src/index.ts b/packages/core-flows/src/index.ts index c11f3e9186..12d1a64135 100644 --- a/packages/core-flows/src/index.ts +++ b/packages/core-flows/src/index.ts @@ -1,5 +1,5 @@ -export * from "./auth" export * from "./api-key" +export * from "./auth" export * from "./customer" export * from "./customer-group" export * from "./definition" @@ -14,6 +14,7 @@ export * from "./pricing" export * from "./product" export * from "./promotion" export * from "./region" +export * from "./sales-channel" export * from "./shipping-options" export * from "./store" export * from "./tax" diff --git a/packages/core-flows/src/sales-channel/index.ts b/packages/core-flows/src/sales-channel/index.ts new file mode 100644 index 0000000000..68de82c9f9 --- /dev/null +++ b/packages/core-flows/src/sales-channel/index.ts @@ -0,0 +1,2 @@ +export * from "./steps" +export * from "./workflows" diff --git a/packages/core-flows/src/sales-channel/steps/create-sales-channels.ts b/packages/core-flows/src/sales-channel/steps/create-sales-channels.ts new file mode 100644 index 0000000000..64499fd39b --- /dev/null +++ b/packages/core-flows/src/sales-channel/steps/create-sales-channels.ts @@ -0,0 +1,38 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + CreateSalesChannelDTO, + ISalesChannelModuleService, +} from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +interface StepInput { + data: CreateSalesChannelDTO[] +} + +export const createSalesChannelsStepId = "create-sales-channels" +export const createSalesChannelsStep = createStep( + createSalesChannelsStepId, + async (input: StepInput, { container }) => { + const salesChannelService = container.resolve( + ModuleRegistrationName.SALES_CHANNEL + ) + + const salesChannels = await salesChannelService.create(input.data) + + return new StepResponse( + salesChannels, + salesChannels.map((s) => s.id) + ) + }, + async (createdIds, { container }) => { + if (!createdIds) { + return + } + + const service = container.resolve( + ModuleRegistrationName.SALES_CHANNEL + ) + + await service.delete(createdIds) + } +) diff --git a/packages/core-flows/src/sales-channel/steps/delete-sales-channels.ts b/packages/core-flows/src/sales-channel/steps/delete-sales-channels.ts new file mode 100644 index 0000000000..bf56e3676e --- /dev/null +++ b/packages/core-flows/src/sales-channel/steps/delete-sales-channels.ts @@ -0,0 +1,30 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ISalesChannelModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +type DeleteSalesChannelsInput = string[] + +export const deleteSalesChannelsStepId = "delete-sales-channels" +export const deleteSalesChannelsStep = createStep( + deleteSalesChannelsStepId, + async (ids: DeleteSalesChannelsInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.SALES_CHANNEL + ) + + await service.softDelete(ids) + + return new StepResponse(void 0, ids) + }, + async (prevSalesChannelIds, { container }) => { + if (!prevSalesChannelIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.SALES_CHANNEL + ) + + await service.restore(prevSalesChannelIds) + } +) diff --git a/packages/core-flows/src/sales-channel/steps/index.ts b/packages/core-flows/src/sales-channel/steps/index.ts new file mode 100644 index 0000000000..5e6727116d --- /dev/null +++ b/packages/core-flows/src/sales-channel/steps/index.ts @@ -0,0 +1,3 @@ +export * from "./create-sales-channels" +export * from "./update-sales-channels" +export * from "./delete-sales-channels" diff --git a/packages/core-flows/src/sales-channel/steps/update-sales-channels.ts b/packages/core-flows/src/sales-channel/steps/update-sales-channels.ts new file mode 100644 index 0000000000..b02a0a4a93 --- /dev/null +++ b/packages/core-flows/src/sales-channel/steps/update-sales-channels.ts @@ -0,0 +1,55 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + FilterableSalesChannelProps, + ISalesChannelModuleService, + UpdateSalesChannelDTO, +} from "@medusajs/types" +import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +type UpdateSalesChannelsStepInput = { + selector: FilterableSalesChannelProps + update: UpdateSalesChannelDTO +} + +export const updateSalesChannelsStepId = "update-sales-channels" +export const updateSalesChannelsStep = createStep( + updateSalesChannelsStepId, + async (data: UpdateSalesChannelsStepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.SALES_CHANNEL + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ + data.update, + ]) + + const prevData = await service.list(data.selector, { + select: selects, + relations, + }) + + const channels = await service.update(data.selector, data.update) + + return new StepResponse(channels, prevData) + }, + async (prevData, { container }) => { + if (!prevData?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.SALES_CHANNEL + ) + + await service.upsert( + prevData.map((r) => ({ + id: r.id, + name: r.name, + description: r.description, + is_disabled: r.is_disabled, + metadata: r.metadata, + })) + ) + } +) diff --git a/packages/core-flows/src/sales-channel/workflows/create-sales-channels.ts b/packages/core-flows/src/sales-channel/workflows/create-sales-channels.ts new file mode 100644 index 0000000000..04e2ed834c --- /dev/null +++ b/packages/core-flows/src/sales-channel/workflows/create-sales-channels.ts @@ -0,0 +1,13 @@ +import { CreateSalesChannelDTO, SalesChannelDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createSalesChannelsStep } from "../steps/create-sales-channels" + +type WorkflowInput = { salesChannelsData: CreateSalesChannelDTO[] } + +export const createSalesChannelsWorkflowId = "create-sales-channels" +export const createSalesChannelsWorkflow = createWorkflow( + createSalesChannelsWorkflowId, + (input: WorkflowData): WorkflowData => { + return createSalesChannelsStep({ data: input.salesChannelsData }) + } +) diff --git a/packages/core-flows/src/sales-channel/workflows/delete-sales-channels.ts b/packages/core-flows/src/sales-channel/workflows/delete-sales-channels.ts new file mode 100644 index 0000000000..dbbffa1675 --- /dev/null +++ b/packages/core-flows/src/sales-channel/workflows/delete-sales-channels.ts @@ -0,0 +1,12 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { deleteSalesChannelsStep } from "../steps/delete-sales-channels" + +type WorkflowInput = { ids: string[] } + +export const deleteSalesChannelsWorkflowId = "delete-sales-channels" +export const deleteSalesChannelsWorkflow = createWorkflow( + deleteSalesChannelsWorkflowId, + (input: WorkflowData): WorkflowData => { + return deleteSalesChannelsStep(input.ids) + } +) diff --git a/packages/core-flows/src/sales-channel/workflows/index.ts b/packages/core-flows/src/sales-channel/workflows/index.ts new file mode 100644 index 0000000000..5e6727116d --- /dev/null +++ b/packages/core-flows/src/sales-channel/workflows/index.ts @@ -0,0 +1,3 @@ +export * from "./create-sales-channels" +export * from "./update-sales-channels" +export * from "./delete-sales-channels" diff --git a/packages/core-flows/src/sales-channel/workflows/update-sales-channels.ts b/packages/core-flows/src/sales-channel/workflows/update-sales-channels.ts new file mode 100644 index 0000000000..9f700ffbae --- /dev/null +++ b/packages/core-flows/src/sales-channel/workflows/update-sales-channels.ts @@ -0,0 +1,22 @@ +import { + FilterableSalesChannelProps, + SalesChannelDTO, + UpdateSalesChannelDTO, +} from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { updateSalesChannelsStep } from "../steps/update-sales-channels" + +type UpdateSalesChannelsStepInput = { + selector: FilterableSalesChannelProps + update: UpdateSalesChannelDTO +} + +export const updateSalesChannelsWorkflowId = "update-sales-channels" +export const updateSalesChannelsWorkflow = createWorkflow( + updateSalesChannelsWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + return updateSalesChannelsStep(input) + } +) diff --git a/packages/medusa/src/api-v2/admin/sales-channels/[id]/route.ts b/packages/medusa/src/api-v2/admin/sales-channels/[id]/route.ts new file mode 100644 index 0000000000..7eb775dbe1 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/sales-channels/[id]/route.ts @@ -0,0 +1,85 @@ +import { + deleteSalesChannelsWorkflow, + updateSalesChannelsWorkflow, +} from "@medusajs/core-flows" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../types/routing" +import { defaultAdminSalesChannelFields } from "../query-config" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const variables = { + id: req.params.id, + } + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "sales_channels", + variables, + fields: defaultAdminSalesChannelFields, + }) + + const [sales_channel] = await remoteQuery(queryObject) + + res.json({ sales_channel }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { errors } = await updateSalesChannelsWorkflow(req.scope).run({ + input: { + selector: { id: req.params.id }, + update: req.validatedBody, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "sales_channels", + variables: { id: req.params.id }, + fields: defaultAdminSalesChannelFields, + }) + + const [sales_channel] = await remoteQuery(queryObject) + + res.status(200).json({ sales_channel }) +} + +export const DELETE = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const id = req.params.id + + const { errors } = await deleteSalesChannelsWorkflow(req.scope).run({ + input: { ids: [id] }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ + id, + object: "sales-channel", + deleted: true, + }) +} diff --git a/packages/medusa/src/api-v2/admin/sales-channels/middlewares.ts b/packages/medusa/src/api-v2/admin/sales-channels/middlewares.ts new file mode 100644 index 0000000000..08fae4a663 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/sales-channels/middlewares.ts @@ -0,0 +1,59 @@ +import { transformBody, transformQuery } from "../../../api/middlewares" +import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" +import { authenticate } from "../../../utils/authenticate-middleware" +import * as QueryConfig from "./query-config" +import { + AdminGetSalesChannelsParams, + AdminGetSalesChannelsSalesChannelParams, + AdminPostSalesChannelsReq, + AdminPostSalesChannelsSalesChannelReq, +} from "./validators" + +export const adminSalesChannelRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["ALL"], + matcher: "/admin/sales-channels*", + middlewares: [authenticate("admin", ["bearer", "session", "api-key"])], + }, + { + method: ["GET"], + matcher: "/admin/sales-channels", + middlewares: [ + transformQuery( + AdminGetSalesChannelsParams, + QueryConfig.listTransformQueryConfig + ), + ], + }, + { + method: ["GET"], + matcher: "/admin/sales-channels/:id", + middlewares: [ + transformQuery( + AdminGetSalesChannelsSalesChannelParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/sales-channels", + middlewares: [ + transformBody(AdminPostSalesChannelsReq), + transformQuery( + AdminGetSalesChannelsSalesChannelParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/sales-channels/:id", + middlewares: [transformBody(AdminPostSalesChannelsSalesChannelReq)], + }, + { + method: ["DELETE"], + matcher: "/admin/sales-channels/:id", + middlewares: [], + }, +] diff --git a/packages/medusa/src/api-v2/admin/sales-channels/query-config.ts b/packages/medusa/src/api-v2/admin/sales-channels/query-config.ts new file mode 100644 index 0000000000..72857723de --- /dev/null +++ b/packages/medusa/src/api-v2/admin/sales-channels/query-config.ts @@ -0,0 +1,19 @@ +export const defaultAdminSalesChannelFields = [ + "id", + "name", + "description", + "is_disabled", + "created_at", + "updated_at", + "deleted_at", +] + +export const retrieveTransformQueryConfig = { + defaults: defaultAdminSalesChannelFields, + isList: false, +} + +export const listTransformQueryConfig = { + ...retrieveTransformQueryConfig, + isList: true, +} diff --git a/packages/medusa/src/api-v2/admin/sales-channels/route.ts b/packages/medusa/src/api-v2/admin/sales-channels/route.ts new file mode 100644 index 0000000000..275b3f3545 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/sales-channels/route.ts @@ -0,0 +1,65 @@ +import { createSalesChannelsWorkflow } from "@medusajs/core-flows" +import { CreateSalesChannelDTO } from "@medusajs/types" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../types/routing" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const variables = { + filters: req.filterableFields, + ...req.remoteQueryConfig.pagination, + } + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "sales_channels", + variables, + fields: req.remoteQueryConfig.fields, + }) + + const { rows: sales_channels, metadata } = await remoteQuery(queryObject) + + res.json({ + sales_channels, + count: metadata.count, + offset: metadata.skip, + limit: metadata.take, + }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const salesChannelsData = [req.validatedBody] + + const { errors } = await createSalesChannelsWorkflow(req.scope).run({ + input: { salesChannelsData }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "sales_channels", + variables: { id: req.params.id }, + fields: req.remoteQueryConfig.fields, + }) + + const [sales_channel] = await remoteQuery(queryObject) + + res.status(200).json({ sales_channel }) +} diff --git a/packages/medusa/src/api-v2/admin/sales-channels/validators.ts b/packages/medusa/src/api-v2/admin/sales-channels/validators.ts new file mode 100644 index 0000000000..2ec35b3f79 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/sales-channels/validators.ts @@ -0,0 +1,102 @@ +import { OperatorMap } from "@medusajs/types" +import { Type } from "class-transformer" +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested, +} from "class-validator" +import { FindParams, extendedFindParamsMixin } from "../../../types/common" +import { OperatorMapValidator } from "../../../types/validators/operator-map" + +export class AdminGetSalesChannelsSalesChannelParams extends FindParams {} + +export class AdminGetSalesChannelsParams extends extendedFindParamsMixin({ + limit: 20, + offset: 0, +}) { + /** + * ID to filter sales channels by. + */ + @IsString() + @IsOptional() + id?: string + + /** + * Name to filter sales channels by. + */ + @IsOptional() + @IsString() + name?: string + + /** + * Description to filter sales channels by. + */ + @IsOptional() + @IsString() + description?: string + + /** + * Date filters to apply on sales channels' `created_at` field. + */ + @IsOptional() + @ValidateNested() + @Type(() => OperatorMapValidator) + created_at?: OperatorMap + + /** + * Date filters to apply on sales channels' `updated_at` field. + */ + @IsOptional() + @ValidateNested() + @Type(() => OperatorMapValidator) + updated_at?: OperatorMap + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => AdminGetSalesChannelsParams) + $and?: AdminGetSalesChannelsParams[] + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => AdminGetSalesChannelsParams) + $or?: AdminGetSalesChannelsParams[] +} + +export class AdminPostSalesChannelsReq { + @IsString() + name: string + + @IsString() + @IsOptional() + description: string + + @IsBoolean() + @IsOptional() + is_disabled?: boolean + + @IsNotEmpty() + @IsString() + @IsOptional() + metadata?: Record +} + +export class AdminPostSalesChannelsSalesChannelReq { + @IsOptional() + @IsString() + name?: string + + @IsOptional() + @IsString() + description?: string + + @IsBoolean() + @IsOptional() + is_disabled?: boolean + + @IsNotEmpty() + @IsString() + @IsOptional() + metadata?: Record +} diff --git a/packages/medusa/src/api-v2/middlewares.ts b/packages/medusa/src/api-v2/middlewares.ts index 42812daac5..5081211d7e 100644 --- a/packages/medusa/src/api-v2/middlewares.ts +++ b/packages/medusa/src/api-v2/middlewares.ts @@ -14,6 +14,7 @@ import { adminPricingRoutesMiddlewares } from "./admin/pricing/middlewares" import { adminProductRoutesMiddlewares } from "./admin/products/middlewares" import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares" import { adminRegionRoutesMiddlewares } from "./admin/regions/middlewares" +import { adminSalesChannelRoutesMiddlewares } from "./admin/sales-channels/middlewares" import { adminStoreRoutesMiddlewares } from "./admin/stores/middlewares" import { adminTaxRateRoutesMiddlewares } from "./admin/tax-rates/middlewares" import { adminTaxRegionRoutesMiddlewares } from "./admin/tax-regions/middlewares" @@ -55,5 +56,6 @@ export const config: MiddlewaresConfig = { ...adminCollectionRoutesMiddlewares, ...adminPricingRoutesMiddlewares, ...adminFulfillmentRoutesMiddlewares, + ...adminSalesChannelRoutesMiddlewares, ], } diff --git a/packages/sales-channel/integration-tests/__tests__/services/sales-channel-module.spec.ts b/packages/sales-channel/integration-tests/__tests__/services/sales-channel-module.spec.ts index 4e2cf51419..b43d80ad88 100644 --- a/packages/sales-channel/integration-tests/__tests__/services/sales-channel-module.spec.ts +++ b/packages/sales-channel/integration-tests/__tests__/services/sales-channel-module.spec.ts @@ -4,8 +4,8 @@ import { ISalesChannelModuleService } from "@medusajs/types" import { initialize } from "../../../src" -import { DB_URL, MikroOrmWrapper } from "../../utils" import { createSalesChannels } from "../../__fixtures__" +import { DB_URL, MikroOrmWrapper } from "../../utils" jest.setTimeout(30000) @@ -84,13 +84,10 @@ describe("Sales Channel Service", () => { const id = "channel-2" it("should update the name of the SalesChannel successfully", async () => { - await service.update([ - { - id, - name: "Update name 2", - is_disabled: true, - }, - ]) + await service.update(id, { + name: "Update name 2", + is_disabled: true, + }) const channel = await service.retrieve(id) @@ -102,11 +99,9 @@ describe("Sales Channel Service", () => { let error try { - await service.update([ - { - id: "does-not-exist", - }, - ]) + await service.update("does-not-exist", { + name: "does-not-exist", + }) } catch (e) { error = e } diff --git a/packages/sales-channel/src/migrations/.snapshot-medusa-sales-channel-tst.json b/packages/sales-channel/src/migrations/.snapshot-medusa-sales-channel-tst.json index da19c5d308..8b8535569a 100644 --- a/packages/sales-channel/src/migrations/.snapshot-medusa-sales-channel-tst.json +++ b/packages/sales-channel/src/migrations/.snapshot-medusa-sales-channel-tst.json @@ -43,6 +43,15 @@ "default": "false", "mappedType": "boolean" }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, "created_at": { "name": "created_at", "type": "timestamptz", diff --git a/packages/sales-channel/src/migrations/Migration20240115152146.ts b/packages/sales-channel/src/migrations/Migration20240115152146.ts index ccf9f899f1..82a2a3dadf 100644 --- a/packages/sales-channel/src/migrations/Migration20240115152146.ts +++ b/packages/sales-channel/src/migrations/Migration20240115152146.ts @@ -3,7 +3,7 @@ import { Migration } from "@mikro-orm/migrations" export class Migration20240115152146 extends Migration { async up(): Promise { this.addSql( - 'create table if not exists "sales_channel" ("id" text not null, "name" text not null, "description" text null, "is_disabled" boolean not null default false, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "sales_channel_pkey" primary key ("id"));' + 'create table if not exists "sales_channel" ("id" text not null, "name" text not null, "description" text null, "is_disabled" boolean not null default false, "metadata" jsonb NULL, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "sales_channel_pkey" primary key ("id"));' ) this.addSql( 'create index "IDX_sales_channel_deleted_at" on "sales_channel" ("deleted_at");' diff --git a/packages/sales-channel/src/models/sales-channel.ts b/packages/sales-channel/src/models/sales-channel.ts index ab9ef280f9..ac89db208a 100644 --- a/packages/sales-channel/src/models/sales-channel.ts +++ b/packages/sales-channel/src/models/sales-channel.ts @@ -38,6 +38,9 @@ export default class SalesChannel { }) created_at: Date + @Property({ columnType: "jsonb", nullable: true }) + metadata: Record | null = null + @Property({ onCreate: () => new Date(), onUpdate: () => new Date(), diff --git a/packages/sales-channel/src/services/sales-channel-module.ts b/packages/sales-channel/src/services/sales-channel-module.ts index ded9511f50..d44232e447 100644 --- a/packages/sales-channel/src/services/sales-channel-module.ts +++ b/packages/sales-channel/src/services/sales-channel-module.ts @@ -2,6 +2,7 @@ import { Context, CreateSalesChannelDTO, DAL, + FilterableSalesChannelProps, InternalModuleDeclaration, ISalesChannelModuleService, ModuleJoinerConfig, @@ -9,10 +10,19 @@ import { SalesChannelDTO, UpdateSalesChannelDTO, } from "@medusajs/types" -import { MedusaContext, ModulesSdkUtils } from "@medusajs/utils" +import { + InjectManager, + InjectTransactionManager, + isString, + MedusaContext, + ModulesSdkUtils, + promiseAll, +} from "@medusajs/utils" import { SalesChannel } from "@models" +import { UpsertSalesChannelDTO } from "@medusajs/types" +import { UpdateSalesChanneInput } from "@types" import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" type InjectedDependencies = { @@ -51,19 +61,18 @@ export default class SalesChannelModuleService< data: CreateSalesChannelDTO[], sharedContext?: Context ): Promise - async create( data: CreateSalesChannelDTO, sharedContext?: Context ): Promise - + @InjectManager("baseRepository_") async create( data: CreateSalesChannelDTO | CreateSalesChannelDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const input = Array.isArray(data) ? data : [data] - const result = await this.salesChannelService_.create(input, sharedContext) + const result = await this.create_(input, sharedContext) return await this.baseRepository_.serialize( Array.isArray(data) ? result : result[0], @@ -73,23 +82,47 @@ export default class SalesChannelModuleService< ) } - async update( - data: UpdateSalesChannelDTO[], - sharedContext?: Context - ): Promise + @InjectTransactionManager("baseRepository_") + async create_( + data: CreateSalesChannelDTO[], + @MedusaContext() sharedContext: Context + ): Promise { + return await this.salesChannelService_.create(data, sharedContext) + } async update( + id: string, data: UpdateSalesChannelDTO, sharedContext?: Context ): Promise - async update( + selector: FilterableSalesChannelProps, + data: UpdateSalesChannelDTO, + sharedContext?: Context + ): Promise + @InjectManager("baseRepository_") + async update( + idOrSelector: string | FilterableSalesChannelProps, data: UpdateSalesChannelDTO | UpdateSalesChannelDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { - const input = Array.isArray(data) ? data : [data] + let normalizedInput: UpdateSalesChanneInput[] = [] + if (isString(idOrSelector)) { + normalizedInput = [{ id: idOrSelector, ...data }] + } else { + const channels = await this.salesChannelService_.list( + idOrSelector, + {}, + sharedContext + ) - const result = await this.salesChannelService_.update(input, sharedContext) + normalizedInput = channels.map((channel) => ({ + id: channel.id, + ...data, + })) + } + + const result = await this.update_(normalizedInput, sharedContext) return await this.baseRepository_.serialize( Array.isArray(data) ? result : result[0], @@ -98,4 +131,45 @@ export default class SalesChannelModuleService< } ) } + + @InjectTransactionManager("baseRepository_") + async update_(data: UpdateSalesChannelDTO[], sharedContext: Context) { + return await this.salesChannelService_.update(data, sharedContext) + } + + async upsert( + data: UpsertSalesChannelDTO[], + sharedContext?: Context + ): Promise + async upsert( + data: UpsertSalesChannelDTO, + sharedContext?: Context + ): Promise + @InjectTransactionManager("baseRepository_") + async upsert( + data: UpsertSalesChannelDTO | UpsertSalesChannelDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + const forUpdate = input.filter( + (channel): channel is UpdateSalesChannelDTO => !!channel.id + ) + const forCreate = input.filter( + (channel): channel is CreateSalesChannelDTO => !channel.id + ) + + const operations: Promise[] = [] + + if (forCreate.length) { + operations.push(this.create_(forCreate, sharedContext)) + } + if (forUpdate.length) { + operations.push(this.update_(forUpdate, sharedContext)) + } + + const result = (await promiseAll(operations)).flat() + return await this.baseRepository_.serialize< + SalesChannelDTO[] | SalesChannelDTO + >(Array.isArray(data) ? result : result[0]) + } } diff --git a/packages/sales-channel/src/types/index.ts b/packages/sales-channel/src/types/index.ts index 0f252977b0..773523638d 100644 --- a/packages/sales-channel/src/types/index.ts +++ b/packages/sales-channel/src/types/index.ts @@ -1,5 +1,7 @@ -import { Logger } from "@medusajs/types" +import { Logger, UpdateSalesChannelDTO } from "@medusajs/types" export type InitializeModuleInjectableDependencies = { logger?: Logger } + +export type UpdateSalesChanneInput = UpdateSalesChannelDTO & { id: string } \ No newline at end of file diff --git a/packages/types/src/sales-channel/common.ts b/packages/types/src/sales-channel/common.ts index 9f35c0519d..703c5cd23f 100644 --- a/packages/types/src/sales-channel/common.ts +++ b/packages/types/src/sales-channel/common.ts @@ -63,12 +63,12 @@ export interface FilterableSalesChannelProps /** * The IDs to filter the sales channel by. */ - id?: string[] + id?: string | string[] /** * Filter sales channels by their names. */ - name?: string[] + name?: string | string[] /** * Filter sales channels by whether they're disabled. diff --git a/packages/types/src/sales-channel/mutations.ts b/packages/types/src/sales-channel/mutations.ts index c6eae5861a..917e9f86a0 100644 --- a/packages/types/src/sales-channel/mutations.ts +++ b/packages/types/src/sales-channel/mutations.ts @@ -22,11 +22,6 @@ export interface CreateSalesChannelDTO { * The attributes to update in the sales channel. */ export interface UpdateSalesChannelDTO { - /** - * The ID of the sales channel. - */ - id: string - /** * The name of the sales channel. */ @@ -41,4 +36,34 @@ export interface UpdateSalesChannelDTO { * Whether the sales channel is disabled. */ is_disabled?: boolean + /** + * Holds custom data in key-value pairs. + */ + metadata?: Record +} + +export interface UpsertSalesChannelDTO { + /** + * The ID of the sales channel. + */ + id?: string + + /** + * The name of the sales channel. + */ + name?: string + + /** + * The description of the sales channel. + */ + description?: string | null + + /** + * Whether the sales channel is disabled. + */ + is_disabled?: boolean + /** + * Holds custom data in key-value pairs. + */ + metadata?: Record | null } diff --git a/packages/types/src/sales-channel/service.ts b/packages/types/src/sales-channel/service.ts index 87448d7275..2d1889cee8 100644 --- a/packages/types/src/sales-channel/service.ts +++ b/packages/types/src/sales-channel/service.ts @@ -1,9 +1,9 @@ -import { IModuleService } from "../modules-sdk" -import { FilterableSalesChannelProps, SalesChannelDTO } from "./common" import { FindConfig } from "../common" -import { Context } from "../shared-context" import { RestoreReturn, SoftDeleteReturn } from "../dal" -import { CreateSalesChannelDTO, UpdateSalesChannelDTO } from "./mutations" +import { IModuleService } from "../modules-sdk" +import { Context } from "../shared-context" +import { FilterableSalesChannelProps, SalesChannelDTO } from "./common" +import { CreateSalesChannelDTO, UpdateSalesChannelDTO, UpsertSalesChannelDTO } from "./mutations" /** * The main service interface for the sales channel module. @@ -39,35 +39,25 @@ export interface ISalesChannelModuleService extends IModuleService { sharedContext?: Context ): Promise - /** - * This method updates existing sales channels. - * - * @param {UpdateSalesChannelDTO[]} data - The attributes to update in each sales channel. - * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. - * @returns {Promise} The updated sales channels. - * - * @example - * {example-code} - */ - update( - data: UpdateSalesChannelDTO[], - sharedContext?: Context - ): Promise - - /** - * This method updates an existing sales channel. - * - * @param {UpdateSalesChannelDTO} data - The attributes to update in the sales channel. - * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. - * @returns {Promise} The updated sales channel. - * - * @example - * {example-code} - */ update( + channelId: string, data: UpdateSalesChannelDTO, sharedContext?: Context ): Promise + update( + selector: FilterableSalesChannelProps, + data: UpdateSalesChannelDTO, + sharedContext?: Context + ): Promise + + upsert( + data: UpsertSalesChannelDTO, + sharedContext?: Context + ): Promise + upsert( + data: UpsertSalesChannelDTO[], + sharedContext?: Context + ): Promise /** * This method deletes sales channels by their IDs.