feat: Create shipping options workflow (#6962)

**What**

- Create main workflow
- Update create pricing rule types step to be idempotent
- update remote joiner to support granular isList confguration on field aliases
- Add full workflow integration tests
This commit is contained in:
Adrien de Peretti
2024-04-06 13:51:48 +02:00
committed by GitHub
parent 5e30b8cce6
commit 65794f4bb5
20 changed files with 804 additions and 20 deletions

View File

@@ -0,0 +1,10 @@
---
"@medusajs/medusa": patch
"@medusajs/core-flows": patch
"@medusajs/link-modules": patch
"medusa-test-utils": patch
"@medusajs/orchestration": patch
"@medusajs/types": patch
---
feat: Create shipping options API

View File

@@ -0,0 +1,270 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
FulfillmentSetDTO,
FulfillmentWorkflow,
IFulfillmentModuleService,
IRegionModuleService,
ServiceZoneDTO,
ShippingProfileDTO,
} from "@medusajs/types"
import { medusaIntegrationTestRunner } from "medusa-test-utils/dist"
import { createShippingOptionsWorkflow } from "@medusajs/core-flows"
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
RuleOperator,
} from "@medusajs/utils"
jest.setTimeout(100000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
const provider_id = "manual_test-provider"
medusaIntegrationTestRunner({
env,
testSuite: ({ getContainer }) => {
let service: IFulfillmentModuleService
let container
beforeAll(() => {
container = getContainer()
service = container.resolve(ModuleRegistrationName.FULFILLMENT)
})
describe("Fulfillment workflows", () => {
let fulfillmentSet: FulfillmentSetDTO
let serviceZone: ServiceZoneDTO
let shippingProfile: ShippingProfileDTO
beforeEach(async () => {
shippingProfile = await service.createShippingProfiles({
name: "test",
type: "default",
})
fulfillmentSet = await service.create({
name: "Test fulfillment set",
type: "manual_test",
})
serviceZone = await service.createServiceZones({
name: "Test service zone",
fulfillment_set_id: fulfillmentSet.id,
geo_zones: [
{
type: "country",
country_code: "US",
},
],
})
})
it("should create shipping options and prices", async () => {
const regionService = container.resolve(
ModuleRegistrationName.REGION
) as IRegionModuleService
const [region] = await regionService.create([
{
name: "Test region",
currency_code: "eur",
countries: ["fr"],
},
])
const shippingOptionData: FulfillmentWorkflow.CreateShippingOptionsWorkflowInput =
{
name: "Test shipping option",
price_type: "flat",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
provider_id,
type: {
code: "manual-type",
label: "Manual Type",
description: "Manual Type Description",
},
prices: [
{
currency_code: "usd",
amount: 10,
},
{
region_id: region.id,
amount: 100,
},
],
rules: [
{
attribute: "total",
operator: RuleOperator.EQ,
value: "100",
},
],
}
const { result } = await createShippingOptionsWorkflow(container).run({
input: [shippingOptionData],
})
const remoteQuery = container.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const remoteQueryObject = remoteQueryObjectFromString({
entryPoint: "shipping_option",
variables: {
id: result[0].id,
},
fields: [
"id",
"name",
"price_type",
"service_zone_id",
"shipping_profile_id",
"provider_id",
"data",
"metadata",
"type.*",
"created_at",
"updated_at",
"deleted_at",
"shipping_option_type_id",
"prices.*",
],
})
const [createdShippingOption] = await remoteQuery(remoteQueryObject)
const prices = createdShippingOption.prices
delete createdShippingOption.prices
expect(createdShippingOption).toEqual(
expect.objectContaining({
id: result[0].id,
name: shippingOptionData.name,
price_type: shippingOptionData.price_type,
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
provider_id: provider_id,
data: null,
metadata: null,
type: expect.objectContaining({
id: expect.any(String),
code: shippingOptionData.type.code,
label: shippingOptionData.type.label,
description: shippingOptionData.type.description,
}),
shipping_option_type_id: expect.any(String),
})
)
expect(prices).toHaveLength(2)
expect(prices).toContainEqual(
expect.objectContaining({
currency_code: "usd",
amount: 10,
})
)
expect(prices).toContainEqual(
expect.objectContaining({
currency_code: "eur",
amount: 100,
rules_count: 1,
})
)
})
it("should revert the shipping options and prices", async () => {
const regionService = container.resolve(
ModuleRegistrationName.REGION
) as IRegionModuleService
const [region] = await regionService.create([
{
name: "Test region",
currency_code: "eur",
countries: ["fr"],
},
])
const shippingOptionData: FulfillmentWorkflow.CreateShippingOptionsWorkflowInput =
{
name: "Test shipping option",
price_type: "flat",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
provider_id,
type: {
code: "manual-type",
label: "Manual Type",
description: "Manual Type Description",
},
prices: [
{
currency_code: "usd",
amount: 10,
},
{
region_id: region.id,
amount: 100,
},
],
rules: [
{
attribute: "total",
operator: RuleOperator.EQ,
value: "100",
},
],
}
const workflow = createShippingOptionsWorkflow(container)
workflow.addAction(
"throw",
{
invoke: async function failStep() {
throw new Error(`Failed to create shipping options`)
},
},
{
noCompensation: true,
}
)
const { errors } = await workflow.run({
input: [shippingOptionData],
throwOnError: false,
})
expect(errors).toHaveLength(1)
expect(errors[0].error.message).toEqual(
`Failed to create shipping options`
)
const remoteQuery = container.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const remoteQueryObject = remoteQueryObjectFromString({
entryPoint: "shipping_option",
fields: ["id"],
})
const createdShippingOptions = await remoteQuery(remoteQueryObject)
expect(createdShippingOptions).toHaveLength(0)
const priceSetsRemoteQueryObject = remoteQueryObjectFromString({
entryPoint: "price_sets",
fields: ["id"],
})
const createdPriceSets = await remoteQuery(priceSetsRemoteQueryObject)
expect(createdPriceSets).toHaveLength(0)
})
})
},
})

View File

@@ -88,7 +88,7 @@ medusaIntegrationTestRunner({
const link = await remoteQuery({
shipping_option: {
fields: ["id"],
price: {
price_set_link: {
fields: ["id", "price_set_id", "shipping_option_id"],
},
},
@@ -99,7 +99,7 @@ medusaIntegrationTestRunner({
expect.arrayContaining([
expect.objectContaining({
id: shippingOption.id,
price: expect.objectContaining({
price_set_link: expect.objectContaining({
price_set_id: priceSet.id,
shipping_option_id: shippingOption.id,
}),

View File

@@ -21,6 +21,15 @@ const customPaymentProvider = {
},
}
const customFulfillmentProvider = {
resolve: "@medusajs/fulfillment-manual",
options: {
config: {
"test-provider": {},
},
},
}
module.exports = {
plugins: [],
projectConfig: {
@@ -91,16 +100,7 @@ module.exports = {
[Modules.FULFILLMENT]: {
/** @type {import('@medusajs/fulfillment').FulfillmentModuleOptions} */
options: {
providers: [
{
resolve: "@medusajs/fulfillment-manual",
options: {
config: {
"test-provider": {},
},
},
},
],
providers: [customFulfillmentProvider],
},
},
},

View File

@@ -15,6 +15,8 @@
"@medusajs/cache-inmemory": "workspace:*",
"@medusajs/customer": "workspace:^",
"@medusajs/event-bus-local": "workspace:*",
"@medusajs/fulfillment": "workspace:^",
"@medusajs/fulfillment-manual": "workspace:^",
"@medusajs/inventory-next": "workspace:^",
"@medusajs/medusa": "workspace:*",
"@medusajs/modules-sdk": "workspace:^",

View File

@@ -0,0 +1,124 @@
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
import {
CreatePriceSetDTO,
IPricingModuleService,
IRegionModuleService,
} from "@medusajs/types"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
interface PriceCurrencyCode {
currency_code: string
amount: number
}
interface PriceRegionId {
region_id: string
amount: number
}
type StepInput = {
id: string
prices: (PriceCurrencyCode | PriceRegionId)[]
}[]
function buildPriceSet(
prices: StepInput[0]["prices"],
regionToCurrencyMap: Map<string, string>
): CreatePriceSetDTO {
const rules: CreatePriceSetDTO["rules"] = []
const shippingOptionPrices = prices.map((price) => {
if ("currency_code" in price) {
return {
currency_code: price.currency_code,
amount: price.amount,
}
}
rules.push({
rule_attribute: "region_id",
})
return {
currency_code: regionToCurrencyMap.get(price.region_id)!,
amount: price.amount,
rules: {
region_id: price.region_id,
},
}
})
return { rules, prices: shippingOptionPrices }
}
export const createShippingOptionsPriceSetsStepId =
"add-shipping-options-prices-step"
export const createShippingOptionsPriceSetsStep = createStep(
createShippingOptionsPriceSetsStepId,
async (data: StepInput, { container }) => {
if (!data?.length) {
return new StepResponse([], [])
}
const regionIds = data
.map((input) => input.prices)
.flat()
.filter((price): price is PriceRegionId => {
return "region_id" in price
})
.map((price) => price.region_id)
let regionToCurrencyMap: Map<string, string> = new Map()
if (regionIds.length) {
const regionService = container.resolve<IRegionModuleService>(
ModuleRegistrationName.REGION
)
const regions = await regionService.list(
{
id: [...new Set(regionIds)],
},
{
select: ["id", "currency_code"],
}
)
regionToCurrencyMap = new Map(
regions.map((region) => [region.id, region.currency_code])
)
}
const priceSetsData = data.map((input) =>
buildPriceSet(input.prices, regionToCurrencyMap)
)
const pricingService = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
const priceSets = await pricingService.create(priceSetsData)
const shippingOptionPriceSetLinData = data.map((input, index) => {
return {
id: input.id,
priceSetId: priceSets[index].id,
}
})
return new StepResponse(
shippingOptionPriceSetLinData,
priceSets.map((priceSet) => priceSet.id)
)
},
async (priceSetIds, { container }) => {
if (!priceSetIds?.length) {
return
}
const pricingService = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
await pricingService.delete(priceSetIds)
}
)

View File

@@ -0,0 +1,43 @@
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
import {
FulfillmentWorkflow,
IFulfillmentModuleService,
ShippingOptionDTO,
} from "@medusajs/types"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
type StepInput = Omit<
FulfillmentWorkflow.CreateShippingOptionsWorkflowInput,
"prices"
>[]
export const createShippingOptionsStepId = "create-shipping-options-step"
export const createShippingOptionsStep = createStep(
createShippingOptionsStepId,
async (input: StepInput, { container }) => {
if (!input?.length) {
return new StepResponse([], [])
}
const fulfillmentService = container.resolve<IFulfillmentModuleService>(
ModuleRegistrationName.FULFILLMENT
)
const createdShippingOptions: ShippingOptionDTO[] =
await fulfillmentService.createShippingOptions(input)
const shippingOptionIds = createdShippingOptions.map((s) => s.id)
return new StepResponse(createdShippingOptions, shippingOptionIds)
},
async (shippingOptionIds, { container }) => {
if (!shippingOptionIds?.length) {
return
}
const fulfillmentService = container.resolve<IFulfillmentModuleService>(
ModuleRegistrationName.FULFILLMENT
)
await fulfillmentService.deleteShippingOptions(shippingOptionIds)
}
)

View File

@@ -1,3 +1,5 @@
export * from "./add-rules-to-fulfillment-shipping-option"
export * from "./create-fulfillment-set"
export * from "./remove-rules-from-fulfillment-shipping-option"
export * from "./create-shipping-options"
export * from "./add-shipping-options-prices"

View File

@@ -0,0 +1,172 @@
import { RemoteLink } from "@medusajs/modules-sdk"
import { RemoteQueryFunction } from "@medusajs/types"
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
import {
ContainerRegistrationKeys,
LINKS,
Modules,
promiseAll,
remoteQueryObjectFromString,
} from "@medusajs/utils"
type SetShippingOptionsPriceSetsStepInput = {
id: string
price_sets?: string[]
}[]
interface FilteredSetShippingOptionsPriceSetsStepInput {
id: string
price_sets: string[]
}
type LinkItems = {
[Modules.FULFILLMENT]: { shipping_option_id: string }
[Modules.PRICING]: { price_set_id: string }
}[]
async function getCurrentShippingOptionPriceSetsLinks(
shippingOptionIds: string[],
{ remoteQuery }: { remoteQuery: RemoteQueryFunction }
): Promise<LinkItems> {
const query = remoteQueryObjectFromString({
service: LINKS.ShippingOptionPriceSet,
variables: {
filters: { shipping_option_id: shippingOptionIds },
take: null,
},
fields: ["shipping_option_id", "price_set_id"],
})
const shippingOptionPriceSetLinks = (await remoteQuery(query)) as {
shipping_option_id: string
price_set_id: string
}[]
return shippingOptionPriceSetLinks.map((shippingOption) => {
return {
[Modules.FULFILLMENT]: {
shipping_option_id: shippingOption.shipping_option_id,
},
[Modules.PRICING]: {
price_set_id: shippingOption.price_set_id,
},
}
})
}
export const setShippingOptionsPriceSetsStepId =
"set-shipping-options-price-sets-step"
export const setShippingOptionsPriceSetsStep = createStep(
setShippingOptionsPriceSetsStepId,
async (data: SetShippingOptionsPriceSetsStepInput, { container }) => {
if (!data.length) {
return
}
const dataInputToProcess = data.filter((inputData) => {
return inputData.price_sets?.length
}) as FilteredSetShippingOptionsPriceSetsStepInput[]
if (!dataInputToProcess.length) {
return
}
const remoteLink = container.resolve<RemoteLink>(
ContainerRegistrationKeys.REMOTE_LINK
)
const remoteQuery = container.resolve<RemoteQueryFunction>(
ContainerRegistrationKeys.REMOTE_QUERY
)
const shippingOptionIds = dataInputToProcess.map(
(inputData) => inputData.id
)
const currentExistingLinks = await getCurrentShippingOptionPriceSetsLinks(
shippingOptionIds,
{ remoteQuery }
)
const linksToRemove: LinkItems = currentExistingLinks
.filter((existingLink) => {
return !dataInputToProcess.some((input) => {
return (
input.id === existingLink[Modules.FULFILLMENT].shipping_option_id &&
input.price_sets.includes(
existingLink[Modules.PRICING].price_set_id
)
)
})
})
.map((link) => {
return {
[Modules.FULFILLMENT]: {
shipping_option_id: link[Modules.FULFILLMENT].shipping_option_id,
},
[Modules.PRICING]: {
price_set_id: link[Modules.PRICING].price_set_id,
},
}
})
const linksToCreate = dataInputToProcess
.map((inputData) => {
return inputData.price_sets.map((priceSet) => {
const alreadyExists = currentExistingLinks.some((link) => {
return (
link[Modules.FULFILLMENT].shipping_option_id === inputData.id &&
link[Modules.PRICING].price_set_id === priceSet
)
})
if (alreadyExists) {
return
}
return {
[Modules.FULFILLMENT]: { shipping_option_id: inputData.id },
[Modules.PRICING]: { price_set_id: priceSet },
}
})
})
.flat()
.filter((d): d is LinkItems[0] => !!d)
const promises: Promise<unknown[]>[] = []
if (linksToRemove.length) {
promises.push(remoteLink.dismiss(linksToRemove))
}
if (linksToCreate.length) {
promises.push(remoteLink.create(linksToCreate))
}
await promiseAll(promises)
return new StepResponse(void 0, {
linksToCreate: linksToRemove,
linksToRemove: linksToCreate,
})
},
async (rollbackData, { container }) => {
if (!rollbackData) {
return
}
const remoteLink = container.resolve<RemoteLink>(
ContainerRegistrationKeys.REMOTE_LINK
)
const promises: Promise<unknown[]>[] = []
if (rollbackData.linksToRemove.length) {
promises.push(remoteLink.dismiss(rollbackData.linksToRemove))
}
if (rollbackData.linksToCreate.length) {
promises.push(remoteLink.create(rollbackData.linksToCreate))
}
await promiseAll(promises)
}
)

View File

@@ -0,0 +1,97 @@
import { CreateRuleTypeDTO, FulfillmentWorkflow } from "@medusajs/types"
import {
createWorkflow,
transform,
WorkflowData,
} from "@medusajs/workflows-sdk"
import {
createShippingOptionsPriceSetsStep,
createShippingOptionsStep,
} from "../steps"
import { setShippingOptionsPriceSetsStep } from "../steps/set-shipping-options-price-sets"
import { createPricingRuleTypesStep } from "../../pricing"
export const createShippingOptionsWorkflowId =
"create-shipping-options-workflow"
export const createShippingOptionsWorkflow = createWorkflow(
createShippingOptionsWorkflowId,
(
input: WorkflowData<
FulfillmentWorkflow.CreateShippingOptionsWorkflowInput[]
>
): WorkflowData<FulfillmentWorkflow.CreateShippingOptionsWorkflowOutput> => {
const data = transform(input, (data) => {
const shippingOptionsIndexToPrices = data.map((option, index) => {
return {
shipping_option_index: index,
prices: option.prices,
}
})
return {
shippingOptions: data,
shippingOptionsIndexToPrices,
}
})
const createdShippingOptions = createShippingOptionsStep(
data.shippingOptions
)
const normalizedShippingOptionsPrices = transform(
{
shippingOptions: createdShippingOptions,
shippingOptionsIndexToPrices: data.shippingOptionsIndexToPrices,
},
(data) => {
const ruleTypes = new Set<CreateRuleTypeDTO>()
const shippingOptionsPrices = data.shippingOptionsIndexToPrices.map(
({ shipping_option_index, prices }) => {
prices.forEach((price) => {
if ("region_id" in price) {
ruleTypes.add({
name: "region_id",
rule_attribute: "region_id",
})
}
})
return {
id: data.shippingOptions[shipping_option_index].id,
prices,
}
}
)
return {
shippingOptionsPrices,
ruleTypes: Array.from(ruleTypes) as CreateRuleTypeDTO[],
}
}
)
createPricingRuleTypesStep(normalizedShippingOptionsPrices.ruleTypes)
const shippingOptionsPriceSetsLinkData = createShippingOptionsPriceSetsStep(
normalizedShippingOptionsPrices.shippingOptionsPrices
)
const normalizedLinkData = transform(
{
shippingOptionsPriceSetsLinkData,
},
(data) => {
return data.shippingOptionsPriceSetsLinkData.map((item) => {
return {
id: item.id,
price_sets: [item.priceSetId],
}
})
}
)
setShippingOptionsPriceSetsStep(normalizedLinkData)
return createdShippingOptions
}
)

View File

@@ -1,2 +1,3 @@
export * from "./add-rules-to-fulfillment-shipping-option"
export * from "./remove-rules-from-fulfillment-shipping-option"
export * from "./create-shipping-options"

View File

@@ -1,16 +1,32 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { CreateRuleTypeDTO, IPricingModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
export const createPricingRuleTypesStepId = "create-pricing-rule-types"
export const createPricingRuleTypesStep = createStep(
createPricingRuleTypesStepId,
async (data: CreateRuleTypeDTO[], { container }) => {
if (!data?.length) {
return
}
const pricingModule = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
const ruleTypes = await pricingModule.createRuleTypes(data)
const existingRuleTypes = await pricingModule.listRuleTypes({
rule_attribute: data.map((d) => d.rule_attribute),
})
const existingRuleTypeAttributes = new Set(
existingRuleTypes.map((ruleType) => ruleType.rule_attribute)
)
const ruleTypesToCreate = data.filter(
(dataItem) => !existingRuleTypeAttributes.has(dataItem.rule_attribute)
)
const ruleTypes = await pricingModule.createRuleTypes(ruleTypesToCreate)
return new StepResponse(
ruleTypes,

View File

@@ -39,11 +39,17 @@ export const ShippingOptionPriceSet: ModuleJoinerConfig = {
extends: [
{
serviceName: Modules.FULFILLMENT,
fieldAlias: {
prices: {
path: "price_set_link.price_set.prices",
isList: true,
},
},
relationship: {
serviceName: LINKS.ShippingOptionPriceSet,
primaryKey: "shipping_option_id",
foreignKey: "id",
alias: "price",
alias: "price_set_link",
},
},
{

View File

@@ -89,7 +89,7 @@ export function medusaIntegrationTestRunner({
testSuite,
}: {
moduleName?: string
env?: Record<string, string | any>
env?: Record<string, any>
dbName?: string
schema?: string
debug?: boolean

View File

@@ -841,6 +841,7 @@ export class RemoteJoiner {
const alias = fieldAlias[property] as any
const path = isString(alias) ? alias : alias.path
const fieldAliasIsList = isString(alias) ? false : !!alias.isList
const fullPath = [...currentPath.concat(path.split("."))]
if (aliasRealPathMap.has(aliasPath)) {
@@ -868,7 +869,7 @@ export class RemoteJoiner {
location: [...currentPath],
property,
path: fullPath,
isList: !!serviceConfig.relationships?.find(
isList: fieldAliasIsList || !!serviceConfig.relationships?.find(
(relationship) => relationship.alias === parentFieldAlias
)?.isList,
})

View File

@@ -147,7 +147,8 @@ export type ModuleJoinerConfig = Omit<
| string
| {
path: string
forwardArgumentsOnPath: string[]
forwardArgumentsOnPath?: string[]
isList?: boolean
}
> // alias for deeper nested relationships (e.g. { 'price': 'prices.calculated_price_set.amount' })
relationship: ModuleJoinerRelationship

View File

@@ -0,0 +1,35 @@
import { ShippingOptionPriceType } from "../../fulfillment"
import { RuleOperatorType } from "../../common"
export interface CreateShippingOptionsWorkflowInput {
name: string
service_zone_id: string
shipping_profile_id: string
data?: Record<string, unknown>
price_type: ShippingOptionPriceType
provider_id: string
type: {
label: string
description: string
code: string
}
prices: (
| {
currency_code: string
amount: number
}
| {
region_id: string
amount: number
}
)[]
rules?: {
attribute: string
operator: RuleOperatorType
value: string | string[]
}[]
}
export type CreateShippingOptionsWorkflowOutput = {
id: string
}[]

View File

@@ -0,0 +1 @@
export * from "./create-shipping-options"

View File

@@ -6,3 +6,4 @@ export * as PriceListWorkflow from "./price-list"
export * as UserWorkflow from "./user"
export * as RegionWorkflow from "./region"
export * as InviteWorkflow from "./invite"
export * as FulfillmentWorkflow from "./fulfillment"

View File

@@ -8274,7 +8274,7 @@ __metadata:
languageName: unknown
linkType: soft
"@medusajs/fulfillment-manual@workspace:*, @medusajs/fulfillment-manual@workspace:packages/fulfillment-manual":
"@medusajs/fulfillment-manual@workspace:*, @medusajs/fulfillment-manual@workspace:^, @medusajs/fulfillment-manual@workspace:packages/fulfillment-manual":
version: 0.0.0-use.local
resolution: "@medusajs/fulfillment-manual@workspace:packages/fulfillment-manual"
dependencies:
@@ -8288,7 +8288,7 @@ __metadata:
languageName: unknown
linkType: soft
"@medusajs/fulfillment@workspace:packages/fulfillment":
"@medusajs/fulfillment@workspace:^, @medusajs/fulfillment@workspace:packages/fulfillment":
version: 0.0.0-use.local
resolution: "@medusajs/fulfillment@workspace:packages/fulfillment"
dependencies:
@@ -32134,6 +32134,8 @@ __metadata:
"@medusajs/cache-inmemory": "workspace:*"
"@medusajs/customer": "workspace:^"
"@medusajs/event-bus-local": "workspace:*"
"@medusajs/fulfillment": "workspace:^"
"@medusajs/fulfillment-manual": "workspace:^"
"@medusajs/inventory-next": "workspace:^"
"@medusajs/medusa": "workspace:*"
"@medusajs/modules-sdk": "workspace:^"