diff --git a/integration-tests/api/__tests__/admin/publishable-api-key.js b/integration-tests/api/__tests__/admin/publishable-api-key.js index 518c59da29..c1435d133a 100644 --- a/integration-tests/api/__tests__/admin/publishable-api-key.js +++ b/integration-tests/api/__tests__/admin/publishable-api-key.js @@ -9,6 +9,11 @@ const adminSeeder = require("../../helpers/admin-seeder") const { simplePublishableApiKeyFactory, } = require("../../factories/simple-publishable-api-key-factory") +const { + simpleSalesChannelFactory, + simpleProductFactory, + simpleRegionFactory, +} = require("../../factories") jest.setTimeout(50000) @@ -18,6 +23,13 @@ const adminHeaders = { }, } +const customerData = { + email: "medusa@test.hr", + password: "medusatest", + first_name: "medusa", + last_name: "medusa", +} + describe("[MEDUSA_FF_PUBLISHABLE_API_KEYS] Publishable API keys", () => { let medusaProcess let dbConnection @@ -27,7 +39,10 @@ describe("[MEDUSA_FF_PUBLISHABLE_API_KEYS] Publishable API keys", () => { const cwd = path.resolve(path.join(__dirname, "..", "..")) const [process, connection] = await startServerWithEnvironment({ cwd, - env: { MEDUSA_FF_PUBLISHABLE_API_KEYS: true }, + env: { + MEDUSA_FF_PUBLISHABLE_API_KEYS: true, + MEDUSA_FF_SALES_CHANNELS: true, + }, verbose: false, }) dbConnection = connection @@ -280,4 +295,777 @@ describe("[MEDUSA_FF_PUBLISHABLE_API_KEYS] Publishable API keys", () => { } }) }) + + describe("POST /admin/publishable-api-keys/:id/sales-channels/batch", () => { + const pubKeyId = IdMap.getId("pubkey-get-id-batch") + let salesChannel1 + let salesChannel2 + + beforeEach(async () => { + await adminSeeder(dbConnection) + + await simplePublishableApiKeyFactory(dbConnection, { + id: pubKeyId, + }) + + salesChannel1 = await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + }) + + salesChannel2 = await simpleSalesChannelFactory(dbConnection, { + name: "test name 2", + description: "test description 2", + }) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("add sales channels to the publishable api key scope", async () => { + const api = useApi() + + const response = await api.post( + `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, + { + sales_channel_ids: [ + { id: salesChannel1.id }, + { id: salesChannel2.id }, + ], + }, + adminHeaders + ) + + const mappings = await dbConnection.manager.query( + `SELECT * + FROM publishable_api_key_sales_channel + WHERE publishable_key_id = '${pubKeyId}'` + ) + + expect(response.status).toBe(200) + + expect(mappings).toEqual([ + { + sales_channel_id: salesChannel1.id, + publishable_key_id: pubKeyId, + }, + { + sales_channel_id: salesChannel2.id, + publishable_key_id: pubKeyId, + }, + ]) + + expect(response.data.publishable_api_key).toMatchObject({ + id: pubKeyId, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + }) + + describe("DELETE /admin/publishable-api-keys/:id/sales-channels/batch", () => { + const pubKeyId = IdMap.getId("pubkey-get-id-batch-v2") + let salesChannel1 + let salesChannel2 + let salesChannel3 + + beforeEach(async () => { + await adminSeeder(dbConnection) + + await simplePublishableApiKeyFactory(dbConnection, { + id: pubKeyId, + }) + + salesChannel1 = await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + }) + + salesChannel2 = await simpleSalesChannelFactory(dbConnection, { + name: "test name 2", + description: "test description 2", + }) + + salesChannel3 = await simpleSalesChannelFactory(dbConnection, { + name: "test name 3", + description: "test description 3", + }) + + await dbConnection.manager.query( + `INSERT INTO + publishable_api_key_sales_channel + (publishable_key_id, sales_channel_id) + VALUES + ('${pubKeyId}', '${salesChannel1.id}'), + ('${pubKeyId}', '${salesChannel2.id}'), + ('${pubKeyId}', '${salesChannel3.id}');` + ) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("remove sales channels from the publishable api key scope", async () => { + const api = useApi() + + const response = await api.delete( + `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, + { + data: { + sales_channel_ids: [ + { id: salesChannel1.id }, + { id: salesChannel2.id }, + ], + }, + ...adminHeaders, + } + ) + + const mappings = await dbConnection.manager.query( + `SELECT * + FROM publishable_api_key_sales_channel + WHERE publishable_key_id = '${pubKeyId}'` + ) + + expect(response.status).toBe(200) + + expect(mappings).toEqual([ + { + sales_channel_id: salesChannel3.id, + publishable_key_id: pubKeyId, + }, + ]) + + expect(response.data.publishable_api_key).toMatchObject({ + id: pubKeyId, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + }) + + describe("GET /admin/publishable-api-keys/:id/sales-channels", () => { + const pubKeyId = IdMap.getId("pubkey-get-id-batch-v2") + let salesChannel1 + let salesChannel2 + let salesChannel3 + + beforeEach(async () => { + await adminSeeder(dbConnection) + + await simplePublishableApiKeyFactory(dbConnection, { + id: pubKeyId, + }) + + salesChannel1 = await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + }) + + salesChannel2 = await simpleSalesChannelFactory(dbConnection, { + name: "test name 2", + description: "test description 2", + }) + + salesChannel3 = await simpleSalesChannelFactory(dbConnection, { + name: "test name 3", + description: "test description 3", + }) + + await dbConnection.manager.query( + `INSERT INTO + publishable_api_key_sales_channel + (publishable_key_id, sales_channel_id) + VALUES + ('${pubKeyId}', '${salesChannel1.id}'), + ('${pubKeyId}', '${salesChannel2.id}');` + ) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("list sales channels from the publishable api key", async () => { + const api = useApi() + + const response = await api.get( + `/admin/publishable-api-keys/${pubKeyId}/sales-channels`, + adminHeaders + ) + + expect(response.status).toBe(200) + expect(response.data.sales_channels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: salesChannel1.id, + deleted_at: null, + name: "test name", + description: "test description", + is_disabled: false, + }), + expect.objectContaining({ + id: salesChannel2.id, + deleted_at: null, + name: "test name 2", + description: "test description 2", + is_disabled: false, + }), + ]) + ) + }) + }) + + describe("GET /store/products", () => { + const pubKeyId = IdMap.getId("pubkey-get-id") + + let salesChannel1 + let salesChannel2 + let product1 + let product2 + let product3 + + beforeEach(async () => { + await adminSeeder(dbConnection) + + salesChannel1 = await simpleSalesChannelFactory(dbConnection, { + name: "salesChannel1", + description: "salesChannel1", + }) + + salesChannel2 = await simpleSalesChannelFactory(dbConnection, { + name: "salesChannel2", + description: "salesChannel2", + }) + + product1 = await simpleProductFactory(dbConnection, { + title: "prod 1", + status: "published", + sales_channels: [salesChannel1], + }) + + product2 = await simpleProductFactory(dbConnection, { + title: "prod 2", + status: "published", + sales_channels: [salesChannel2], + }) + + product3 = await simpleProductFactory(dbConnection, { + title: "prod 3", + status: "published", + }) + + await simplePublishableApiKeyFactory(dbConnection, { + id: pubKeyId, + created_by: adminUserId, + }) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("returns products from a specific channel associated with a publishable key", async () => { + const api = useApi() + + await api.post( + `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, + { + sales_channel_ids: [{ id: salesChannel1.id }], + }, + adminHeaders + ) + + const response = await api.get(`/store/products`, { + headers: { + Authorization: "Bearer test_token", + "x-publishable-api-key": pubKeyId, + }, + }) + + expect(response.data.products.length).toBe(1) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: product1.id, + }), + ]) + ) + }) + + it("returns products from multiples sales channels associated with a publishable key", async () => { + const api = useApi() + + await api.post( + `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, + { + sales_channel_ids: [ + { id: salesChannel1.id }, + { id: salesChannel2.id }, + ], + }, + adminHeaders + ) + + const response = await api.get(`/store/products`, { + headers: { + Authorization: "Bearer test_token", + "x-publishable-api-key": pubKeyId, + }, + }) + + expect(response.data.products.length).toBe(2) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: product1.id, + }), + expect.objectContaining({ + id: product2.id, + }), + ]) + ) + }) + + it("SC param overrides PK channels (but SK still needs to be in the PK's scope", async () => { + const api = useApi() + + await api.post( + `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, + { + sales_channel_ids: [ + { id: salesChannel1.id }, + { id: salesChannel2.id }, + ], + }, + adminHeaders + ) + + const response = await api.get( + `/store/products?sales_channel_id[0]=${salesChannel2.id}`, + { + headers: { + Authorization: "Bearer test_token", + "x-publishable-api-key": pubKeyId, + }, + } + ) + + expect(response.data.products.length).toBe(1) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: product2.id, + }), + ]) + ) + }) + + it("returns all products if PK is not passed", async () => { + const api = useApi() + + await api.post( + `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, + { + sales_channel_ids: [ + { id: salesChannel1.id }, + { id: salesChannel2.id }, + ], + }, + adminHeaders + ) + + const response = await api.get(`/store/products`, { + headers: { + Authorization: "Bearer test_token", + // "x-publishable-api-key": pubKeyId, + }, + }) + + expect(response.data.products.length).toBe(3) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: product1.id, + }), + expect.objectContaining({ + id: product2.id, + }), + expect.objectContaining({ + id: product3.id, + }), + ]) + ) + }) + + it("returns all products if passed PK doesn't have associated channels", async () => { + const api = useApi() + + const response = await api.get(`/store/products`, { + headers: { + Authorization: "Bearer test_token", + "x-publishable-api-key": pubKeyId, + }, + }) + + expect(response.data.products.length).toBe(3) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: product1.id, + }), + expect.objectContaining({ + id: product2.id, + }), + expect.objectContaining({ + id: product3.id, + }), + ]) + ) + }) + + it("throws because sales channel param is not in the scope of passed PK", async () => { + const api = useApi() + + await api.post( + `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, + { + sales_channel_ids: [{ id: salesChannel1.id }], + }, + adminHeaders + ) + + try { + await api.get( + `/store/products?sales_channel_id[]=${salesChannel2.id}`, + { + headers: { + Authorization: "Bearer test_token", + "x-publishable-api-key": pubKeyId, + }, + } + ) + } catch (error) { + expect(error.response.status).toEqual(400) + expect(error.response.data.errors[0]).toEqual( + `Provided sales channel id param: ${salesChannel2.id} is not associated with the Publishable API Key passed in the header of the request.` + ) + } + }) + }) + + describe("GET /store/products/:id", () => { + const pubKeyId = IdMap.getId("pubkey-get-id") + + let salesChannel1 + let product1 + let product2 + + beforeEach(async () => { + await adminSeeder(dbConnection) + + salesChannel1 = await simpleSalesChannelFactory(dbConnection, { + name: "salesChannel1", + description: "salesChannel1", + }) + + product1 = await simpleProductFactory(dbConnection, { + title: "prod 1", + status: "published", + sales_channels: [salesChannel1], + }) + + product2 = await simpleProductFactory(dbConnection, { + title: "prod 2", + status: "published", + }) + + await simplePublishableApiKeyFactory(dbConnection, { + id: pubKeyId, + created_by: adminUserId, + }) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("retrieve a products from a specific channel associated with a publishable key", async () => { + const api = useApi() + + await api.post( + `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, + { + sales_channel_ids: [{ id: salesChannel1.id }], + }, + adminHeaders + ) + + const response = await api.get(`/store/products/${product1.id}`, { + headers: { + Authorization: "Bearer test_token", + "x-publishable-api-key": pubKeyId, + }, + }) + + expect(response.data.product).toEqual( + expect.objectContaining({ + id: product1.id, + }) + ) + }) + + it("return 400 because requested product is not in the SC associated with a publishable key", async () => { + const api = useApi() + + await api.post( + `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, + { + sales_channel_ids: [{ id: salesChannel1.id }], + }, + adminHeaders + ) + + const response = await api + .get(`/store/products/${product2.id}`, { + headers: { + Authorization: "Bearer test_token", + "x-publishable-api-key": pubKeyId, + }, + }) + .catch((err) => { + return err.response + }) + + expect(response.status).toEqual(400) + }) + + it("correctly returns a product if passed PK has no associated SCs", async () => { + const api = useApi() + + let response = await api + .get(`/store/products/${product1.id}`, { + headers: { + Authorization: "Bearer test_token", + "x-publishable-api-key": pubKeyId, + }, + }) + .catch((err) => { + return err.response + }) + + expect(response.status).toEqual(200) + + response = await api + .get(`/store/products/${product2.id}`, { + headers: { + Authorization: "Bearer test_token", + "x-publishable-api-key": pubKeyId, + }, + }) + .catch((err) => { + return err.response + }) + + expect(response.status).toEqual(200) + }) + }) + + describe("POST /store/carts/:id", () => { + let product + const pubKeyId = IdMap.getId("pubkey-get-id") + + beforeEach(async () => { + await adminSeeder(dbConnection) + + await simpleRegionFactory(dbConnection, { + id: "test-region", + }) + + await simplePublishableApiKeyFactory(dbConnection, { + id: pubKeyId, + created_by: adminUserId, + }) + + product = await simpleProductFactory(dbConnection, { + sales_channels: [ + { + id: "sales-channel", + name: "Sales channel", + description: "Sales channel", + }, + { + id: "sales-channel2", + name: "Sales channel2", + description: "Sales channel2", + }, + ], + }) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("should assign sales channel to order on cart completion if PK is present in the header", async () => { + const api = useApi() + + await api.post( + `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, + { + sales_channel_ids: [{ id: "sales-channel" }], + }, + adminHeaders + ) + + const customerRes = await api.post("/store/customers", customerData, { + withCredentials: true, + }) + + const createCartRes = await api.post( + "/store/carts", + { + region_id: "test-region", + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + }, + ], + }, + { + headers: { + Authorization: "Bearer test_token", + "x-publishable-api-key": pubKeyId, + }, + } + ) + + const cart = createCartRes.data.cart + + await api.post(`/store/carts/${cart.id}`, { + customer_id: customerRes.data.customer.id, + }) + + await api.post(`/store/carts/${cart.id}/payment-sessions`) + + const createdOrder = await api.post( + `/store/carts/${cart.id}/complete-cart` + ) + + expect(createdOrder.data.type).toEqual("order") + expect(createdOrder.status).toEqual(200) + expect(createdOrder.data.data).toEqual( + expect.objectContaining({ + sales_channel_id: "sales-channel", + }) + ) + }) + + it("SC from params defines where product is assigned (passed SC still has to be in the scope of PK from the header)", async () => { + const api = useApi() + + await api.post( + `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, + { + sales_channel_ids: [ + { id: "sales-channel" }, + { id: "sales-channel2" }, + ], + }, + adminHeaders + ) + + const customerRes = await api.post("/store/customers", customerData, { + withCredentials: true, + }) + + const createCartRes = await api.post( + "/store/carts", + { + sales_channel_id: "sales-channel2", + region_id: "test-region", + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + }, + ], + }, + { + headers: { + Authorization: "Bearer test_token", + "x-publishable-api-key": pubKeyId, + }, + } + ) + + const cart = createCartRes.data.cart + + await api.post(`/store/carts/${cart.id}`, { + customer_id: customerRes.data.customer.id, + }) + + await api.post(`/store/carts/${cart.id}/payment-sessions`) + + const createdOrder = await api.post( + `/store/carts/${cart.id}/complete-cart` + ) + + expect(createdOrder.data.type).toEqual("order") + expect(createdOrder.status).toEqual(200) + expect(createdOrder.data.data).toEqual( + expect.objectContaining({ + sales_channel_id: "sales-channel2", + }) + ) + }) + + it("should throw because SC id in the body is not in the scope of PK from the header", async () => { + const api = useApi() + + await api.post( + `/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`, + { + sales_channel_ids: [{ id: "sales-channel" }], + }, + adminHeaders + ) + + try { + await api.post( + "/store/carts", + { + sales_channel_id: "sales-channel2", // SC not in the PK scope + region_id: "test-region", + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + }, + ], + }, + { + headers: { + Authorization: "Bearer test_token", + "x-publishable-api-key": pubKeyId, + }, + } + ) + } catch (error) { + expect(error.response.status).toEqual(400) + expect(error.response.data.errors[0]).toEqual( + `Provided sales channel id param: sales-channel2 is not associated with the Publishable API Key passed in the header of the request.` + ) + } + }) + }) }) diff --git a/integration-tests/api/__tests__/store/cart/ff-sales-channels.js b/integration-tests/api/__tests__/store/cart/ff-sales-channels.js index c0d92f51d2..9ae859af8a 100644 --- a/integration-tests/api/__tests__/store/cart/ff-sales-channels.js +++ b/integration-tests/api/__tests__/store/cart/ff-sales-channels.js @@ -6,15 +6,9 @@ const { useApi } = require("../../../../helpers/use-api") const { useDb } = require("../../../../helpers/use-db") const { - simpleCartFactory, simpleRegionFactory, - simpleShippingOptionFactory, - simpleCustomShippingOptionFactory, simpleProductFactory, - simplePriceListFactory, - simpleDiscountFactory, } = require("../../../factories") -const { IdMap } = require("medusa-test-utils") jest.setTimeout(30000) diff --git a/packages/medusa/src/api/middlewares/publishable-api-key/extend-request-params.ts b/packages/medusa/src/api/middlewares/publishable-api-key/extend-request-params.ts new file mode 100644 index 0000000000..a71c7939bc --- /dev/null +++ b/packages/medusa/src/api/middlewares/publishable-api-key/extend-request-params.ts @@ -0,0 +1,40 @@ +import { NextFunction, Request, Response } from "express" + +import PublishableApiKeyService from "../../../services/publishable-api-key" + +export type PublishableApiKeyScopes = { + sales_channel_id: string[] +} + +/** + * The middleware, in case that a key is present in the request header, + * attaches ids of resources within the scope of the key to the req object. + * + * @param req - request object + * @param res - response object + * @param next - next middleware call + * + * @throws if sales channel id is passed as a url or body param + * but that id is not in the scope defined by the PK from the header + */ +async function extendRequestParams( + req: Request & { publishableApiKeyScopes: PublishableApiKeyScopes }, + res: Response, + next: NextFunction +) { + const pubKey = req.get("x-publishable-api-key") + + if (pubKey) { + const publishableKeyService: PublishableApiKeyService = req.scope.resolve( + "publishableApiKeyService" + ) + + req.publishableApiKeyScopes = await publishableKeyService.getResourceScopes( + pubKey + ) + } + + next() +} + +export { extendRequestParams } diff --git a/packages/medusa/src/api/middlewares/publishable-api-key/validate-product-sales-channel-association.ts b/packages/medusa/src/api/middlewares/publishable-api-key/validate-product-sales-channel-association.ts new file mode 100644 index 0000000000..d119ce734f --- /dev/null +++ b/packages/medusa/src/api/middlewares/publishable-api-key/validate-product-sales-channel-association.ts @@ -0,0 +1,46 @@ +import { NextFunction, Request, Response } from "express" + +import PublishableApiKeyService from "../../../services/publishable-api-key" +import { ProductService } from "../../../services" + +/** + * The middleware check if requested product is assigned to a SC associated with PK in the header. + * + * @param req - request object + * @param res - response object + * @param next - next middleware call + */ +async function validateProductSalesChannelAssociation( + req: Request, + res: Response, + next: NextFunction +) { + const pubKey = req.get("x-publishable-api-key") + + if (pubKey) { + const productService: ProductService = req.scope.resolve("productService") + const publishableKeyService: PublishableApiKeyService = req.scope.resolve( + "publishableApiKeyService" + ) + + const { sales_channel_id: salesChannelIds } = + await publishableKeyService.getResourceScopes(pubKey) + + if ( + salesChannelIds.length && + !(await productService.isProductInSalesChannels( + req.params.id, + salesChannelIds + )) + ) { + req.errors = req.errors ?? [] + req.errors.push( + `Product with id: ${req.params.id} is not associated with sales channels defined by the Publishable API Key passed in the header of the request.` + ) + } + } + + next() +} + +export { validateProductSalesChannelAssociation } diff --git a/packages/medusa/src/api/middlewares/publishable-api-key/validate-sales-channel-param.ts b/packages/medusa/src/api/middlewares/publishable-api-key/validate-sales-channel-param.ts new file mode 100644 index 0000000000..657038c54d --- /dev/null +++ b/packages/medusa/src/api/middlewares/publishable-api-key/validate-sales-channel-param.ts @@ -0,0 +1,46 @@ +import { NextFunction, Request, Response } from "express" + +import { PublishableApiKeyScopes } from "./extend-request-params" + +/** + * The middleware will return 400 if sales channel id is passed as an url or body param + * but that id is not in the scope of the PK from the header. + * + * NOTE: must be applied after the `extendRequestParams` middleware + * + * @param req - request object + * @param res - response object + * @param next - next middleware call + */ +async function validateSalesChannelParam( + req: Request & { publishableApiKeyScopes: PublishableApiKeyScopes }, + res: Response, + next: NextFunction +) { + const pubKey = req.get("x-publishable-api-key") + + if (pubKey) { + const scopes = req.publishableApiKeyScopes + let channelIds = req.body.sales_channel_id || req.query.sales_channel_id + + if (!channelIds) { + return next() + } + + channelIds = !Array.isArray(channelIds) ? [channelIds] : channelIds + + if ( + scopes.sales_channel_id.length && + !channelIds.every((sc) => scopes.sales_channel_id.includes(sc)) + ) { + req.errors = req.errors ?? [] + req.errors.push( + `Provided sales channel id param: ${channelIds} is not associated with the Publishable API Key passed in the header of the request.` + ) + } + } + + next() +} + +export { validateSalesChannelParam } diff --git a/packages/medusa/src/api/routes/admin/publishable-api-keys/add-channels-batch.ts b/packages/medusa/src/api/routes/admin/publishable-api-keys/add-channels-batch.ts new file mode 100644 index 0000000000..7b29f22ce5 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/publishable-api-keys/add-channels-batch.ts @@ -0,0 +1,124 @@ +import { IsArray, ValidateNested } from "class-validator" +import { Request, Response } from "express" +import { Type } from "class-transformer" +import { EntityManager } from "typeorm" + +import { ProductBatchSalesChannel } from "../../../../types/sales-channels" +import PublishableApiKeyService from "../../../../services/publishable-api-key" + +/** + * @oas [post] /publishable-api-keys/{id}/sales-channels/batch + * operationId: "PostPublishableApiKeySalesChannelsChannelsBatch" + * summary: "Add sales channel to a publishable api key scope" + * description: "Assign a batch of sales channels to a publishable api key." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the Publishable Api Key. + * requestBody: + * content: + * application/json: + * schema: + * required: + * - sales_channel_ids + * properties: + * sales_channel_ids: + * description: The IDs of the sales channels to add to the publishable api key + * type: array + * items: + * type: object + * required: + * - id + * properties: + * id: + * type: string + * description: The ID of the sales channel + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.publishableApiKeys.addSalesChannels(publishableApiKeyId, { + * sales_channel_ids: [ + * { + * id: channel_id + * } + * ] + * }) + * .then(({ publishable_api_key }) => { + * console.log(publishable_api_key.id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request POST 'https://medusa-url.com/admin/publishable-api-keys/afasf/batch' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: application/json' \ + * --data-raw '{ + * "sales_channel_ids": [ + * { + * "id": "{sales_channel_id}" + * } + * ] + * }' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Publishable Api Key + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * publishable_api_key: + * $ref: "#/components/schemas/publishable_api_key" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response): Promise => { + const validatedBody = + req.validatedBody as AdminPostPublishableApiKeySalesChannelsBatchReq + + const { id } = req.params + + const publishableApiKeyService: PublishableApiKeyService = req.scope.resolve( + "publishableApiKeyService" + ) + + const manager: EntityManager = req.scope.resolve("manager") + const publishableApiKey = await manager.transaction( + async (transactionManager) => { + await publishableApiKeyService + .withTransaction(transactionManager) + .addSalesChannels( + id, + validatedBody.sales_channel_ids.map((p) => p.id) + ) + + return await publishableApiKeyService.retrieve(id) + } + ) + + res.status(200).json({ publishable_api_key: publishableApiKey }) +} + +export class AdminPostPublishableApiKeySalesChannelsBatchReq { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProductBatchSalesChannel) + sales_channel_ids: ProductBatchSalesChannel[] +} diff --git a/packages/medusa/src/api/routes/admin/publishable-api-keys/delete-channels-batch.ts b/packages/medusa/src/api/routes/admin/publishable-api-keys/delete-channels-batch.ts new file mode 100644 index 0000000000..b3a0da6c11 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/publishable-api-keys/delete-channels-batch.ts @@ -0,0 +1,124 @@ +import { IsArray, ValidateNested } from "class-validator" +import { Request, Response } from "express" +import { Type } from "class-transformer" +import { EntityManager } from "typeorm" + +import { ProductBatchSalesChannel } from "../../../../types/sales-channels" +import PublishableApiKeyService from "../../../../services/publishable-api-key" + +/** + * @oas [delete] /publishable-api-keys/{id}/sales-channels/batch + * operationId: "DeletePublishableApiKeySalesChannelsChannelsBatch" + * summary: "Remove sales channel from a publishable api key scope" + * description: "Remove a batch of sales channels from a publishable api key." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the Publishable Api Key. + * requestBody: + * content: + * application/json: + * schema: + * required: + * - sales_channel_ids + * properties: + * sales_channel_ids: + * description: The IDs of the sales channels to delete from the publishable api key + * type: array + * items: + * type: object + * required: + * - id + * properties: + * id: + * type: string + * description: The ID of the sales channel + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.publishableApiKeys.removeSalesChannels(publishableApiKeyId, { + * sales_channel_ids: [ + * { + * id: channel_id + * } + * ] + * }) + * .then(({ publishable_api_key }) => { + * console.log(publishable_api_key.id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request DELETE 'https://medusa-url.com/admin/publishable-api-keys/afasf/batch' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: application/json' \ + * --data-raw '{ + * "sales_channel_ids": [ + * { + * "id": "{sales_channel_id}" + * } + * ] + * }' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Publishable Api Key + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * publishable_api_key: + * $ref: "#/components/schemas/publishable_api_key" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response): Promise => { + const validatedBody = + req.validatedBody as AdminDeletePublishableApiKeySalesChannelsBatchReq + + const { id } = req.params + + const publishableApiKeyService: PublishableApiKeyService = req.scope.resolve( + "publishableApiKeyService" + ) + + const manager: EntityManager = req.scope.resolve("manager") + const publishableApiKey = await manager.transaction( + async (transactionManager) => { + await publishableApiKeyService + .withTransaction(transactionManager) + .removeSalesChannels( + id, + validatedBody.sales_channel_ids.map((p) => p.id) + ) + + return await publishableApiKeyService.retrieve(id) + } + ) + + res.status(200).json({ publishable_api_key: publishableApiKey }) +} + +export class AdminDeletePublishableApiKeySalesChannelsBatchReq { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProductBatchSalesChannel) + sales_channel_ids: ProductBatchSalesChannel[] +} diff --git a/packages/medusa/src/api/routes/admin/publishable-api-keys/index.ts b/packages/medusa/src/api/routes/admin/publishable-api-keys/index.ts index c53d5635b6..deda24d8e7 100644 --- a/packages/medusa/src/api/routes/admin/publishable-api-keys/index.ts +++ b/packages/medusa/src/api/routes/admin/publishable-api-keys/index.ts @@ -11,6 +11,9 @@ import { PublishableApiKey } from "../../../../models" import { DeleteResponse, PaginatedResponse } from "../../../../types/common" import { AdminPostPublishableApiKeysReq } from "./create-publishable-api-key" import { AdminPostPublishableApiKeysPublishableApiKeyReq } from "./update-publishable-api-key" +import { AdminDeletePublishableApiKeySalesChannelsBatchReq } from "./delete-channels-batch" +import { AdminPostPublishableApiKeySalesChannelsBatchReq } from "./add-channels-batch" +import { GetPublishableApiKeySalesChannelsParams } from "./list-publishable-api-key-sales-channels" const route = Router() @@ -55,6 +58,26 @@ export default (app) => { }), middlewares.wrap(require("./list-publishable-api-keys").default) ) + + route.get( + "/:id/sales-channels", + transformQuery(GetPublishableApiKeySalesChannelsParams, { isList: true }), + middlewares.wrap( + require("./list-publishable-api-key-sales-channels").default + ) + ) + + route.post( + "/:id/sales-channels/batch", + transformBody(AdminPostPublishableApiKeySalesChannelsBatchReq), + middlewares.wrap(require("./add-channels-batch").default) + ) + + route.delete( + "/:id/sales-channels/batch", + transformBody(AdminDeletePublishableApiKeySalesChannelsBatchReq), + middlewares.wrap(require("./delete-channels-batch").default) + ) } export type AdminPublishableApiKeysRes = { @@ -65,6 +88,9 @@ export type AdminPublishableApiKeysListRes = PaginatedResponse & { } export type AdminPublishableApiKeyDeleteRes = DeleteResponse +export * from "./add-channels-batch" +export * from "./delete-channels-batch" export * from "./list-publishable-api-keys" +export * from "./list-publishable-api-key-sales-channels" export * from "./create-publishable-api-key" export * from "./update-publishable-api-key" diff --git a/packages/medusa/src/api/routes/admin/publishable-api-keys/list-publishable-api-key-sales-channels.ts b/packages/medusa/src/api/routes/admin/publishable-api-keys/list-publishable-api-key-sales-channels.ts new file mode 100644 index 0000000000..3e3ff40ab9 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/publishable-api-keys/list-publishable-api-key-sales-channels.ts @@ -0,0 +1,69 @@ +import { Request, Response } from "express" + +import PublishableApiKeyService from "../../../../services/publishable-api-key" + +/** + * @oas [get] /publishable-api-keys/:id/sales-channels + * operationId: "GetPublishableApiKeySalesChannels" + * summary: "List PublishableApiKey's SalesChannels" + * description: "List PublishableApiKey's SalesChannels" + * x-authenticated: true + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.publishableApiKeys.listSalesChannels() + * .then(({ sales_channels, limit, offset, count }) => { + * console.log(sales_channels) + * }) + * - lang: Shell + * label: cURL + * source: | + * curl --location --request GET 'https://medusa-url.com/admin/publishable-api-keys/pk_123/sales-channels' \ + * --header 'Authorization: Bearer {api_token}' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - PublishableApiKeySalesChannels + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * sales_channels: + * type: array + * items: + * $ref: "#/components/schemas/sales_channel" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response) => { + const { id } = req.params + const publishableApiKeyService: PublishableApiKeyService = req.scope.resolve( + "publishableApiKeyService" + ) + + const salesChannels = await publishableApiKeyService.listSalesChannels(id) + + return res.json({ + sales_channels: salesChannels, + }) +} + +export class GetPublishableApiKeySalesChannelsParams {} diff --git a/packages/medusa/src/api/routes/admin/sales-channels/list-sales-channels.ts b/packages/medusa/src/api/routes/admin/sales-channels/list-sales-channels.ts index 0864903c87..be7310befc 100644 --- a/packages/medusa/src/api/routes/admin/sales-channels/list-sales-channels.ts +++ b/packages/medusa/src/api/routes/admin/sales-channels/list-sales-channels.ts @@ -2,12 +2,11 @@ import { DateComparisonOperator, extendedFindParamsMixin, } from "../../../../types/common" -import { IsNumber, IsOptional, IsString, ValidateNested } from "class-validator" +import { IsOptional, IsString, ValidateNested } from "class-validator" import { Request, Response } from "express" import { SalesChannelService } from "../../../../services" import { Type } from "class-transformer" -import { removeUndefinedProperties } from "../../../../utils" /** * @oas [get] /sales-channels diff --git a/packages/medusa/src/api/routes/store/carts/create-cart.ts b/packages/medusa/src/api/routes/store/carts/create-cart.ts index 1cda2f2437..d78c940f91 100644 --- a/packages/medusa/src/api/routes/store/carts/create-cart.ts +++ b/packages/medusa/src/api/routes/store/carts/create-cart.ts @@ -23,6 +23,7 @@ import { FlagRouter } from "../../../../utils/flag-router" import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" import { CartCreateProps } from "../../../../types/cart" import { isDefined } from "../../../../utils" +import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys" /** * @oas [post] /carts @@ -159,6 +160,23 @@ export default async (req, res) => { } } + if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) { + if ( + !toCreate.sales_channel_id && + req.publishableApiKeyScopes?.sales_channel_id.length + ) { + if (req.publishableApiKeyScopes.sales_channel_id.length > 1) { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + "The PublishableApiKey provided in the request header has multiple associated sales channels." + ) + } + + toCreate.sales_channel_id = + req.publishableApiKeyScopes.sales_channel_id[0] + } + } + let cart: Cart await entityManager.transaction(async (manager) => { cart = await cartService.withTransaction(manager).create(toCreate) diff --git a/packages/medusa/src/api/routes/store/carts/index.ts b/packages/medusa/src/api/routes/store/carts/index.ts index 550a817abf..cabccb1275 100644 --- a/packages/medusa/src/api/routes/store/carts/index.ts +++ b/packages/medusa/src/api/routes/store/carts/index.ts @@ -1,5 +1,6 @@ -import { Router } from "express" import "reflect-metadata" +import { RequestHandler, Router } from "express" + import { Cart, Order, Swap } from "../../../../" import { DeleteResponse, FindParams } from "../../../../types/common" import middlewares, { @@ -8,6 +9,10 @@ import middlewares, { } from "../../../middlewares" import { StorePostCartsCartReq } from "./update-cart" import { StorePostCartReq } from "./create-cart" +import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" +import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys" +import { extendRequestParams } from "../../../middlewares/publishable-api-key/extend-request-params" +import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param" const route = Router() @@ -17,7 +22,7 @@ export default (app, container) => { app.use("/carts", route) - if (featureFlagRouter.isFeatureEnabled("sales_channels")) { + if (featureFlagRouter.isFeatureEnabled(SalesChannelFeatureFlag.key)) { defaultStoreCartRelations.push("sales_channel") } @@ -37,10 +42,21 @@ export default (app, container) => { middlewares.wrap(require("./get-cart").default) ) - route.post( - "/", + const createMiddlewares = [ middlewareService.usePreCartCreation(), transformBody(StorePostCartReq), + ] + + if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) { + createMiddlewares.push( + extendRequestParams as unknown as RequestHandler, + validateSalesChannelParam as unknown as RequestHandler + ) + } + + route.post( + "/", + ...createMiddlewares, middlewares.wrap(require("./create-cart").default) ) diff --git a/packages/medusa/src/api/routes/store/index.js b/packages/medusa/src/api/routes/store/index.js index ed917231ba..e5cee0d1bc 100644 --- a/packages/medusa/src/api/routes/store/index.js +++ b/packages/medusa/src/api/routes/store/index.js @@ -31,12 +31,14 @@ export default (app, container, config) => { }) ) + const featureFlagRouter = container.resolve("featureFlagRouter") + route.use(middlewares.authenticateCustomer()) authRoutes(route) collectionRoutes(route) customerRoutes(route, container) - productRoutes(route) + productRoutes(route, featureFlagRouter) productTypesRoutes(route) orderRoutes(route) orderEditRoutes(route) diff --git a/packages/medusa/src/api/routes/store/products/index.ts b/packages/medusa/src/api/routes/store/products/index.ts index 72a349cfc0..b9eb230541 100644 --- a/packages/medusa/src/api/routes/store/products/index.ts +++ b/packages/medusa/src/api/routes/store/products/index.ts @@ -1,17 +1,32 @@ -import { Router } from "express" +import { RequestHandler, Router } from "express" import "reflect-metadata" + import { Product } from "../../../.." -import { PaginatedResponse } from "../../../../types/common" import middlewares from "../../../middlewares" +import { FlagRouter } from "../../../../utils/flag-router" +import { PaginatedResponse } from "../../../../types/common" +import { extendRequestParams } from "../../../middlewares/publishable-api-key/extend-request-params" +import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys" +import { validateProductSalesChannelAssociation } from "../../../middlewares/publishable-api-key/validate-product-sales-channel-association" +import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param" const route = Router() -export default (app) => { +export default (app, featureFlagRouter: FlagRouter) => { app.use("/products", route) + if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) { + route.use( + "/", + extendRequestParams as unknown as RequestHandler, + validateSalesChannelParam as unknown as RequestHandler + ) + route.use("/:id", validateProductSalesChannelAssociation) + } + route.get("/", middlewares.wrap(require("./list-products").default)) - route.post("/search", middlewares.wrap(require("./search").default)) route.get("/:id", middlewares.wrap(require("./get-product").default)) + route.post("/search", middlewares.wrap(require("./search").default)) return app } diff --git a/packages/medusa/src/api/routes/store/products/list-products.ts b/packages/medusa/src/api/routes/store/products/list-products.ts index ee376c81b4..393e81f397 100644 --- a/packages/medusa/src/api/routes/store/products/list-products.ts +++ b/packages/medusa/src/api/routes/store/products/list-products.ts @@ -25,6 +25,8 @@ import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators import { validator } from "../../../../utils/validator" import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" import { IsType } from "../../../../utils/validators/is-type" +import { FlagRouter } from "../../../../utils/flag-router" +import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys" /** * @oas [get] /products @@ -195,8 +197,18 @@ export default async (req, res) => { const cartService: CartService = req.scope.resolve("cartService") const regionService: RegionService = req.scope.resolve("regionService") + const featureFlagRouter: FlagRouter = req.scope.resolve("featureFlagRouter") + const validated = await validator(StoreGetProductsParams, req.query) + if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) { + if (req.publishableApiKeyScopes?.sales_channel_id.length) { + validated.sales_channel_id = + validated.sales_channel_id || + req.publishableApiKeyScopes.sales_channel_id + } + } + const filterableFields: StoreGetProductsParams = omit(validated, [ "fields", "expand", diff --git a/packages/medusa/src/repositories/product.ts b/packages/medusa/src/repositories/product.ts index 280eb6be54..fdb5417e6c 100644 --- a/packages/medusa/src/repositories/product.ts +++ b/packages/medusa/src/repositories/product.ts @@ -397,6 +397,22 @@ export class ProductRepository extends Repository { return [products, count] } + public async isProductInSalesChannels( + id: string, + salesChannelIds: string[] + ): Promise { + return ( + (await this.createQueryBuilder("product") + .leftJoin( + "product.sales_channels", + "sales_channels", + "sales_channels.id IN (:...salesChannelIds)", + { salesChannelIds } + ) + .getCount()) > 0 + ) + } + private _cleanOptions( options: FindWithoutRelationsOptions ): WithRequiredProperty { diff --git a/packages/medusa/src/repositories/publishable-api-key-sales-channel.ts b/packages/medusa/src/repositories/publishable-api-key-sales-channel.ts new file mode 100644 index 0000000000..7d64217506 --- /dev/null +++ b/packages/medusa/src/repositories/publishable-api-key-sales-channel.ts @@ -0,0 +1,78 @@ +import { EntityRepository, In, Repository } from "typeorm" + +import { PublishableApiKeySalesChannel, SalesChannel } from "../models" + +@EntityRepository(PublishableApiKeySalesChannel) +export class PublishableApiKeySalesChannelRepository extends Repository { + /** + * Query a list of sales channels that are assigned to the publishable key scope + * + * @param publishableApiKeyId - id of the key to retrieve channels for + */ + public async findSalesChannels( + publishableApiKeyId: string + ): Promise { + const data = await this.createQueryBuilder("PublishableKeySalesChannel") + .select("PublishableKeySalesChannel.sales_channel_id") + .innerJoinAndMapOne( + "PublishableKeySalesChannel.sales_channel_id", + SalesChannel, + "SalesChannel", + "PublishableKeySalesChannel.sales_channel_id = SalesChannel.id" + ) + .where( + "PublishableKeySalesChannel.publishable_key_id = :publishableApiKeyId", + { + publishableApiKeyId, + } + ) + .getMany() + + return data.map( + (record) => record.sales_channel_id as unknown as SalesChannel + ) + } + + /** + * Assign (multiple) sales channels to the Publishable Key scope + * + * @param publishableApiKeyId - publishable key id + * @param salesChannelIds - an array of SC ids + */ + public async addSalesChannels( + publishableApiKeyId: string, + salesChannelIds: string[] + ): Promise { + await this.createQueryBuilder() + .insert() + .into("publishable_api_key_sales_channel") + .values( + salesChannelIds.map((id) => ({ + sales_channel_id: id, + publishable_key_id: publishableApiKeyId, + })) + ) + .orIgnore() + .execute() + } + + /** + * Remove multiple sales channels from the PK scope + * + * @param publishableApiKeyId -publishable key id + * @param salesChannelIds - an array of SC ids + */ + public async removeSalesChannels( + publishableApiKeyId: string, + salesChannelIds: string[] + ): Promise { + await this.createQueryBuilder() + .delete() + .from("publishable_api_key_sales_channel") + .where({ + sales_channel_id: In(salesChannelIds), + publishable_key_id: publishableApiKeyId, + }) + .execute() + } +} diff --git a/packages/medusa/src/repositories/publishable-api-key.ts b/packages/medusa/src/repositories/publishable-api-key.ts index 7a72d70bac..a3cd1c0e3c 100644 --- a/packages/medusa/src/repositories/publishable-api-key.ts +++ b/packages/medusa/src/repositories/publishable-api-key.ts @@ -1,7 +1,7 @@ import { flatten, groupBy, merge } from "lodash" import { EntityRepository, FindManyOptions, Repository } from "typeorm" -import { PublishableApiKey } from "../models/publishable-api-key" +import { PublishableApiKey } from "../models" @EntityRepository(PublishableApiKey) export class PublishableApiKeyRepository extends Repository { diff --git a/packages/medusa/src/services/__tests__/publishable-api-key.ts b/packages/medusa/src/services/__tests__/publishable-api-key.ts index 3cdb1a1a86..2442f0e3e2 100644 --- a/packages/medusa/src/services/__tests__/publishable-api-key.ts +++ b/packages/medusa/src/services/__tests__/publishable-api-key.ts @@ -27,8 +27,12 @@ describe("PublishableApiKeyService", () => { }, }) + const publishableApiKeySalesChannelRepository = MockRepository({}) + const publishableApiKeyService = new PublishableApiKeyService({ manager: MockManager, + publishableApiKeySalesChannelRepository: + publishableApiKeySalesChannelRepository, publishableApiKeyRepository: publishableApiKeyRepository, eventBusService: EventBusServiceMock as unknown as EventBusService, }) diff --git a/packages/medusa/src/services/product.ts b/packages/medusa/src/services/product.ts index 9a22d69354..2e1d37fd20 100644 --- a/packages/medusa/src/services/product.ts +++ b/packages/medusa/src/services/product.ts @@ -324,6 +324,29 @@ class ProductService extends TransactionBaseService { return await productTagRepo.listTagsByUsage(count) } + /** + * Check if the product is assigned to at least one of the provided sales channels. + * + * @param id - product id + * @param salesChannelIds - an array of sales channel ids + */ + async isProductInSalesChannels( + id: string, + salesChannelIds: string[] + ): Promise { + const product = await this.retrieve_( + { id }, + { relations: ["sales_channels"] } + ) + + // TODO: reimplement this to use db level check + const productsSalesChannels = product.sales_channels.map( + (channel) => channel.id + ) + + return productsSalesChannels.some((id) => salesChannelIds.includes(id)) + } + /** * Creates a product. * @param productObject - the product to create diff --git a/packages/medusa/src/services/publishable-api-key.ts b/packages/medusa/src/services/publishable-api-key.ts index 4b87543646..f2e1da3433 100644 --- a/packages/medusa/src/services/publishable-api-key.ts +++ b/packages/medusa/src/services/publishable-api-key.ts @@ -3,7 +3,7 @@ import { MedusaError } from "medusa-core-utils" import { PublishableApiKeyRepository } from "../repositories/publishable-api-key" import { FindConfig, Selector } from "../types/common" -import { PublishableApiKey } from "../models" +import { PublishableApiKey, SalesChannel } from "../models" import { TransactionBaseService } from "../interfaces" import EventBusService from "./event-bus" import { buildQuery, isDefined, isString } from "../utils" @@ -11,12 +11,14 @@ import { CreatePublishableApiKeyInput, UpdatePublishableApiKeyInput, } from "../types/publishable-api-key" +import { PublishableApiKeySalesChannelRepository } from "../repositories/publishable-api-key-sales-channel" type InjectedDependencies = { manager: EntityManager eventBusService: EventBusService publishableApiKeyRepository: typeof PublishableApiKeyRepository + publishableApiKeySalesChannelRepository: typeof PublishableApiKeySalesChannelRepository } /** @@ -33,17 +35,21 @@ class PublishableApiKeyService extends TransactionBaseService { protected readonly eventBusService_: EventBusService protected readonly publishableApiKeyRepository_: typeof PublishableApiKeyRepository + protected readonly publishableApiKeySalesChannelRepository_: typeof PublishableApiKeySalesChannelRepository constructor({ manager, eventBusService, publishableApiKeyRepository, + publishableApiKeySalesChannelRepository, }: InjectedDependencies) { super(arguments[0]) this.manager_ = manager this.eventBusService_ = eventBusService this.publishableApiKeyRepository_ = publishableApiKeyRepository + this.publishableApiKeySalesChannelRepository_ = + publishableApiKeySalesChannelRepository } /** @@ -247,6 +253,91 @@ class PublishableApiKeyService extends TransactionBaseService { const pubKey = await this.retrieve(publishableApiKeyId) return pubKey.revoked_by === null } + + /** + * Associate provided sales channels with the publishable api key. + * + * @param publishableApiKeyId + * @param salesChannelIds + */ + async addSalesChannels( + publishableApiKeyId: string, + salesChannelIds: string[] + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const pubKeySalesChannelRepo = transactionManager.getCustomRepository( + this.publishableApiKeySalesChannelRepository_ + ) + + await pubKeySalesChannelRepo.addSalesChannels( + publishableApiKeyId, + salesChannelIds + ) + }) + } + + /** + * Remove provided sales channels from the publishable api key scope. + * + * @param publishableApiKeyId + * @param salesChannelIds + */ + async removeSalesChannels( + publishableApiKeyId: string, + salesChannelIds: string[] + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const pubKeySalesChannelRepo = transactionManager.getCustomRepository( + this.publishableApiKeySalesChannelRepository_ + ) + + await pubKeySalesChannelRepo.removeSalesChannels( + publishableApiKeyId, + salesChannelIds + ) + }) + } + + /** + * List SalesChannels associated with the PublishableKey + * + * @param publishableApiKeyId - id of the key SalesChannels are listed for + */ + async listSalesChannels( + publishableApiKeyId: string + ): Promise { + const manager = this.manager_ + const pubKeySalesChannelRepo = manager.getCustomRepository( + this.publishableApiKeySalesChannelRepository_ + ) + + return await pubKeySalesChannelRepo.findSalesChannels(publishableApiKeyId) + } + + /** + * Get a map of resources ids that are withing the key's scope. + * + * @param publishableApiKeyId + */ + async getResourceScopes( + publishableApiKeyId: string + ): Promise<{ sales_channel_id: string[] }> { + const manager = this.manager_ + const pubKeySalesChannelRepo = manager.getCustomRepository( + this.publishableApiKeySalesChannelRepository_ + ) + + const salesChannels = await pubKeySalesChannelRepo.find({ + select: ["sales_channel_id"], + where: { publishable_key_id: publishableApiKeyId }, + }) + + return { + sales_channel_id: salesChannels.map( + ({ sales_channel_id }) => sales_channel_id + ), + } + } } export default PublishableApiKeyService