feat: Remove sales channels from pub keys (#6876)

**What**
- Add workflow + step for detaching sales channels from pub API keys
- Tweak linking error message to be more helpful
- Add `removeRemoteLink` step to delete API key workflow
This commit is contained in:
Oli Juhl
2024-03-29 16:01:10 +01:00
committed by GitHub
parent 8fd1488938
commit 1bcb13f892
16 changed files with 351 additions and 26 deletions

View File

@@ -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

View File

@@ -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)
})
})
},
})

View File

@@ -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) => {

View File

@@ -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)
}
)

View File

@@ -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"

View File

@@ -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<WorkflowInput>): WorkflowData<void> => {
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 },
})
}
)

View File

@@ -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"

View File

@@ -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<WorkflowInput>) => {
detachApiKeysWithSalesChannelsStep({ links: input.data })
}
)

View File

@@ -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,

View File

@@ -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"
),
}

View File

@@ -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)

View File

@@ -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 })
}

View File

@@ -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),
],
},
]

View File

@@ -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 {}

View File

@@ -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, [])

View File

@@ -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
)}'.
`
}