feat(medusa, core-flows, types): Allow to update the rules from a shipping options (#7175)

**What**
Add support for the following operations
- update rules from the update shipping options end point
- update rules from the batch update end point

Also added some improvements, that can be revisited later
- Add a rule value normalizer, jsonb will transform the input value to a [primitive](https://www.postgresql.org/docs/current/datatype-json.html#JSON-TYPE-MAPPING-TABLE) when possible meaning that passing `"true"` will result in storing `true` and not the string. The normalizer takes care of that
This commit is contained in:
Adrien de Peretti authored and GitHub committed 2024-04-30 16:45:17 +00:00
1 parent 3affcc2525
commit e26cda4b6a
11 files changed
+470 -41

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
"@medusajs/medusa": patch
"@medusajs/core-flows": patch
"@medusajs/fulfillment": patch
"@medusajs/types": patch
---
feat(medusa, core-flows, types): Allow to update the rules from a shipping options
@@ -254,6 +254,14 @@ medusaIntegrationTestRunner({
amount: 10000, amount: 10000,
}, },
], ],
rules: [
shippingOptionRule,
{
operator: RuleOperator.EQ,
attribute: "new_attr",
value: "true",
},
],
} }
const updateResponse = await api.post( const updateResponse = await api.post(
@@ -264,6 +272,7 @@ medusaIntegrationTestRunner({
expect(updateResponse.status).toEqual(200) expect(updateResponse.status).toEqual(200)
expect(updateResponse.data.shipping_option.prices).toHaveLength(2) expect(updateResponse.data.shipping_option.prices).toHaveLength(2)
expect(updateResponse.data.shipping_option.rules).toHaveLength(2)
expect(updateResponse.data.shipping_option).toEqual( expect(updateResponse.data.shipping_option).toEqual(
expect.objectContaining({ expect.objectContaining({
id: expect.any(String), id: expect.any(String),
@@ -301,6 +310,12 @@ medusaIntegrationTestRunner({
attribute: "old_attr", attribute: "old_attr",
value: "old value", value: "old value",
}), }),
expect.objectContaining({
id: expect.any(String),
operator: "eq",
attribute: "new_attr",
value: "true",
}),
]), ]),
}) })
) )
@@ -0,0 +1,326 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
BatchWorkflowInput,
CreateShippingOptionRuleDTO,
FulfillmentSetDTO,
FulfillmentWorkflow,
IFulfillmentModuleService,
IRegionModuleService,
ServiceZoneDTO,
ShippingProfileDTO,
UpdateShippingOptionRuleDTO,
} from "@medusajs/types"
import { medusaIntegrationTestRunner } from "medusa-test-utils/dist"
import {
batchShippingOptionRulesWorkflow,
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"
async function createShippingOptionFixture({
container,
serviceZone,
shippingProfile,
}) {
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",
},
{
attribute: "is_store",
operator: RuleOperator.EQ,
value: "true",
},
],
}
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.*",
"rules.*",
],
})
const [createdShippingOption] = await remoteQuery(remoteQueryObject)
return createdShippingOption
}
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, update and delete rules in batch", async () => {
const shippingOption = await createShippingOptionFixture({
container,
serviceZone,
shippingProfile,
})
expect(shippingOption.rules).toHaveLength(2)
const ruleToUpdate = shippingOption.rules.find((rule) => {
return rule.attribute === "is_store"
})
const ruleToDelete = shippingOption.rules.find((rule) => {
return rule.attribute === "total"
})
const workflowInput: BatchWorkflowInput<
CreateShippingOptionRuleDTO,
UpdateShippingOptionRuleDTO
> = {
create: [
{
shipping_option_id: shippingOption.id,
attribute: "new_attribute",
operator: RuleOperator.EQ,
value: "100",
},
],
update: [
{
...ruleToUpdate,
value: "false",
},
],
delete: [ruleToDelete.id],
}
await batchShippingOptionRulesWorkflow(container).run({
input: workflowInput,
})
const remoteQuery = container.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const remoteQueryObject = remoteQueryObjectFromString({
entryPoint: "shipping_option",
variables: {
id: shippingOption.id,
},
fields: ["id", "rules.*"],
})
const [updatedShippingOption] = await remoteQuery(remoteQueryObject)
const newAttrRule = updatedShippingOption.rules.find((rule) => {
return rule.attribute === "new_attribute"
})
const updatedRule = updatedShippingOption.rules.find((rule) => {
return rule.attribute === "is_store"
})
expect(updatedShippingOption.rules).toHaveLength(2)
expect(newAttrRule).toEqual(
expect.objectContaining({
attribute: "new_attribute",
operator: "eq",
value: 100,
})
)
expect(updatedRule).toEqual(
expect.objectContaining({
value: "false",
})
)
})
it("should revert the shipping options rules batch actions", async () => {
const shippingOption = await createShippingOptionFixture({
container,
serviceZone,
shippingProfile,
})
expect(shippingOption.rules).toHaveLength(2)
const ruleToUpdate = shippingOption.rules.find((rule) => {
return rule.attribute === "is_store"
})
const ruleToDelete = shippingOption.rules.find((rule) => {
return rule.attribute === "total"
})
const workflowInput: BatchWorkflowInput<
CreateShippingOptionRuleDTO,
UpdateShippingOptionRuleDTO
> = {
create: [
{
shipping_option_id: shippingOption.id,
attribute: "new_attribute",
operator: RuleOperator.EQ,
value: "100",
},
],
update: [
{
...ruleToUpdate,
value: "false",
},
],
delete: [ruleToDelete.id],
}
const workflow = batchShippingOptionRulesWorkflow(container)
workflow.addAction(
"throw",
{
invoke: async function failStep() {
throw new Error(`Failed to update shipping option rules`)
},
},
{
noCompensation: true,
}
)
const { errors } = await workflow.run({
input: workflowInput,
throwOnError: false,
})
expect(errors).toHaveLength(1)
expect(errors[0].error.message).toEqual(
`Failed to update shipping option rules`
)
const remoteQuery = container.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const remoteQueryObject = remoteQueryObjectFromString({
entryPoint: "shipping_option",
variables: {
id: shippingOption.id,
},
fields: ["id", "rules.*"],
})
const [updatedShippingOption] = await remoteQuery(remoteQueryObject)
expect(updatedShippingOption.rules).toHaveLength(2)
expect(updatedShippingOption.rules).toEqual(
expect.arrayContaining([
expect.objectContaining({
attribute: "is_store",
operator: "eq",
value: "true",
}),
expect.objectContaining({
attribute: "total",
operator: "eq",
value: 100,
}),
])
)
})
})
},
})
@@ -3,7 +3,7 @@ import {
AddFulfillmentShippingOptionRulesWorkflowDTO, AddFulfillmentShippingOptionRulesWorkflowDTO,
IFulfillmentModuleService, IFulfillmentModuleService,
} from "@medusajs/types" } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk" import { createStep, StepResponse } from "@medusajs/workflows-sdk"
export const createShippingOptionRulesStepId = "create-shipping-option-rules" export const createShippingOptionRulesStepId = "create-shipping-option-rules"
export const createShippingOptionRulesStep = createStep( export const createShippingOptionRulesStep = createStep(
@@ -18,12 +18,12 @@ export const createShippingOptionRulesStep = createStep(
ModuleRegistrationName.FULFILLMENT ModuleRegistrationName.FULFILLMENT
) )
const createdPromotionRules = const createdShippingOptionRules =
await fulfillmentModule.createShippingOptionRules(data) await fulfillmentModule.createShippingOptionRules(data)
return new StepResponse( return new StepResponse(
createdPromotionRules, createdShippingOptionRules,
createdPromotionRules.map((pr) => pr.id) createdShippingOptionRules.map((pr) => pr.id)
) )
}, },
async (ruleIds, { container }) => { async (ruleIds, { container }) => {
@@ -4,7 +4,7 @@ import {
RemoveFulfillmentShippingOptionRulesWorkflowDTO, RemoveFulfillmentShippingOptionRulesWorkflowDTO,
RuleOperatorType, RuleOperatorType,
} from "@medusajs/types" } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk" import { createStep, StepResponse } from "@medusajs/workflows-sdk"
export const deleteShippingOptionRulesStepId = "delete-shipping-option-rules" export const deleteShippingOptionRulesStepId = "delete-shipping-option-rules"
export const deleteShippingOptionRulesStep = createStep( export const deleteShippingOptionRulesStep = createStep(
@@ -13,6 +13,10 @@ export const deleteShippingOptionRulesStep = createStep(
input: RemoveFulfillmentShippingOptionRulesWorkflowDTO, input: RemoveFulfillmentShippingOptionRulesWorkflowDTO,
{ container } { container }
) => { ) => {
if (!input.ids?.length) {
return
}
const { ids } = input const { ids } = input
const fulfillmentModule = container.resolve<IFulfillmentModuleService>( const fulfillmentModule = container.resolve<IFulfillmentModuleService>(
@@ -39,6 +43,7 @@ export const deleteShippingOptionRulesStep = createStep(
await fulfillmentModule.createShippingOptionRules( await fulfillmentModule.createShippingOptionRules(
shippingOptionRules.map((rule) => ({ shippingOptionRules.map((rule) => ({
id: rule.id,
attribute: rule.attribute, attribute: rule.attribute,
operator: rule.operator as RuleOperatorType, operator: rule.operator as RuleOperatorType,
value: rule.value as unknown as string | string[], value: rule.value as unknown as string | string[],
@@ -0,0 +1,51 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
IFulfillmentModuleService,
UpdateFulfillmentShippingOptionRulesWorkflowDTO,
UpdateShippingOptionRuleDTO,
} from "@medusajs/types"
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
export const updateShippingOptionRulesStepId = "update-shipping-option-rules"
export const updateShippingOptionRulesStep = createStep(
updateShippingOptionRulesStepId,
async (
input: UpdateFulfillmentShippingOptionRulesWorkflowDTO,
{ container }
) => {
if (!input.data?.length) {
return
}
const { data } = input
const fulfillmentModule = container.resolve<IFulfillmentModuleService>(
ModuleRegistrationName.FULFILLMENT
)
const ids = data.map((d) => d.id)
const shippingOptionRules = await fulfillmentModule.listShippingOptionRules(
{ id: ids },
{ select: ["id", "attribute", "operator", "value", "shipping_option_id"] }
)
const updatedPromotionRules =
await fulfillmentModule.updateShippingOptionRules(data)
return new StepResponse(
updatedPromotionRules,
shippingOptionRules as unknown as UpdateShippingOptionRuleDTO[]
)
},
async (previousRulesData, { container }) => {
if (!previousRulesData?.length) {
return
}
const fulfillmentModule = container.resolve<IFulfillmentModuleService>(
ModuleRegistrationName.FULFILLMENT
)
await fulfillmentModule.updateShippingOptionRules(previousRulesData)
}
)
@@ -6,15 +6,16 @@ import {
UpdateShippingOptionRuleDTO, UpdateShippingOptionRuleDTO,
} from "@medusajs/types" } from "@medusajs/types"
import { import {
WorkflowData,
createWorkflow, createWorkflow,
parallelize, parallelize,
transform, transform,
WorkflowData,
} from "@medusajs/workflows-sdk" } from "@medusajs/workflows-sdk"
import { import {
createShippingOptionRulesStep, createShippingOptionRulesStep,
deleteShippingOptionRulesStep, deleteShippingOptionRulesStep,
} from "../steps" } from "../steps"
import { updateShippingOptionRulesStep } from "../steps/update-shipping-option-rules"
export const batchShippingOptionRulesWorkflowId = "batch-shipping-option-rules" export const batchShippingOptionRulesWorkflowId = "batch-shipping-option-rules"
export const batchShippingOptionRulesWorkflow = createWorkflow( export const batchShippingOptionRulesWorkflow = createWorkflow(
@@ -27,25 +28,21 @@ export const batchShippingOptionRulesWorkflow = createWorkflow(
> >
> >
): WorkflowData<BatchWorkflowOutput<ShippingOptionRuleDTO>> => { ): WorkflowData<BatchWorkflowOutput<ShippingOptionRuleDTO>> => {
const createInput = transform({ input }, (data) => ({ const actionInputs = transform({ input }, (data) => {
data: data.input.create ?? [], const { create, update, delete: del } = data.input
})) return {
createInput: { data: create ?? [] },
updateInput: { data: update ?? [] },
deleteInput: { ids: del ?? [] },
}
})
const updateInput = transform({ input }, (data) => ({ const [created, updated, deleted] = parallelize(
data: data.input.update ?? [], createShippingOptionRulesStep(actionInputs.createInput),
})) updateShippingOptionRulesStep(actionInputs.updateInput),
deleteShippingOptionRulesStep(actionInputs.deleteInput)
const deleteInput = transform({ input }, (data) => ({
ids: data.input.delete ?? [],
}))
// TODO: Currently we don't support edits, add support for this.
// We just call the steps directly here since there are no independent workflows, switch to CRUD workflows if they get added.
const [created, deleted] = parallelize(
createShippingOptionRulesStep(createInput),
deleteShippingOptionRulesStep(deleteInput)
) )
return transform({ created, deleted }, (data) => ({ ...data, updated: [] })) return transform({ created, deleted, updated }, (data) => data)
} }
) )
@@ -14,17 +14,17 @@ import {
UpdateServiceZoneDTO, UpdateServiceZoneDTO,
} from "@medusajs/types" } from "@medusajs/types"
import { import {
arrayDifference,
EmitEvents, EmitEvents,
FulfillmentUtils, FulfillmentUtils,
getSetDifference,
InjectManager, InjectManager,
InjectTransactionManager, InjectTransactionManager,
isString,
MedusaContext, MedusaContext,
MedusaError, MedusaError,
Modules, Modules,
ModulesSdkUtils, ModulesSdkUtils,
arrayDifference,
getSetDifference,
isString,
promiseAll, promiseAll,
} from "@medusajs/utils" } from "@medusajs/utils"
import { import {
@@ -38,9 +38,9 @@ import {
ShippingOptionType, ShippingOptionType,
ShippingProfile, ShippingProfile,
} from "@models" } from "@models"
import { isContextValid, validateRules } from "@utils" import {isContextValid, validateAndNormalizeRules} from "@utils"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" import {entityNameToLinkableKeysMap, joinerConfig} from "../joiner-config"
import { UpdateShippingOptionsInput } from "../types/service" import {UpdateShippingOptionsInput} from "../types/service"
import FulfillmentProviderService from "./fulfillment-provider" import FulfillmentProviderService from "./fulfillment-provider"
const generateMethodForModels = [ const generateMethodForModels = [
@@ -343,7 +343,7 @@ export default class FulfillmentModuleService<
| FulfillmentTypes.CreateServiceZoneDTO, | FulfillmentTypes.CreateServiceZoneDTO,
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<TServiceZoneEntity | TServiceZoneEntity[]> { ): Promise<TServiceZoneEntity | TServiceZoneEntity[]> {
let data_ = Array.isArray(data) ? data : [data] const data_ = Array.isArray(data) ? data : [data]
if (!data_.length) { if (!data_.length) {
return [] return []
@@ -402,7 +402,7 @@ export default class FulfillmentModuleService<
| FulfillmentTypes.CreateShippingOptionDTO, | FulfillmentTypes.CreateShippingOptionDTO,
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<TShippingOptionEntity | TShippingOptionEntity[]> { ): Promise<TShippingOptionEntity | TShippingOptionEntity[]> {
let data_ = Array.isArray(data) ? data : [data] const data_ = Array.isArray(data) ? data : [data]
if (!data_.length) { if (!data_.length) {
return [] return []
@@ -410,7 +410,7 @@ export default class FulfillmentModuleService<
const rules = data_.flatMap((d) => d.rules).filter(Boolean) const rules = data_.flatMap((d) => d.rules).filter(Boolean)
if (rules.length) { if (rules.length) {
validateRules(rules as Record<string, unknown>[]) validateAndNormalizeRules(rules as Record<string, unknown>[])
} }
const createdShippingOptions = await this.shippingOptionService_.create( const createdShippingOptions = await this.shippingOptionService_.create(
@@ -555,7 +555,7 @@ export default class FulfillmentModuleService<
return [] return []
} }
validateRules(data_ as unknown as Record<string, unknown>[]) validateAndNormalizeRules(data_ as unknown as Record<string, unknown>[])
const createdShippingOptionRules = const createdShippingOptionRules =
await this.shippingOptionRuleService_.create(data_, sharedContext) await this.shippingOptionRuleService_.create(data_, sharedContext)
@@ -1175,7 +1175,7 @@ export default class FulfillmentModuleService<
}) })
.filter(Boolean) .filter(Boolean)
validateRules(newRules as Record<string, unknown>[]) validateAndNormalizeRules(newRules as Record<string, unknown>[])
shippingOption.rules = shippingOption.rules.map((rule) => { shippingOption.rules = shippingOption.rules.map((rule) => {
if (!("id" in rule)) { if (!("id" in rule)) {
@@ -1382,7 +1382,7 @@ export default class FulfillmentModuleService<
return [] return []
} }
validateRules(data_ as unknown as Record<string, unknown>[]) validateAndNormalizeRules(data_ as unknown as Record<string, unknown>[])
const updatedShippingOptionRules = const updatedShippingOptionRules =
await this.shippingOptionRuleService_.update(data_, sharedContext) await this.shippingOptionRuleService_.update(data_, sharedContext)
+24 -5
View File
@@ -1,4 +1,5 @@
import { import {
isObject,
isString, isString,
MedusaError, MedusaError,
pickValueFromObject, pickValueFromObject,
@@ -97,21 +98,21 @@ export function validateRule(rule: Record<string, unknown>): boolean {
if (!rule.attribute || !rule.operator || !rule.value) { if (!rule.attribute || !rule.operator || !rule.value) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_DATA, MedusaError.Types.INVALID_DATA,
"Rule must have an attribute, an operator and contextValue value" "Rule must have an attribute, an operator and a value"
) )
} }
if (!isString(rule.attribute)) { if (!isString(rule.attribute)) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_DATA, MedusaError.Types.INVALID_DATA,
"Rule attribute must be contextValue string" "Rule attribute must be a string"
) )
} }
if (!isString(rule.operator)) { if (!isString(rule.operator)) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_DATA, MedusaError.Types.INVALID_DATA,
"Rule operator must be contextValue string" "Rule operator must be a string"
) )
} }
@@ -132,10 +133,10 @@ export function validateRule(rule: Record<string, unknown>): boolean {
) )
} }
} else { } else {
if (!isString(rule.value)) { if (Array.isArray(rule.value) || isObject(rule.value)) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_DATA, MedusaError.Types.INVALID_DATA,
`Rule value must be a string for the selected operator ${rule.operator}` `Rule value must be a string, bool, number value for the selected operator ${rule.operator}`
) )
} }
} }
@@ -143,6 +144,24 @@ export function validateRule(rule: Record<string, unknown>): boolean {
return true return true
} }
export function normalizeRulesValue<T extends Partial<Rule>>(rules: T[]): void {
rules.forEach((rule) => {
/**
* If a string is provided, then we don't want jsonb to convert to the primitive value based on the RFC
*/
if (rule.value === "true" || rule.value === "false") {
rule.value = rule.value === "true" ? '"true"' : '"false"'
}
return rule
})
}
export function validateAndNormalizeRules<T extends Partial<Rule>>(rules: T[]) {
rules.forEach(validateRule)
normalizeRulesValue(rules)
}
/** /**
* Validate contextValue set of rules * Validate contextValue set of rules
* @param rules * @param rules
@@ -126,5 +126,6 @@ export const AdminUpdateShippingOption = z
) )
.array() .array()
.optional(), .optional(),
rules: AdminUpdateShippingOptionRule.or(AdminCreateShippingOptionRule).array().optional(),
}) })
.strict() .strict()
+8 -1
View File
@@ -1,4 +1,7 @@
import { CreateShippingOptionRuleDTO } from "./mutations" import {
CreateShippingOptionRuleDTO,
UpdateShippingOptionRuleDTO,
} from "./mutations"
export type AddFulfillmentShippingOptionRulesWorkflowDTO = { export type AddFulfillmentShippingOptionRulesWorkflowDTO = {
data: CreateShippingOptionRuleDTO[] data: CreateShippingOptionRuleDTO[]
@@ -7,3 +10,7 @@ export type AddFulfillmentShippingOptionRulesWorkflowDTO = {
export type RemoveFulfillmentShippingOptionRulesWorkflowDTO = { export type RemoveFulfillmentShippingOptionRulesWorkflowDTO = {
ids: string[] ids: string[]
} }
export type UpdateFulfillmentShippingOptionRulesWorkflowDTO = {
data: UpdateShippingOptionRuleDTO[]
}