diff --git a/.changeset/hot-bobcats-cough.md b/.changeset/hot-bobcats-cough.md new file mode 100644 index 0000000000..42c9e686f8 --- /dev/null +++ b/.changeset/hot-bobcats-cough.md @@ -0,0 +1,8 @@ +--- +"@medusajs/medusa": patch +"@medusajs/core-flows": patch +"@medusajs/link-modules": patch +"@medusajs/modules-sdk": patch +--- + +feat: Remove sales channels from pub keys diff --git a/integration-tests/api/__tests__/admin/api-key.spec.ts b/integration-tests/api/__tests__/admin/api-key.spec.ts index 7822fd8045..69725601c7 100644 --- a/integration-tests/api/__tests__/admin/api-key.spec.ts +++ b/integration-tests/api/__tests__/admin/api-key.spec.ts @@ -1,4 +1,4 @@ -import { ApiKeyType } from "@medusajs/utils" +import { ApiKeyType, ContainerRegistrationKeys } from "@medusajs/utils" import { medusaIntegrationTestRunner } from "medusa-test-utils" import { createAdminUser } from "../../../helpers/create-admin-user" @@ -268,6 +268,127 @@ medusaIntegrationTestRunner({ "Sales channels with IDs phony do not exist" ) }) + + it("should detach sales channels from a publishable API key", async () => { + const salesChannelRes = await api.post( + `/admin/sales-channels`, + { + name: "Test Sales Channel", + }, + adminHeaders + ) + + const { sales_channel } = salesChannelRes.data + + const apiKeyRes = await api.post( + `/admin/api-keys`, + { + title: "Test publishable KEY", + type: ApiKeyType.PUBLISHABLE, + }, + adminHeaders + ) + + const { api_key } = apiKeyRes.data + + const keyWithChannelsRes = await api.post( + `/admin/api-keys/${api_key.id}/sales-channels/batch/add`, + { + sales_channel_ids: [sales_channel.id], + }, + adminHeaders + ) + + const { api_key: keyWithChannels } = keyWithChannelsRes.data + + expect(keyWithChannelsRes.status).toEqual(200) + expect(keyWithChannels.title).toEqual("Test publishable KEY") + expect(keyWithChannels.sales_channels).toEqual([ + expect.objectContaining({ + id: sales_channel.id, + name: "Test Sales Channel", + }), + ]) + + const keyWithoutChannelsRes = await api.post( + `/admin/api-keys/${api_key.id}/sales-channels/batch/remove`, + { + sales_channel_ids: [sales_channel.id], + }, + adminHeaders + ) + + const { api_key: keyWithoutChannels } = keyWithoutChannelsRes.data + + expect(keyWithoutChannelsRes.status).toEqual(200) + expect(keyWithoutChannels.title).toEqual("Test publishable KEY") + expect(keyWithoutChannels.sales_channels).toEqual([]) + }) + + it("should detach sales channels from a publishable API key on delete", async () => { + const salesChannelRes = await api.post( + `/admin/sales-channels`, + { + name: "Test Sales Channel", + }, + adminHeaders + ) + + const { sales_channel } = salesChannelRes.data + + const apiKeyRes = await api.post( + `/admin/api-keys`, + { + title: "Test publishable KEY", + type: ApiKeyType.PUBLISHABLE, + }, + adminHeaders + ) + + const { api_key } = apiKeyRes.data + + const keyWithChannelsRes = await api.post( + `/admin/api-keys/${api_key.id}/sales-channels/batch/add`, + { + sales_channel_ids: [sales_channel.id], + }, + adminHeaders + ) + + const { api_key: keyWithChannels } = keyWithChannelsRes.data + + expect(keyWithChannelsRes.status).toEqual(200) + expect(keyWithChannels.title).toEqual("Test publishable KEY") + expect(keyWithChannels.sales_channels).toEqual([ + expect.objectContaining({ + id: sales_channel.id, + name: "Test Sales Channel", + }), + ]) + + await api.delete(`/admin/api-keys/${api_key.id}`, adminHeaders) + + const deletedApiKeys = await api.get( + `/admin/api-keys?id=${api_key.id}`, + adminHeaders + ) + + expect(deletedApiKeys.data.api_keys).toHaveLength(0) + + const remoteQuery = container.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + + // Not the prettiest, but an easy way to check if the link was removed + const channels = await remoteQuery({ + publishable_api_key_sales_channels: { + __args: { sales_channel_id: [sales_channel.id] }, + fields: ["id", "sales_channel_id", "publishable_key_id"], + }, + }) + + expect(channels).toHaveLength(0) + }) }) }, }) diff --git a/packages/core-flows/src/api-key/steps/associate-sales-channels-with-publishable-keys.ts b/packages/core-flows/src/api-key/steps/associate-sales-channels-with-publishable-keys.ts index 255444aeba..31c477a543 100644 --- a/packages/core-flows/src/api-key/steps/associate-sales-channels-with-publishable-keys.ts +++ b/packages/core-flows/src/api-key/steps/associate-sales-channels-with-publishable-keys.ts @@ -16,6 +16,10 @@ export const associateApiKeysWithSalesChannelsStep = createStep( async (input: StepInput, { container }) => { const remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK) + if (!input.links) { + return + } + const links = input.links .map((link) => { return link.sales_channel_ids.map((id) => { diff --git a/packages/core-flows/src/api-key/steps/detach-sales-channels-from-publishable-keys.ts b/packages/core-flows/src/api-key/steps/detach-sales-channels-from-publishable-keys.ts new file mode 100644 index 0000000000..d9f37dd945 --- /dev/null +++ b/packages/core-flows/src/api-key/steps/detach-sales-channels-from-publishable-keys.ts @@ -0,0 +1,47 @@ +import { Modules } from "@medusajs/modules-sdk" +import { ContainerRegistrationKeys } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +interface StepInput { + links: { + api_key_id: string + sales_channel_ids: string[] + }[] +} + +export const detachApiKeysWithSalesChannelsStepId = + "detach-sales-channels-with-api-keys" +export const detachApiKeysWithSalesChannelsStep = createStep( + detachApiKeysWithSalesChannelsStepId, + async (input: StepInput, { container }) => { + const remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK) + + const links = input.links + .map((link) => { + return link.sales_channel_ids.map((id) => { + return { + [Modules.API_KEY]: { + publishable_key_id: link.api_key_id, + }, + [Modules.SALES_CHANNEL]: { + sales_channel_id: id, + }, + } + }) + }) + .flat() + + await remoteLink.dismiss(links) + + return new StepResponse(void 0, links) + }, + async (links, { container }) => { + if (!links?.length) { + return + } + + const remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK) + + await remoteLink.create(links) + } +) diff --git a/packages/core-flows/src/api-key/steps/index.ts b/packages/core-flows/src/api-key/steps/index.ts index c5c4100a90..2c5337e318 100644 --- a/packages/core-flows/src/api-key/steps/index.ts +++ b/packages/core-flows/src/api-key/steps/index.ts @@ -1,6 +1,7 @@ export * from "./associate-sales-channels-with-publishable-keys" export * from "./create-api-keys" export * from "./delete-api-keys" +export * from "./detach-sales-channels-from-publishable-keys" export * from "./revoke-api-keys" export * from "./update-api-keys" export * from "./validate-sales-channel-exists" diff --git a/packages/core-flows/src/api-key/workflows/delete-api-keys.ts b/packages/core-flows/src/api-key/workflows/delete-api-keys.ts index a94df6e224..1810445311 100644 --- a/packages/core-flows/src/api-key/workflows/delete-api-keys.ts +++ b/packages/core-flows/src/api-key/workflows/delete-api-keys.ts @@ -1,4 +1,6 @@ +import { Modules } from "@medusajs/modules-sdk" import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { removeRemoteLinkStep } from "../../common/steps/remove-remote-links" import { deleteApiKeysStep } from "../steps" type WorkflowInput = { ids: string[] } @@ -7,6 +9,11 @@ export const deleteApiKeysWorkflowId = "delete-api-keys" export const deleteApiKeysWorkflow = createWorkflow( deleteApiKeysWorkflowId, (input: WorkflowData): WorkflowData => { - return deleteApiKeysStep(input.ids) + deleteApiKeysStep(input.ids) + + // Please note, the ids here should be publishable key IDs + removeRemoteLinkStep({ + [Modules.API_KEY]: { publishable_key_id: input.ids }, + }) } ) diff --git a/packages/core-flows/src/api-key/workflows/index.ts b/packages/core-flows/src/api-key/workflows/index.ts index 55b998523b..b817357753 100644 --- a/packages/core-flows/src/api-key/workflows/index.ts +++ b/packages/core-flows/src/api-key/workflows/index.ts @@ -3,3 +3,4 @@ export * from "./delete-api-keys" export * from "./update-api-keys" export * from "./revoke-api-keys" export * from "./add-sales-channels-to-publishable-key" +export * from "./remove-sales-channels-from-publishable-key" diff --git a/packages/core-flows/src/api-key/workflows/remove-sales-channels-from-publishable-key.ts b/packages/core-flows/src/api-key/workflows/remove-sales-channels-from-publishable-key.ts new file mode 100644 index 0000000000..e02c11aab8 --- /dev/null +++ b/packages/core-flows/src/api-key/workflows/remove-sales-channels-from-publishable-key.ts @@ -0,0 +1,18 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { detachApiKeysWithSalesChannelsStep } from "../steps/detach-sales-channels-from-publishable-keys" + +type WorkflowInput = { + data: { + api_key_id: string + sales_channel_ids: string[] + }[] +} + +export const removeSalesChannelsFromApiKeyWorkflowId = + "remove-sales-channels-from-api-key" +export const removeSalesChannelsFromApiKeyWorkflow = createWorkflow( + removeSalesChannelsFromApiKeyWorkflowId, + (input: WorkflowData) => { + detachApiKeysWithSalesChannelsStep({ links: input.data }) + } +) diff --git a/packages/link-modules/src/definitions/publishable-api-key-sales-channel.ts b/packages/link-modules/src/definitions/publishable-api-key-sales-channel.ts index d6f9bc8dc1..132bfcba3a 100644 --- a/packages/link-modules/src/definitions/publishable-api-key-sales-channel.ts +++ b/packages/link-modules/src/definitions/publishable-api-key-sales-channel.ts @@ -11,10 +11,10 @@ export const PublishableApiKeySalesChannel: ModuleJoinerConfig = { }, alias: [ { - name: "publishable_api_key_sales_channel", - }, - { - name: "publishable_api_key_sales_channels", + name: [ + "publishable_api_key_sales_channel", + "publishable_api_key_sales_channels", + ], }, ], primaryKeys: ["id", "publishable_key_id", "sales_channel_id"], @@ -49,7 +49,7 @@ export const PublishableApiKeySalesChannel: ModuleJoinerConfig = { { serviceName: Modules.SALES_CHANNEL, fieldAlias: { - api_keys: "api_keys_link.api_key", + publishable_api_keys: "api_keys_link.api_key", }, relationship: { serviceName: LINKS.PublishableApiKeySalesChannel, diff --git a/packages/link-modules/src/links.ts b/packages/link-modules/src/links.ts index aedf1d9bed..d1809532ae 100644 --- a/packages/link-modules/src/links.ts +++ b/packages/link-modules/src/links.ts @@ -50,6 +50,18 @@ export const LINKS = { Modules.STOCK_LOCATION, "location_id" ), + PublishableApiKeySalesChannel: composeLinkName( + Modules.API_KEY, + "api_key_id", + Modules.SALES_CHANNEL, + "sales_channel_id" + ), + ProductSalesChannel: composeLinkName( + Modules.PRODUCT, + "product_id", + Modules.SALES_CHANNEL, + "sales_channel_id" + ), // Internal services ProductShippingProfile: composeLinkName( @@ -58,22 +70,10 @@ export const LINKS = { "shippingProfileService", "profile_id" ), - ProductSalesChannel: composeLinkName( - Modules.PRODUCT, - "product_id", - Modules.SALES_CHANNEL, - "sales_channel_id" - ), OrderSalesChannel: composeLinkName( "orderService", "order_id", Modules.SALES_CHANNEL, "sales_channel_id" ), - PublishableApiKeySalesChannel: composeLinkName( - Modules.API_KEY, - "api_key_id", - Modules.SALES_CHANNEL, - "sales_channel_id" - ), } diff --git a/packages/medusa/src/api-v2/admin/api-keys/[id]/sales-channels/batch/add/route.ts b/packages/medusa/src/api-v2/admin/api-keys/[id]/sales-channels/batch/add/route.ts index e729c267cb..ff0f6cea5c 100644 --- a/packages/medusa/src/api-v2/admin/api-keys/[id]/sales-channels/batch/add/route.ts +++ b/packages/medusa/src/api-v2/admin/api-keys/[id]/sales-channels/batch/add/route.ts @@ -9,13 +9,14 @@ import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../../../../../types/routing" -import { AdminPostApiKeysApiKeySalesChannelsBatchReq } from "../../../../validators" +import { AdminPostApiKeysApiKeySalesChannelsBatchAddReq } from "../../../../validators" export const POST = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { - const body = req.validatedBody as AdminPostApiKeysApiKeySalesChannelsBatchReq + const body = + req.validatedBody as AdminPostApiKeysApiKeySalesChannelsBatchAddReq const apiKeyModule = req.scope.resolve(ModuleRegistrationName.API_KEY) diff --git a/packages/medusa/src/api-v2/admin/api-keys/[id]/sales-channels/batch/remove/route.ts b/packages/medusa/src/api-v2/admin/api-keys/[id]/sales-channels/batch/remove/route.ts new file mode 100644 index 0000000000..0d2ea7c63b --- /dev/null +++ b/packages/medusa/src/api-v2/admin/api-keys/[id]/sales-channels/batch/remove/route.ts @@ -0,0 +1,65 @@ +import { removeSalesChannelsFromApiKeyWorkflow } from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + ContainerRegistrationKeys, + MedusaError, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../../../types/routing" +import { AdminPostApiKeysApiKeySalesChannelsBatchRemoveReq } from "../../../../validators" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const body = + req.validatedBody as AdminPostApiKeysApiKeySalesChannelsBatchRemoveReq + + const apiKeyModule = req.scope.resolve(ModuleRegistrationName.API_KEY) + + const apiKey = await apiKeyModule.retrieve(req.params.id) + + if (apiKey.type !== "publishable") { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Sales channels can only be associated with publishable API keys" + ) + } + + const workflowInput = { + data: [ + { + api_key_id: req.params.id, + sales_channel_ids: body.sales_channel_ids, + }, + ], + } + + const { errors } = await removeSalesChannelsFromApiKeyWorkflow(req.scope).run( + { + input: workflowInput, + throwOnError: false, + } + ) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const query = remoteQueryObjectFromString({ + entryPoint: "api_key", + fields: req.remoteQueryConfig.fields, + variables: { + id: req.params.id, + }, + }) + + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const [result] = await remoteQuery(query) + + res.status(200).json({ api_key: result }) +} diff --git a/packages/medusa/src/api-v2/admin/api-keys/middlewares.ts b/packages/medusa/src/api-v2/admin/api-keys/middlewares.ts index c1c12e7cdb..76e9e66250 100644 --- a/packages/medusa/src/api-v2/admin/api-keys/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/api-keys/middlewares.ts @@ -5,7 +5,8 @@ import { AdminGetApiKeysApiKeyParams, AdminGetApiKeysParams, AdminPostApiKeysApiKeyReq, - AdminPostApiKeysApiKeySalesChannelsBatchReq, + AdminPostApiKeysApiKeySalesChannelsBatchAddReq, + AdminPostApiKeysApiKeySalesChannelsBatchRemoveReq, AdminPostApiKeysReq, AdminRevokeApiKeysApiKeyReq, } from "./validators" @@ -84,7 +85,18 @@ export const adminApiKeyRoutesMiddlewares: MiddlewareRoute[] = [ AdminGetApiKeysApiKeyParams, QueryConfig.retrieveTransformQueryConfig ), - transformBody(AdminPostApiKeysApiKeySalesChannelsBatchReq), + transformBody(AdminPostApiKeysApiKeySalesChannelsBatchAddReq), + ], + }, + { + method: ["POST"], + matcher: "/admin/api-keys/:id/sales-channels/batch/remove", + middlewares: [ + transformQuery( + AdminGetApiKeysApiKeyParams, + QueryConfig.retrieveTransformQueryConfig + ), + transformBody(AdminPostApiKeysApiKeySalesChannelsBatchRemoveReq), ], }, ] diff --git a/packages/medusa/src/api-v2/admin/api-keys/validators.ts b/packages/medusa/src/api-v2/admin/api-keys/validators.ts index 8cf3f9b3f6..899ba1cbb5 100644 --- a/packages/medusa/src/api-v2/admin/api-keys/validators.ts +++ b/packages/medusa/src/api-v2/admin/api-keys/validators.ts @@ -79,7 +79,10 @@ export class AdminRevokeApiKeysApiKeyReq { export class AdminDeleteApiKeysApiKeyReq {} -export class AdminPostApiKeysApiKeySalesChannelsBatchReq { +export class AdminPostApiKeysApiKeySalesChannelsBatchAddReq { @IsArray() sales_channel_ids: string[] } + +// eslint-disable-next-line max-len +export class AdminPostApiKeysApiKeySalesChannelsBatchRemoveReq extends AdminPostApiKeysApiKeySalesChannelsBatchAddReq {} diff --git a/packages/modules-sdk/src/remote-link.ts b/packages/modules-sdk/src/remote-link.ts index 8b81444dfe..b65d0d119b 100644 --- a/packages/modules-sdk/src/remote-link.ts +++ b/packages/modules-sdk/src/remote-link.ts @@ -6,6 +6,7 @@ import { import { isObject, promiseAll, toPascalCase } from "@medusajs/utils" import { MedusaModule } from "./medusa-module" +import { linkingErrorMessage } from "./utils/linking-error" export type DeleteEntityInput = { [moduleName: string]: { [linkableKey: string]: string | string[] } @@ -353,7 +354,13 @@ export class RemoteLink { if (!service) { throw new Error( - `Module to link ${moduleA}[${moduleAKey}] and ${moduleB}[${moduleBKey}] was not found.` + linkingErrorMessage({ + moduleA, + moduleAKey, + moduleB, + moduleBKey, + type: "link", + }) ) } else if (!serviceLinks.has(service.__definition.key)) { serviceLinks.set(service.__definition.key, []) @@ -404,7 +411,13 @@ export class RemoteLink { if (!service) { throw new Error( - `Module to dismiss link ${moduleA}[${moduleAKey}] and ${moduleB}[${moduleBKey}] was not found.` + linkingErrorMessage({ + moduleA, + moduleAKey, + moduleB, + moduleBKey, + type: "dismiss", + }) ) } else if (!serviceLinks.has(service.__definition.key)) { serviceLinks.set(service.__definition.key, []) diff --git a/packages/modules-sdk/src/utils/linking-error.ts b/packages/modules-sdk/src/utils/linking-error.ts new file mode 100644 index 0000000000..a0c8bd5753 --- /dev/null +++ b/packages/modules-sdk/src/utils/linking-error.ts @@ -0,0 +1,24 @@ +const typeToMethod = new Map([ + [`dismiss`, `dismiss`], + [`link`, `create`], +]) + +type LinkingErrorMessageInput = { + moduleA: string + moduleAKey: string + moduleB: string + moduleBKey: string + type: "dismiss" | "link" +} + +/** + * + * Example: Module to dismiss salesChannel and apiKey by keys sales_channel_id and api_key_id was not found. Ensure the link exists, keys are correct, and the link is passed in the correct order to method 'remoteLink.dismiss' + */ +export const linkingErrorMessage = (input: LinkingErrorMessageInput) => { + const { moduleA, moduleB, moduleAKey, moduleBKey, type } = input + return `Module to type ${moduleA} and ${moduleB} by keys ${moduleAKey} and ${moduleBKey} was not found. Ensure the link exists, keys are correct, and link is passed in the correct order to method 'remoteLink.${typeToMethod.get( + type + )}'. +` +}