Feat(link-module, core-flows, medusa): Add sales channel location management (#6905)

* add locations with sales channels

* init, not working location based management

* working adding sales channel stock locations

* remote sales channels from location

* remove console log

* rm allowedFields

* redo errorhandler

* make validation take an array

* add changeset

* fix build

* Update packages/core-flows/src/sales-channel/workflows/remove-locations-from-sales-channel.ts

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>

* add default cases to return early

* cleanup

* add sc test and remove associations

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Philip Korsholm
2024-04-03 08:38:53 +02:00
committed by GitHub
parent 27f4f0d724
commit edafe7db47
19 changed files with 483 additions and 43 deletions

View File

@@ -0,0 +1,7 @@
---
"@medusajs/link-modules": patch
"@medusajs/core-flows": patch
"@medusajs/medusa": patch
---
feat(link-modules, core-flows, medusa): add sales channel location management endpoint

View File

@@ -1,6 +1,9 @@
const { ModuleRegistrationName, Modules } = require("@medusajs/modules-sdk")
const { medusaIntegrationTestRunner } = require("medusa-test-utils")
const { createAdminUser } = require("../../../helpers/create-admin-user")
const {
createAdminUser,
adminHeaders,
} = require("../../../helpers/create-admin-user")
const { breaking } = require("../../../helpers/breaking")
const { ContainerRegistrationKeys } = require("@medusajs/utils")
@@ -345,7 +348,7 @@ medusaIntegrationTestRunner({
})
it("should delete the requested sales channel", async () => {
let toDelete = await breaking(
const toDelete = await breaking(
async () => {
return await dbConnection.manager.findOne(SalesChannel, {
where: { id: salesChannel.id },
@@ -403,6 +406,48 @@ medusaIntegrationTestRunner({
)
})
})
it("should successfully delete channel associations", async () => {
await breaking(null, async () => {
const remoteLink = container.resolve(
ContainerRegistrationKeys.REMOTE_LINK
)
console.warn("testing")
await remoteLink.create([
{
[Modules.SALES_CHANNEL]: {
sales_channel_id: "test-channel",
},
[Modules.STOCK_LOCATION]: {
stock_location_id: "test-location",
},
},
{
[Modules.SALES_CHANNEL]: {
sales_channel_id: "test-channel-default",
},
[Modules.STOCK_LOCATION]: {
stock_location_id: "test-location",
},
},
])
await api
.delete(`/admin/sales-channels/test-channel`, adminReqConfig)
.catch(console.log)
const linkService = remoteLink.getLinkModule(
Modules.SALES_CHANNEL,
"sales_channel_id",
Modules.STOCK_LOCATION,
"stock_location_id"
)
const channelLinks = await linkService.list()
expect(channelLinks).toHaveLength(1)
})
})
})
describe("GET /admin/orders/:id", () => {

View File

@@ -279,5 +279,100 @@ medusaIntegrationTestRunner({
expect(stockLocationLinks).toHaveLength(0)
})
})
describe("Add sales channels", () => {
let salesChannel
let location
beforeEach(async () => {
const salesChannelResponse = await api.post(
"/admin/sales-channels",
{
name: "test name",
description: "test description",
},
adminHeaders
)
salesChannel = salesChannelResponse.data.sales_channel
const locationResponse = await api.post(
"/admin/stock-locations",
{
name: "test location",
},
adminHeaders
)
location = locationResponse.data.stock_location
})
it("should add sales channels to a location", async () => {
const salesChannelResponse = await api.post(
`/admin/stock-locations/${location.id}/sales-channels/batch/add?fields=*sales_channels`,
{ sales_channel_ids: [salesChannel.id] },
adminHeaders
)
expect(
salesChannelResponse.data.stock_location.sales_channels
).toHaveLength(1)
})
})
describe("Remove sales channels", () => {
let salesChannel1
let salesChannel2
let location
beforeEach(async () => {
const salesChannelResponse1 = await api.post(
"/admin/sales-channels",
{
name: "test name",
description: "test description",
},
adminHeaders
)
salesChannel1 = salesChannelResponse1.data.sales_channel
const salesChannelResponse2 = await api.post(
"/admin/sales-channels",
{
name: "test name",
description: "test description",
},
adminHeaders
)
salesChannel2 = salesChannelResponse2.data.sales_channel
const locationResponse = await api.post(
"/admin/stock-locations",
{
name: "test location",
},
adminHeaders
)
location = locationResponse.data.stock_location
await api.post(
`/admin/stock-locations/${location.id}/sales-channels/batch/add?fields=*sales_channels`,
{ sales_channel_ids: [salesChannel1.id, salesChannel2.id] },
adminHeaders
)
})
it("should remove sales channels from a location", async () => {
const salesChannelResponse = await api.post(
`/admin/stock-locations/${location.id}/sales-channels/batch/remove?fields=*sales_channels`,
{ sales_channel_ids: [salesChannel1.id] },
adminHeaders
)
expect(
salesChannelResponse.data.stock_location.sales_channels
).toHaveLength(1)
})
})
},
})

View File

@@ -0,0 +1,52 @@
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
import { ContainerRegistrationKeys } from "@medusajs/utils"
import { Modules } from "@medusajs/modules-sdk"
interface StepInput {
links: {
sales_channel_id: string
location_ids: string[]
}[]
}
export const associateLocationsWithChannelStepId =
"associate-locations-with-channel-step"
export const associateLocationsWithChannelStep = createStep(
associateLocationsWithChannelStepId,
async (data: StepInput, { container }) => {
if (!data.links.length) {
return new StepResponse([], [])
}
const remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK)
const links = data.links
.map((link) => {
return link.location_ids.map((id) => {
return {
[Modules.SALES_CHANNEL]: {
sales_channel_id: link.sales_channel_id,
},
[Modules.STOCK_LOCATION]: {
stock_location_id: id,
},
}
})
})
.flat()
const createdLinks = await remoteLink.create(links)
return new StepResponse(createdLinks, links)
},
async (links, { container }) => {
if (!links?.length) {
return
}
const remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK)
await remoteLink.dismiss(links)
}
)

View File

@@ -4,4 +4,5 @@ export * from "./create-sales-channels"
export * from "./delete-sales-channels"
export * from "./detach-products-from-sales-channels"
export * from "./update-sales-channels"
export * from "./associate-locations-with-channel"
export * from "./remove-locations-from-channels"

View File

@@ -0,0 +1,71 @@
import { Modules, RemoteLink } from "@medusajs/modules-sdk"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
import { ContainerRegistrationKeys } from "@medusajs/utils"
export const removeLocationsFromSalesChannelStepId =
"remove-locations-from-sales-channel"
export const removeLocationsFromSalesChannelStep = createStep(
removeLocationsFromSalesChannelStepId,
async (
data: {
sales_channel_id: string
location_ids: string[]
}[],
{ container }
) => {
if (!data.length) {
return new StepResponse([], [])
}
const remoteLink = container.resolve<RemoteLink>(
ContainerRegistrationKeys.REMOTE_LINK
)
const linkModule = remoteLink.getLinkModule(
Modules.SALES_CHANNEL,
"sales_channel_id",
Modules.STOCK_LOCATION,
"stock_location_id"
)
if (!linkModule) {
return new StepResponse([], [])
}
const links = data
.map((d) =>
d.location_ids.map((locId) => ({
sales_channel_id: d.sales_channel_id,
stock_location_id: locId,
}))
)
.flat()
await linkModule.softDelete(links)
return new StepResponse(void 0, links)
},
async (links, { container }) => {
if (!links?.length) {
return
}
const remoteLink = container.resolve<RemoteLink>(
ContainerRegistrationKeys.REMOTE_LINK
)
const linkModule = remoteLink.getLinkModule(
Modules.SALES_CHANNEL,
"sales_channel_id",
Modules.STOCK_LOCATION,
"stock_location_id"
)
if (!linkModule) {
return
}
await linkModule.restore(links)
}
)

View File

@@ -0,0 +1,20 @@
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { SalesChannelDTO } from "@medusajs/types"
import { associateLocationsWithChannelStep } from "../steps"
interface WorkflowInput {
data: {
sales_channel_id: string
location_ids: string[]
}[]
}
export const addLocationsToSalesChannelWorkflowId =
"add-locations-to-sales-channel"
export const addLocationsToSalesChannelWorkflow = createWorkflow(
addLocationsToSalesChannelWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<SalesChannelDTO[]> => {
return associateLocationsWithChannelStep({ links: input.data })
}
)

View File

@@ -1,5 +1,8 @@
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { Modules } from "@medusajs/modules-sdk"
import { deleteSalesChannelsStep } from "../steps/delete-sales-channels"
import { removeRemoteLinkStep } from "../../common/steps/remove-remote-links"
type WorkflowInput = { ids: string[] }
@@ -7,6 +10,10 @@ export const deleteSalesChannelsWorkflowId = "delete-sales-channels"
export const deleteSalesChannelsWorkflow = createWorkflow(
deleteSalesChannelsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<void> => {
return deleteSalesChannelsStep(input.ids)
deleteSalesChannelsStep(input.ids)
removeRemoteLinkStep({
[Modules.SALES_CHANNEL]: { sales_channel_id: input.ids },
})
}
)

View File

@@ -3,4 +3,5 @@ export * from "./create-sales-channels"
export * from "./delete-sales-channels"
export * from "./remove-products-from-sales-channels"
export * from "./update-sales-channels"
export * from "./add-locations-to-sales-channel"
export * from "./remove-locations-from-sales-channel"

View File

@@ -0,0 +1,19 @@
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { removeLocationsFromSalesChannelStep } from "../steps"
interface WorkflowInput {
data: {
sales_channel_id: string
location_ids: string[]
}[]
}
export const removeLocationsFromSalesChannelWorkflowId =
"remove-locations-from-sales-channel"
export const removeLocationsFromSalesChannelWorkflow = createWorkflow(
removeLocationsFromSalesChannelWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<void> => {
removeLocationsFromSalesChannelStep(input.data)
}
)

View File

@@ -100,16 +100,19 @@ export default class LinkModuleService<TLink> implements ILinkModule {
return this.primaryKey_.concat(this.foreignKey_).includes(name)
}
private validateFields(data: any) {
const keys = Object.keys(data)
if (!keys.every((k) => this.isValidKeyName(k))) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Invalid field name provided. Valid field names are ${this.primaryKey_.concat(
this.foreignKey_
)}`
)
}
private validateFields(data: any | any[]) {
const dataToValidate = Array.isArray(data) ? data : [data]
dataToValidate.forEach((d) => {
const keys = Object.keys(d)
if (keys.some((k) => !this.isValidKeyName(k))) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Invalid field name provided. Valid field names are ${this.primaryKey_.concat(
this.foreignKey_
)}`
)
}
})
}
@InjectManager("baseRepository_")
@@ -276,10 +279,12 @@ export default class LinkModuleService<TLink> implements ILinkModule {
{ returnLinkableKeys }: SoftDeleteReturn = {},
@MedusaContext() sharedContext: Context = {}
): Promise<Record<string, unknown[]> | void> {
this.validateFields(data)
const inputArray = Array.isArray(data) ? data : [data]
this.validateFields(inputArray)
let [deletedEntities, cascadedEntitiesMap] = await this.softDelete_(
data,
inputArray,
sharedContext
)
@@ -324,7 +329,7 @@ export default class LinkModuleService<TLink> implements ILinkModule {
@InjectTransactionManager(shouldForceTransaction, "baseRepository_")
protected async softDelete_(
data: any,
data: any[],
@MedusaContext() sharedContext: Context = {}
): Promise<[object[], Record<string, string[]>]> {
return await this.linkService_.softDelete(data, sharedContext)
@@ -335,10 +340,11 @@ export default class LinkModuleService<TLink> implements ILinkModule {
{ returnLinkableKeys }: RestoreReturn = {},
@MedusaContext() sharedContext: Context = {}
): Promise<Record<string, unknown[]> | void> {
this.validateFields(data)
const inputArray = Array.isArray(data) ? data : [data]
this.validateFields(inputArray)
let [restoredEntities, cascadedEntitiesMap] = await this.restore_(
data,
inputArray,
sharedContext
)

View File

@@ -87,15 +87,24 @@ export default class LinkService<TEntity> {
@InjectTransactionManager(doNotForceTransaction, "linkRepository_")
async softDelete(
data: any,
data: any[],
@MedusaContext() sharedContext: Context = {}
): Promise<[object[], Record<string, string[]>]> {
const filter = {}
for (const key in data) {
filter[key] = { $in: Array.isArray(data[key]) ? data[key] : [data[key]] }
const deleteFilters = {
$or: data.map((dataEntry) => {
const filter = {}
for (const key in dataEntry) {
filter[key] = {
$in: Array.isArray(dataEntry[key])
? dataEntry[key]
: [dataEntry[key]],
}
}
return filter
}),
}
return await this.linkRepository_.softDelete(filter, {
return await this.linkRepository_.softDelete(deleteFilters, {
transactionManager: sharedContext.transactionManager,
})
}
@@ -105,12 +114,21 @@ export default class LinkService<TEntity> {
data: any,
@MedusaContext() sharedContext: Context = {}
): Promise<[object[], Record<string, string[]>]> {
const filter = {}
for (const key in data) {
filter[key] = { $in: Array.isArray(data[key]) ? data[key] : [data[key]] }
const restoreFilters = {
$or: data.map((dataEntry) => {
const filter = {}
for (const key in dataEntry) {
filter[key] = {
$in: Array.isArray(dataEntry[key])
? dataEntry[key]
: [dataEntry[key]],
}
}
return filter
}),
}
return await this.linkRepository_.restore(data, {
return await this.linkRepository_.restore(restoreFilters, {
transactionManager: sharedContext.transactionManager,
})
}

View File

@@ -1,10 +1,11 @@
import { removeRulesFromPromotionsWorkflow } from "@medusajs/core-flows"
import { RuleType } from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../../../../types/routing"
import { AdminPostPromotionsPromotionRulesBatchRemoveReq } from "../../../../validators"
import { RuleType } from "@medusajs/utils"
import { removeRulesFromPromotionsWorkflow } from "@medusajs/core-flows"
export const POST = async (
req: AuthenticatedMedusaRequest<AdminPostPromotionsPromotionRulesBatchRemoveReq>,

View File

@@ -25,7 +25,7 @@ export const GET = async (
const queryObject = remoteQueryObjectFromString({
entryPoint: "sales_channels",
variables,
fields: defaultAdminSalesChannelFields,
fields: req.remoteQueryConfig.fields,
})
const [sales_channel] = await remoteQuery(queryObject)

View File

@@ -0,0 +1,44 @@
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import {
MedusaRequest,
MedusaResponse,
} from "../../../../../../../types/routing"
import { AdminStockLocationsLocationSalesChannelBatchReq } from "../../../../validators"
import { addLocationsToSalesChannelWorkflow } from "@medusajs/core-flows"
export const POST = async (
req: MedusaRequest<AdminStockLocationsLocationSalesChannelBatchReq>,
res: MedusaResponse
) => {
const workflowInput = {
data: req.validatedBody.sales_channel_ids.map((id) => ({
sales_channel_id: id,
location_ids: [req.params.id],
})),
}
const { errors } = await addLocationsToSalesChannelWorkflow(req.scope).run({
input: workflowInput,
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const queryObject = remoteQueryObjectFromString({
entryPoint: "stock_locations",
variables: { id: req.params.id },
fields: req.remoteQueryConfig.fields,
})
const [stock_location] = await remoteQuery(queryObject)
res.status(200).json({ stock_location })
}

View File

@@ -0,0 +1,46 @@
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import {
MedusaRequest,
MedusaResponse,
} from "../../../../../../../types/routing"
import { AdminStockLocationsLocationSalesChannelBatchReq } from "../../../../validators"
import { removeLocationsFromSalesChannelWorkflow } from "@medusajs/core-flows"
export const POST = async (
req: MedusaRequest<AdminStockLocationsLocationSalesChannelBatchReq>,
res: MedusaResponse
) => {
const workflowInput = {
data: req.validatedBody.sales_channel_ids.map((id) => ({
sales_channel_id: id,
location_ids: [req.params.id],
})),
}
const { errors } = await removeLocationsFromSalesChannelWorkflow(
req.scope
).run({
input: workflowInput,
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const queryObject = remoteQueryObjectFromString({
entryPoint: "stock_locations",
variables: { id: req.params.id },
fields: req.remoteQueryConfig.fields,
})
const [stock_location] = await remoteQuery(queryObject)
res.status(200).json({ stock_location })
}

View File

@@ -7,6 +7,7 @@ import {
AdminPostStockLocationsLocationReq,
AdminPostStockLocationsParams,
AdminPostStockLocationsReq,
AdminStockLocationsLocationSalesChannelBatchReq,
} from "./validators"
import { transformBody, transformQuery } from "../../../api/middlewares"
@@ -53,6 +54,17 @@ export const adminStockLocationRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/admin/stock-locations/:id/sales-channels/batch*",
middlewares: [
transformBody(AdminStockLocationsLocationSalesChannelBatchReq),
transformQuery(
AdminPostStockLocationsLocationParams,
QueryConfig.retrieveTransformQueryConfig
),
],
},
{
method: ["GET"],
matcher: "/admin/stock-locations/:id",

View File

@@ -17,7 +17,6 @@ export const defaultAdminStockLocationFields = [
export const retrieveTransformQueryConfig = {
defaults: defaultAdminStockLocationFields,
allowed: defaultAdminStockLocationFields,
isList: false,
}

View File

@@ -1,15 +1,6 @@
import { FindParams, extendedFindParamsMixin } from "../../../types/common"
import {
DateComparisonOperator,
FindParams,
NumericalComparisonOperator,
StringComparisonOperator,
extendedFindParamsMixin,
} from "../../../types/common"
import {
IsBoolean,
IsEmail,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
@@ -290,3 +281,8 @@ export class AdminPostStockLocationsLocationReq {
export class AdminPostStockLocationsLocationParams extends FindParams {}
export class AdminGetStockLocationsLocationParams extends FindParams {}
export class AdminStockLocationsLocationSalesChannelBatchReq {
@IsString({ each: true })
sales_channel_ids: string[]
}