feat(fulfillment): Separate list and context rules validation (#6674)

**What**

- Add method to validate fulfillment option from the provider
- Separate list/list and count from context rules validation and add listShippingOptionsForContext

FIXES CORE-1861
This commit is contained in:
Adrien de Peretti
2024-03-15 14:25:51 +01:00
committed by GitHub
parent 1956dce80a
commit 3188e703b3
16 changed files with 1011 additions and 326 deletions

View File

@@ -1413,7 +1413,7 @@ medusaIntegrationTestRunner({
geo_zones: [
{
type: "country",
country_code: "us",
country_code: "dk",
},
],
},
@@ -1637,7 +1637,7 @@ medusaIntegrationTestRunner({
geo_zones: [
{
type: "country",
country_code: "us",
country_code: "dk",
},
],
},

View File

@@ -1,8 +1,8 @@
import { ListShippingOptionsForCartWorkflowInputDTO } from "@medusajs/types"
import {
WorkflowData,
createWorkflow,
transform,
WorkflowData,
} from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../../common/steps/use-remote-query"
import { listShippingOptionsForContextStep } from "../../../shipping-options"
@@ -33,12 +33,10 @@ export const listShippingOptionsForCartWorkflow = createWorkflow(
return {
context: {
fulfillment_set_id: fulfillmentSetIds,
service_zone: {
geo_zones: {
city: data.input.shipping_address?.city,
country_code: data.input.shipping_address?.country_code,
province_code: data.input.shipping_address?.province,
},
address: {
city: data.input.shipping_address?.city,
country_code: data.input.shipping_address?.country_code,
province_code: data.input.shipping_address?.province,
},
},
config: {

View File

@@ -4,7 +4,7 @@ import {
IFulfillmentModuleService,
ShippingOptionDTO,
} from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
interface StepInput {
context: Record<string, unknown>
@@ -20,10 +20,11 @@ export const listShippingOptionsForContextStep = createStep(
ModuleRegistrationName.FULFILLMENT
)
const shippingOptions = await fulfillmentService.listShippingOptions(
data.context,
data.config
)
const shippingOptions =
await fulfillmentService.listShippingOptionsForContext(
data.context,
data.config
)
return new StepResponse(shippingOptions)
}

View File

@@ -1,4 +1,4 @@
import {Modules} from "@medusajs/modules-sdk"
import { Modules } from "@medusajs/modules-sdk"
import {
CreateFulfillmentSetDTO,
CreateServiceZoneDTO,
@@ -6,8 +6,8 @@ import {
ServiceZoneDTO,
UpdateFulfillmentSetDTO,
} from "@medusajs/types"
import {GeoZoneType} from "@medusajs/utils"
import {moduleIntegrationTestRunner, SuiteOptions} from "medusa-test-utils"
import { GeoZoneType } from "@medusajs/utils"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
jest.setTimeout(100000)
@@ -17,123 +17,123 @@ moduleIntegrationTestRunner({
describe("Fulfillment Module Service", () => {
describe("read", () => {
it("should list fulfillment sets with a filter", async function () {
const createdSet1 = await service.create({
name: "test",
type: "test-type",
})
const createdSet2 = await service.create({
name: "test2",
type: "test-type",
service_zones: [
{
name: "test",
geo_zones: [
{
type: GeoZoneType.COUNTRY,
country_code: "fr",
},
],
},
{
name: "test2",
geo_zones: [
{
type: GeoZoneType.COUNTRY,
country_code: "fr",
},
],
},
{
name: "_test",
geo_zones: [
{
type: GeoZoneType.COUNTRY,
country_code: "fr",
},
],
},
],
})
let listedSets = await service.list(
{
type: createdSet1.type,
},
{
relations: ["service_zones"],
}
)
const listedSets2 = await service.list(
{
type: createdSet1.type,
},
{
relations: ["service_zones"],
}
)
expect(listedSets).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet1.id }),
expect.objectContaining({ id: createdSet2.id }),
])
)
// Respecting order id by default
expect(listedSets[1].service_zones).toEqual([
expect.objectContaining({ name: "test" }),
expect.objectContaining({ name: "test2" }),
expect.objectContaining({ name: "_test" }),
])
expect(listedSets2).toEqual(listedSets2)
listedSets = await service.list({
name: createdSet2.name,
})
expect(listedSets).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet2.id }),
])
)
expect(listedSets).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet1.id }),
])
)
listedSets = await service.list({
service_zones: { name: "test" },
})
expect(listedSets).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet2.id }),
])
)
expect(listedSets).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet1.id }),
])
)
listedSets = await service.list({
service_zones: { geo_zones: { country_code: "fr" } },
})
expect(listedSets).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet2.id }),
])
)
expect(listedSets).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet1.id }),
])
)
const createdSet1 = await service.create({
name: "test",
type: "test-type",
})
const createdSet2 = await service.create({
name: "test2",
type: "test-type",
service_zones: [
{
name: "test",
geo_zones: [
{
type: GeoZoneType.COUNTRY,
country_code: "fr",
},
],
},
{
name: "test2",
geo_zones: [
{
type: GeoZoneType.COUNTRY,
country_code: "fr",
},
],
},
{
name: "_test",
geo_zones: [
{
type: GeoZoneType.COUNTRY,
country_code: "fr",
},
],
},
],
})
let listedSets = await service.list(
{
type: createdSet1.type,
},
{
relations: ["service_zones"],
}
)
const listedSets2 = await service.list(
{
type: createdSet1.type,
},
{
relations: ["service_zones"],
}
)
expect(listedSets).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet1.id }),
expect.objectContaining({ id: createdSet2.id }),
])
)
// Respecting order id by default
expect(listedSets[1].service_zones).toEqual([
expect.objectContaining({ name: "test" }),
expect.objectContaining({ name: "test2" }),
expect.objectContaining({ name: "_test" }),
])
expect(listedSets2).toEqual(listedSets2)
listedSets = await service.list({
name: createdSet2.name,
})
expect(listedSets).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet2.id }),
])
)
expect(listedSets).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet1.id }),
])
)
listedSets = await service.list({
service_zones: { name: "test" },
})
expect(listedSets).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet2.id }),
])
)
expect(listedSets).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet1.id }),
])
)
listedSets = await service.list({
service_zones: { geo_zones: { country_code: "fr" } },
})
expect(listedSets).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet2.id }),
])
)
expect(listedSets).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdSet1.id }),
])
)
})
})
describe("mutations", () => {
@@ -349,6 +349,7 @@ moduleIntegrationTestRunner({
{
type: GeoZoneType.CITY,
country_code: "fr",
province_code: "test",
city: "lyon",
},
],
@@ -400,6 +401,91 @@ moduleIntegrationTestRunner({
expect(err).toBeDefined()
expect(err.constraint).toBe("IDX_fulfillment_set_name_unique")
})
it("should fail on creating a new fulfillment set with new service zones and new geo zones that are not valid", async function () {
let data: CreateFulfillmentSetDTO = {
name: "test",
type: "test-type",
service_zones: [
{
name: "test",
geo_zones: [
{
type: GeoZoneType.PROVINCE,
country_code: "fr",
} as any,
],
},
],
}
let err = await service.create(data).catch((e) => e)
expect(err.message).toBe(
"Missing required property province_code for geo zone type province"
)
data = {
name: "test",
type: "test-type",
service_zones: [
{
name: "test",
geo_zones: [
{
type: GeoZoneType.CITY,
country_code: "fr",
province_code: "test",
} as any,
],
},
],
}
err = await service.create(data).catch((e) => e)
expect(err.message).toBe(
"Missing required property city for geo zone type city"
)
data = {
name: "test",
type: "test-type",
service_zones: [
{
name: "test",
geo_zones: [
{
type: GeoZoneType.ZIP,
postal_expression: "test",
} as any,
],
},
],
}
err = await service.create(data).catch((e) => e)
expect(err.message).toBe(
"Missing required property country_code for geo zone type zip"
)
data = {
name: "test",
type: "test-type",
service_zones: [
{
name: "test",
geo_zones: [
{
type: "unknown",
postal_expression: "test",
} as any,
],
},
],
}
err = await service.create(data).catch((e) => e)
expect(err.message).toBe(`Invalid geo zone type: unknown`)
})
})
describe("on update", () => {

View File

@@ -1,11 +1,11 @@
import {Modules} from "@medusajs/modules-sdk"
import { Modules } from "@medusajs/modules-sdk"
import {
CreateGeoZoneDTO,
IFulfillmentModuleService,
UpdateGeoZoneDTO,
} from "@medusajs/types"
import {GeoZoneType} from "@medusajs/utils"
import {moduleIntegrationTestRunner, SuiteOptions} from "medusa-test-utils"
import { GeoZoneType } from "@medusajs/utils"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
jest.setTimeout(100000)
@@ -15,52 +15,52 @@ moduleIntegrationTestRunner({
describe("Fulfillment Module Service", () => {
describe("read", () => {
it("should list geo zones with a filter", async function () {
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const serviceZone = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
const createdZone1 = await service.createGeoZones({
service_zone_id: serviceZone.id,
type: GeoZoneType.COUNTRY,
country_code: "fr",
})
const createdZone2 = await service.createGeoZones({
service_zone_id: serviceZone.id,
type: GeoZoneType.COUNTRY,
country_code: "us",
})
let listedZones = await service.listGeoZones({
type: createdZone1.type,
})
expect(listedZones).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone1.id }),
expect.objectContaining({ id: createdZone2.id }),
])
)
listedZones = await service.listGeoZones({
country_code: createdZone2.country_code,
})
expect(listedZones).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone2.id }),
])
)
expect(listedZones).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone1.id }),
])
)
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const serviceZone = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
const createdZone1 = await service.createGeoZones({
service_zone_id: serviceZone.id,
type: GeoZoneType.COUNTRY,
country_code: "fr",
})
const createdZone2 = await service.createGeoZones({
service_zone_id: serviceZone.id,
type: GeoZoneType.COUNTRY,
country_code: "us",
})
let listedZones = await service.listGeoZones({
type: createdZone1.type,
})
expect(listedZones).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone1.id }),
expect.objectContaining({ id: createdZone2.id }),
])
)
listedZones = await service.listGeoZones({
country_code: createdZone2.country_code,
})
expect(listedZones).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone2.id }),
])
)
expect(listedZones).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone1.id }),
])
)
})
})
describe("mutations", () => {
@@ -131,6 +131,60 @@ moduleIntegrationTestRunner({
++i
}
})
it("should fail to create new geo zones that are not valid", async function () {
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const serviceZone = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
let data: CreateGeoZoneDTO = {
service_zone_id: serviceZone.id,
type: GeoZoneType.PROVINCE,
country_code: "fr",
} as any
let err = await service.createGeoZones(data).catch((e) => e)
expect(err.message).toBe(
"Missing required property province_code for geo zone type province"
)
data = {
service_zone_id: serviceZone.id,
type: GeoZoneType.CITY,
country_code: "fr",
province_code: "test",
} as any
err = await service.createGeoZones(data).catch((e) => e)
expect(err.message).toBe(
"Missing required property city for geo zone type city"
)
data = {
service_zone_id: serviceZone.id,
type: GeoZoneType.ZIP,
postal_expression: "test",
} as any
err = await service.createGeoZones(data).catch((e) => e)
expect(err.message).toBe(
"Missing required property country_code for geo zone type zip"
)
data = {
service_zone_id: serviceZone.id,
type: "unknown",
postal_expression: "test",
} as any
err = await service.createGeoZones(data).catch((e) => e)
expect(err.message).toBe(`Invalid geo zone type: unknown`)
})
})
describe("on update", () => {

View File

@@ -16,56 +16,56 @@ moduleIntegrationTestRunner({
describe("Fulfillment Module Service", () => {
describe("read", () => {
it("should list service zones with a filter", async function () {
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const createdZone1 = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
const createdZone2 = await service.createServiceZones({
name: "test2",
fulfillment_set_id: fulfillmentSet.id,
geo_zones: [
{
type: GeoZoneType.COUNTRY,
country_code: "fr",
},
],
})
let listedZones = await service.listServiceZones({
name: createdZone2.name,
})
expect(listedZones).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone2.id }),
])
)
expect(listedZones).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone1.id }),
])
)
listedZones = await service.listServiceZones({
geo_zones: { country_code: "fr" },
})
expect(listedZones).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone2.id }),
])
)
expect(listedZones).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone1.id }),
])
)
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const createdZone1 = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
const createdZone2 = await service.createServiceZones({
name: "test2",
fulfillment_set_id: fulfillmentSet.id,
geo_zones: [
{
type: GeoZoneType.COUNTRY,
country_code: "fr",
},
],
})
let listedZones = await service.listServiceZones({
name: createdZone2.name,
})
expect(listedZones).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone2.id }),
])
)
expect(listedZones).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone1.id }),
])
)
listedZones = await service.listServiceZones({
geo_zones: { country_code: "fr" },
})
expect(listedZones).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone2.id }),
])
)
expect(listedZones).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: createdZone1.id }),
])
)
})
})
describe("mutations", () => {
@@ -189,6 +189,76 @@ moduleIntegrationTestRunner({
expect(err).toBeDefined()
expect(err.constraint).toBe("IDX_service_zone_name_unique")
})
it("should fail on creating a service zone and new geo zones that are not valid", async function () {
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
let data: CreateServiceZoneDTO = {
name: "test",
fulfillment_set_id: fulfillmentSet.id,
geo_zones: [
{
type: GeoZoneType.PROVINCE,
country_code: "fr",
} as any,
],
}
let err = await service.createServiceZones(data).catch((e) => e)
expect(err.message).toBe(
"Missing required property province_code for geo zone type province"
)
data = {
name: "test",
fulfillment_set_id: fulfillmentSet.id,
geo_zones: [
{
type: GeoZoneType.CITY,
country_code: "fr",
province_code: "test",
} as any,
],
}
err = await service.createServiceZones(data).catch((e) => e)
expect(err.message).toBe(
"Missing required property city for geo zone type city"
)
data = {
name: "test",
fulfillment_set_id: fulfillmentSet.id,
geo_zones: [
{
type: GeoZoneType.ZIP,
postal_expression: "test",
} as any,
],
}
err = await service.createServiceZones(data).catch((e) => e)
expect(err.message).toBe(
"Missing required property country_code for geo zone type zip"
)
data = {
name: "test",
fulfillment_set_id: fulfillmentSet.id,
geo_zones: [
{
type: "unknown",
postal_expression: "test",
} as any,
],
}
err = await service.createServiceZones(data).catch((e) => e)
expect(err.message).toBe(`Invalid geo zone type: unknown`)
})
})
describe("on update", () => {

View File

@@ -8,6 +8,7 @@ import { generateCreateShippingOptionsData } from "../../__fixtures__"
import { resolve } from "path"
import { FulfillmentProviderService } from "@services"
import { FulfillmentProviderServiceFixtures } from "../../__fixtures__/providers"
import { GeoZoneType } from "@medusajs/utils"
jest.setTimeout(100000)
@@ -35,10 +36,7 @@ const providerId = FulfillmentProviderService.getRegistrationIdentifier(
moduleIntegrationTestRunner({
moduleName: Modules.FULFILLMENT,
moduleOptions,
testSuite: ({
MikroOrmWrapper,
service,
}: SuiteOptions<IFulfillmentModuleService>) => {
testSuite: ({ service }: SuiteOptions<IFulfillmentModuleService>) => {
describe("Fulfillment Module Service", () => {
describe("read", () => {
it("should list shipping options with a filter", async function () {
@@ -158,7 +156,7 @@ moduleIntegrationTestRunner({
}),
])
let listedOptions = await service.listShippingOptions({
let listedOptions = await service.listShippingOptionsForContext({
context: {
"test-attribute": "test",
"test-attribute2": {
@@ -175,8 +173,12 @@ moduleIntegrationTestRunner({
])
)
listedOptions = await service.listShippingOptions({
fulfillment_set_id: { $ne: fulfillmentSet.id },
listedOptions = await service.listShippingOptionsForContext({
service_zone: {
fulfillment_set: {
id: { $ne: fulfillmentSet.id },
},
},
context: {
"test-attribute": "test",
"test-attribute2": {
@@ -187,8 +189,12 @@ moduleIntegrationTestRunner({
expect(listedOptions).toHaveLength(0)
listedOptions = await service.listShippingOptions({
fulfillment_set_type: "non-existing-type",
listedOptions = await service.listShippingOptionsForContext({
service_zone: {
fulfillment_set: {
type: "non-existing-type",
},
},
context: {
"test-attribute": "test",
"test-attribute2": {
@@ -199,6 +205,207 @@ moduleIntegrationTestRunner({
expect(listedOptions).toHaveLength(0)
})
it(`should list the shipping options for a context with a specific address`, async function () {
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
service_zones: [
{
name: "test",
geo_zones: [
{
type: GeoZoneType.ZIP,
country_code: "fr",
province_code: "rhone",
city: "paris",
postal_expression: "75006",
},
],
},
],
})
const shippingProfile = await service.createShippingProfiles({
name: "test",
type: "default",
})
const [shippingOption1, , shippingOption3] =
await service.createShippingOptions([
generateCreateShippingOptionsData({
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: providerId,
rules: [
{
attribute: "test-attribute",
operator: "in",
value: ["test"],
},
],
}),
generateCreateShippingOptionsData({
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: providerId,
rules: [
{
attribute: "test-attribute",
operator: "in",
value: ["test-test"],
},
],
}),
generateCreateShippingOptionsData({
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: providerId,
rules: [
{
attribute: "test-attribute",
operator: "eq",
value: "test",
},
{
attribute: "test-attribute2.options",
operator: "in",
value: ["test", "test2"],
},
],
}),
])
let shippingOptions = await service.listShippingOptionsForContext({
address: {
country_code: "fr",
province_code: "rhone",
city: "paris",
postal_expression: "75006",
},
})
expect(shippingOptions).toHaveLength(3)
shippingOptions = await service.listShippingOptionsForContext({
address: {
country_code: "fr",
province_code: "rhone",
city: "paris",
postal_expression: "75001",
},
})
expect(shippingOptions).toHaveLength(0)
shippingOptions = await service.listShippingOptionsForContext({
address: {
country_code: "fr",
province_code: "rhone",
city: "paris",
postal_expression: "75006",
},
context: {
"test-attribute": "test",
"test-attribute2": {
options: "test2",
},
},
})
expect(shippingOptions).toHaveLength(2)
expect(shippingOptions).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: shippingOption1.id }),
expect.objectContaining({ id: shippingOption3.id }),
])
)
})
})
it("should validate if a shipping option is applicable to a context", async function () {
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
service_zones: [
{
name: "test",
},
],
})
const shippingProfile = await service.createShippingProfiles({
name: "test",
type: "default",
})
const [shippingOption1, shippingOption2, shippingOption3] =
await service.createShippingOptions([
generateCreateShippingOptionsData({
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: providerId,
rules: [
{
attribute: "test-attribute",
operator: "in",
value: ["test"],
},
],
}),
generateCreateShippingOptionsData({
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: providerId,
rules: [
{
attribute: "test-attribute",
operator: "in",
value: ["test-test"],
},
],
}),
generateCreateShippingOptionsData({
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: providerId,
rules: [
{
attribute: "test-attribute",
operator: "eq",
value: "test",
},
{
attribute: "test-attribute2.options",
operator: "in",
value: ["test", "test2"],
},
],
}),
])
let listedOptions = await service.listShippingOptions()
expect(listedOptions).toHaveLength(3)
const context = {
"test-attribute": "test",
"test-attribute2": {
options: "test2",
},
}
const isShippingOption1Applicable =
await service.validateShippingOption(shippingOption1.id, context)
expect(isShippingOption1Applicable).toBeTruthy()
const isShippingOption2Applicable =
await service.validateShippingOption(shippingOption2.id, context)
expect(isShippingOption2Applicable).toBeFalsy()
const isShippingOption3Applicable =
await service.validateShippingOption(shippingOption3.id, context)
expect(isShippingOption3Applicable).toBeTruthy()
})
describe("mutations", () => {

View File

@@ -28,7 +28,7 @@
"watch:test": "tsc --build tsconfig.spec.json --watch",
"prepublishOnly": "cross-env NODE_ENV=production tsc --build && tsc-alias -p tsconfig.json",
"build": "rimraf dist && tsc --build && tsc-alias -p tsconfig.json",
"test": "jest --runInBand --bail --forceExit -- src/**/__tests__/**/*.ts",
"test": "jest --runInBand --bail --forceExit --passWithNoTests -- src/**/__tests__/**/*.ts",
"test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.spec.ts",
"migration:generate": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:generate",
"migration:initial": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create --initial -n InitialSetupMigration",

View File

@@ -1,5 +0,0 @@
describe("noop", function () {
it("should run", function () {
expect(true).toBe(true)
})
})

View File

@@ -2,8 +2,6 @@ import {
Context,
DAL,
FilterableFulfillmentSetProps,
FilterableShippingOptionProps,
FilterQuery,
FindConfig,
FulfillmentDTO,
FulfillmentTypes,
@@ -131,69 +129,17 @@ export default class FulfillmentModuleService<
return joinerConfig
}
protected static normalizeShippingOptionsListParams(
filters: FilterableShippingOptionProps = {},
config: FindConfig<ShippingOptionDTO> = {}
) {
let { fulfillment_set_id, fulfillment_set_type, context, ...where } =
filters
const normalizedConfig = { ...config }
normalizedConfig.relations = [
"rules",
"type",
"shipping_profile",
"provider",
...(normalizedConfig.relations ?? []),
]
// The assumption is that there won't be an infinite amount of shipping options. So if a context filtering needs to be applied we can retrieve them all.
normalizedConfig.take =
normalizedConfig.take ?? (context ? null : undefined)
let normalizedFilters = { ...where } as FilterQuery
if (fulfillment_set_id || fulfillment_set_type) {
const fulfillmentSetConstraints = {}
if (fulfillment_set_id) {
fulfillmentSetConstraints["id"] = fulfillment_set_id
}
if (fulfillment_set_type) {
fulfillmentSetConstraints["type"] = fulfillment_set_type
}
normalizedFilters = {
...normalizedFilters,
service_zone: {
fulfillment_set: fulfillmentSetConstraints,
},
}
normalizedConfig.relations.push("service_zone.fulfillment_set")
}
normalizedConfig.relations = Array.from(new Set(normalizedConfig.relations))
return {
filters: normalizedFilters,
config: normalizedConfig,
context,
}
}
@InjectManager("baseRepository_")
// @ts-ignore
async listShippingOptions(
filters: FilterableShippingOptionProps = {},
async listShippingOptionsForContext(
filters: FulfillmentTypes.FilterableShippingOptionForContextProps,
config: FindConfig<ShippingOptionDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<FulfillmentTypes.ShippingOptionDTO[]> {
const {
filters: normalizedFilters,
config: normalizedConfig,
context,
} = FulfillmentModuleService.normalizeShippingOptionsListParams(
config: normalizedConfig,
filters: normalizedFilters,
} = FulfillmentModuleService.normalizeListShippingOptionsForContextParams(
filters,
config
)
@@ -204,7 +150,6 @@ export default class FulfillmentModuleService<
sharedContext
)
// Apply rules context filtering
if (context) {
shippingOptions = shippingOptions.filter((shippingOption) => {
if (!shippingOption.rules?.length) {
@@ -245,14 +190,6 @@ export default class FulfillmentModuleService<
)
}
async retrieveFulfillmentOptions(
providerId: string
): Promise<Record<string, any>[]> {
return await this.fulfillmentProviderService_.getFulfillmentOptions(
providerId
)
}
@InjectManager("baseRepository_")
async listFulfillments(
filters: FulfillmentTypes.FilterableFulfillmentProps = {},
@@ -331,6 +268,20 @@ export default class FulfillmentModuleService<
): Promise<TEntity | TEntity[]> {
const data_ = Array.isArray(data) ? data : [data]
if (!data_.length) {
return []
}
for (const fulfillmentSet of data_) {
if (fulfillmentSet.service_zones?.length) {
for (const serviceZone of fulfillmentSet.service_zones) {
if (serviceZone.geo_zones?.length) {
FulfillmentModuleService.validateGeoZones(serviceZone.geo_zones)
}
}
}
}
const createdFulfillmentSets = await this.fulfillmentSetService_.create(
data_,
sharedContext
@@ -384,6 +335,14 @@ export default class FulfillmentModuleService<
return []
}
for (const serviceZone of data_) {
if (serviceZone.geo_zones?.length) {
if (serviceZone.geo_zones?.length) {
FulfillmentModuleService.validateGeoZones(serviceZone.geo_zones)
}
}
}
const createdServiceZones = await this.serviceZoneService_.create(
data_,
sharedContext
@@ -520,13 +479,17 @@ export default class FulfillmentModuleService<
| FulfillmentTypes.CreateGeoZoneDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<FulfillmentTypes.GeoZoneDTO | FulfillmentTypes.GeoZoneDTO[]> {
const data_ = Array.isArray(data) ? data : [data]
FulfillmentModuleService.validateGeoZones(data_)
const createdGeoZones = await this.geoZoneService_.create(
data,
data_,
sharedContext
)
return await this.baseRepository_.serialize<FulfillmentTypes.GeoZoneDTO[]>(
createdGeoZones,
Array.isArray(data) ? createdGeoZones : createdGeoZones[0],
{
populate: true,
}
@@ -777,6 +740,11 @@ export default class FulfillmentModuleService<
fulfillmentSet.service_zones = fulfillmentSet.service_zones.map(
(serviceZone) => {
if (!("id" in serviceZone)) {
if (serviceZone.geo_zones?.length) {
FulfillmentModuleService.validateGeoZones(
serviceZone.geo_zones
)
}
return serviceZone
}
return serviceZonesMap.get(serviceZone.id)!
@@ -940,6 +908,7 @@ export default class FulfillmentModuleService<
serviceZone.geo_zones = serviceZone.geo_zones.map((geoZone) => {
if (!("id" in geoZone)) {
FulfillmentModuleService.validateGeoZones([geoZone])
return geoZone
}
return geoZonesMap.get(geoZone.id)!
@@ -1138,6 +1107,14 @@ export default class FulfillmentModuleService<
| FulfillmentTypes.UpdateGeoZoneDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<FulfillmentTypes.GeoZoneDTO | FulfillmentTypes.GeoZoneDTO[]> {
const data_ = Array.isArray(data) ? data : [data]
if (!data_.length) {
return []
}
FulfillmentModuleService.validateGeoZones(data_)
const updatedGeoZones = await this.geoZoneService_.update(
data,
sharedContext
@@ -1271,6 +1248,41 @@ export default class FulfillmentModuleService<
return Array.isArray(result) ? result[0] : result
}
async retrieveFulfillmentOptions(
providerId: string
): Promise<Record<string, any>[]> {
return await this.fulfillmentProviderService_.getFulfillmentOptions(
providerId
)
}
async validateFulfillmentOption(
providerId: string,
data: Record<string, unknown>
): Promise<boolean> {
return await this.fulfillmentProviderService_.validateOption(
providerId,
data
)
}
@InjectManager("baseRepository_")
async validateShippingOption(
shippingOptionId: string,
context: Record<string, unknown> = {},
@MedusaContext() sharedContext: Context = {}
) {
const shippingOptions = await this.listShippingOptionsForContext(
{ id: shippingOptionId, context },
{
relations: ["rules"],
},
sharedContext
)
return !!shippingOptions.length
}
protected static canCancelFulfillmentOrThrow(fulfillment: Fulfillment) {
if (fulfillment.shipped_at) {
throw new MedusaError(
@@ -1336,4 +1348,213 @@ export default class FulfillmentModuleService<
)
}
}
protected static validateGeoZones(
geoZones: (
| (Partial<FulfillmentTypes.CreateGeoZoneDTO> & { type: string })
| (Partial<FulfillmentTypes.UpdateGeoZoneDTO> & { type: string })
)[]
) {
const requirePropForType = {
country: ["country_code"],
province: ["country_code", "province_code"],
city: ["country_code", "province_code", "city"],
zip: ["country_code", "province_code", "city", "postal_expression"],
}
for (const geoZone of geoZones) {
if (!requirePropForType[geoZone.type]) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Invalid geo zone type: ${geoZone.type}`
)
}
for (const prop of requirePropForType[geoZone.type]) {
if (!geoZone[prop]) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Missing required property ${prop} for geo zone type ${geoZone.type}`
)
}
}
}
}
protected static normalizeListShippingOptionsForContextParams(
filters: FulfillmentTypes.FilterableShippingOptionForContextProps,
config: FindConfig<ShippingOptionDTO> = {}
) {
let {
fulfillment_set_id,
fulfillment_set_type,
address,
context,
...where
} = filters
const normalizedConfig = { ...config }
normalizedConfig.relations = [
"rules",
"type",
"shipping_profile",
"provider",
...(normalizedConfig.relations ?? []),
]
normalizedConfig.take =
normalizedConfig.take ?? (context ? null : undefined)
let normalizedFilters = { ...where }
if (fulfillment_set_id || fulfillment_set_type) {
const fulfillmentSetConstraints = {}
if (fulfillment_set_id) {
fulfillmentSetConstraints["id"] = fulfillment_set_id
}
if (fulfillment_set_type) {
fulfillmentSetConstraints["type"] = fulfillment_set_type
}
normalizedFilters = {
...normalizedFilters,
service_zone: {
...(normalizedFilters.service_zone ?? {}),
fulfillment_set: {
...(normalizedFilters.service_zone?.fulfillment_set ?? {}),
...fulfillmentSetConstraints,
},
},
}
normalizedConfig.relations.push("service_zone.fulfillment_set")
}
if (address) {
const geoZoneConstraints =
FulfillmentModuleService.buildGeoZoneConstraintsFromAddress(address)
normalizedFilters = {
...normalizedFilters,
service_zone: {
...(normalizedFilters.service_zone ?? {}),
geo_zones: {
$or: geoZoneConstraints.map((geoZoneConstraint) => ({
// Apply eventually provided constraints on the geo zone along side the address constraints
...(normalizedFilters.service_zone?.geo_zones ?? {}),
...geoZoneConstraint,
})),
},
},
}
normalizedConfig.relations.push("service_zone.geo_zones")
}
normalizedConfig.relations = Array.from(new Set(normalizedConfig.relations))
return {
filters: normalizedFilters,
config: normalizedConfig,
context,
}
}
/**
* Build the constraints for the geo zones based on the address properties
* available and the hierarchy of required properties.
* We build a OR constraint from the narrowest to the broadest
* e.g. if we have a postal expression we build a constraint for the postal expression require props of type zip
* and a constraint for the city required props of type city
* and a constraint for the province code required props of type province
* and a constraint for the country code required props of type country
* example:
* {
* $or: [
* {
* type: "zip",
* country_code: "SE",
* province_code: "AB",
* city: "Stockholm",
* postal_expression: "12345"
* },
* {
* type: "city",
* country_code: "SE",
* province_code: "AB",
* city: "Stockholm"
* },
* {
* type: "province",
* country_code: "SE",
* province_code: "AB"
* },
* {
* type: "country",
* country_code: "SE"
* }
* ]
* }
*/
private static buildGeoZoneConstraintsFromAddress(
address: FulfillmentTypes.FilterableShippingOptionForContextProps["address"]
) {
/**
* Define the hierarchy of required properties for the geo zones.
*/
const geoZoneRequirePropertyHierarchy = {
postal_expression: [
"country_code",
"province_code",
"city",
"postal_expression",
],
city: ["country_code", "province_code", "city"],
province_code: ["country_code", "province_code"],
country_code: ["country_code"],
}
/**
* Validate that the address has the required properties for the geo zones
* constraints to build after. We are going from the narrowest to the broadest
*/
Object.entries(geoZoneRequirePropertyHierarchy).forEach(
([prop, requiredProps]) => {
if (address![prop]) {
for (const requiredProp of requiredProps) {
if (!address![requiredProp]) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Missing required property ${requiredProp} for address when property ${prop} is set`
)
}
}
}
}
)
const geoZoneConstraints = Object.entries(geoZoneRequirePropertyHierarchy)
.map(([prop, requiredProps]) => {
if (address![prop]) {
return requiredProps.reduce((geoZoneConstraint, prop) => {
geoZoneConstraint.type =
prop === "postal_expression"
? "zip"
: prop === "city"
? "city"
: prop === "province_code"
? "province"
: "country"
geoZoneConstraint[prop] = address![prop]
return geoZoneConstraint
}, {} as Record<string, string | undefined>)
}
return null
})
.filter((v): v is Record<string, any> => !!v)
return geoZoneConstraints
}
}

View File

@@ -78,8 +78,8 @@ export default class FulfillmentProviderService extends ModulesSdkUtils.internal
return await provider.validateFulfillmentData(optionData, data, context)
}
async validateOption(data: any) {
const provider = this.retrieveProviderRegistration(data.provider_id)
async validateOption(providerId: string, data: Record<string, unknown>) {
const provider = this.retrieveProviderRegistration(providerId)
return await provider.validateOption(data)
}

View File

@@ -73,7 +73,7 @@ export function isContextValid(
} = {
someAreValid: false,
}
) {
): boolean {
const { someAreValid } = options
const loopComparator = someAreValid ? rules.some : rules.every

View File

@@ -1,4 +1,7 @@
import { FulfillmentSetDTO } from "./fulfillment-set"
import {
FilterableFulfillmentSetProps,
FulfillmentSetDTO,
} from "./fulfillment-set"
import { FilterableGeoZoneProps, GeoZoneDTO } from "./geo-zone"
import { ShippingOptionDTO } from "./shipping-option"
import { BaseFilterable, OperatorMap } from "../../dal"
@@ -20,5 +23,6 @@ export interface FilterableServiceZoneProps
id?: string | string[] | OperatorMap<string | string[]>
name?: string | string[] | OperatorMap<string | string[]>
geo_zones?: FilterableGeoZoneProps
fulfillment_set?: FilterableFulfillmentSetProps
shipping_options?: any // TODO
}

View File

@@ -39,9 +39,7 @@ export interface FilterableShippingOptionProps
extends BaseFilterable<FilterableShippingOptionProps> {
id?: string | string[] | OperatorMap<string | string[]>
name?: string | string[] | OperatorMap<string | string[]>
fulfillment_set_id?: string | string[] | OperatorMap<string | string[]>
shipping_profile_id?: string | string[] | OperatorMap<string | string[]>
fulfillment_set_type?: string | string[] | OperatorMap<string | string[]>
price_type?:
| ShippingOptionPriceType
| ShippingOptionPriceType[]
@@ -49,5 +47,22 @@ export interface FilterableShippingOptionProps
service_zone?: FilterableServiceZoneProps
shipping_option_type?: FilterableShippingOptionTypeProps
rules?: FilterableShippingOptionRuleProps
context?: Record<string, unknown>
}
export interface FilterableShippingOptionForContextProps
extends FilterableShippingOptionProps {
fulfillment_set_id?: string | string[] | OperatorMap<string | string[]>
fulfillment_set_type?: string | string[] | OperatorMap<string | string[]>
/**
* The address is a shortcut to filter through geo_zones
* and build opinionated validation and filtering around the geo_zones.
* For custom filtering you can go through the service_zone.geo_zones directly.
*/
address?: {
country_code?: string
province_code?: string
city?: string
postal_expression?: string
}
context?: Record<string, any>
}

View File

@@ -18,11 +18,14 @@ export interface CreateProvinceGeoZoneDTO extends CreateGeoZoneBaseDTO {
export interface CreateCityGeoZoneDTO extends CreateGeoZoneBaseDTO {
type: "city"
province_code: string
city: string
}
export interface CreateZipGeoZoneDTO extends CreateGeoZoneBaseDTO {
type: "zip"
province_code: string
city: string
postal_expression: Record<string, any>
}
@@ -47,12 +50,15 @@ export interface UpdateProvinceGeoZoneDTO extends UpdateGeoZoneBaseDTO {
export interface UpdateCityGeoZoneDTO extends UpdateGeoZoneBaseDTO {
type: "city"
city: string
province_code?: string
city?: string
}
export interface UpdateZipGeoZoneDTO extends UpdateGeoZoneBaseDTO {
type: "zip"
postal_expression: Record<string, any>
province_code?: string
city?: string
postal_expression?: Record<string, any>
}
export type UpdateGeoZoneDTO =

View File

@@ -3,6 +3,7 @@ import {
FilterableFulfillmentSetProps,
FilterableGeoZoneProps,
FilterableServiceZoneProps,
FilterableShippingOptionForContextProps,
FilterableShippingOptionProps,
FilterableShippingOptionRuleProps,
FilterableShippingOptionTypeProps,
@@ -326,13 +327,24 @@ export interface IFulfillmentModuleService extends IModuleService {
sharedContext?: Context
): Promise<ShippingOptionDTO[]>
/**
* List and count shipping options
* List shipping options and eventually filter the result based on the context and their rules
* @param filters
* @param config
* @param sharedContext
*/
listShippingOptionsForContext(
filters: FilterableShippingOptionForContextProps,
config?: FindConfig<ShippingOptionDTO>,
sharedContext?: Context
): Promise<ShippingOptionDTO[]>
/**
* List and count shipping options without taking into account the context
* @param filters
* @param config
* @param sharedContext
*/
listAndCountShippingOptions(
filters?: FilterableShippingOptionProps,
filters?: Omit<FilterableShippingOptionProps, "context">,
config?: FindConfig<ShippingOptionDTO>,
sharedContext?: Context
): Promise<[ShippingOptionDTO[], number]>
@@ -664,4 +676,20 @@ export interface IFulfillmentModuleService extends IModuleService {
retrieveFulfillmentOptions(
providerId: string
): Promise<Record<string, unknown>[]>
/**
* Validate the given shipping option fulfillment option from the provided data
*/
validateFulfillmentOption(
providerId: string,
data: Record<string, unknown>
): Promise<boolean>
/**
* Validate if the given shipping option is valid for a given context
*/
validateShippingOption(
shippingOptionId: string,
context: Record<string, unknown>
): Promise<boolean>
}