feat(fulfillment): Shipping options, rules CRUD + rules based context filtering (#6455)

**What**
- Update shipping options with its rules and type
- create/update rules independently
- context based validation fundation
- 🔴 list shipping options with context rules fitlering will come in a separate pr to keep this one smaller

FIXES CORE-1743
FIXES CORE-1764
This commit is contained in:
Adrien de Peretti
2024-02-23 12:14:33 +01:00
committed by GitHub
parent 78b6d46584
commit 788c4a1e36
19 changed files with 1767 additions and 55 deletions

View File

@@ -812,7 +812,7 @@ moduleIntegrationTestRunner({
{
attribute: "test-attribute",
operator: "in",
value: "test-value",
value: ["test-value"],
},
],
}
@@ -887,7 +887,7 @@ moduleIntegrationTestRunner({
rules: [
{
attribute: "test-attribute",
operator: "in",
operator: "eq",
value: "test-value",
},
],
@@ -909,7 +909,7 @@ moduleIntegrationTestRunner({
rules: [
{
attribute: "test-attribute",
operator: "in",
operator: "eq",
value: "test-value",
},
],
@@ -953,6 +953,145 @@ moduleIntegrationTestRunner({
++i
}
})
it("should fail to create a new shipping option with invalid rules", async function () {
const shippingProfile = await service.createShippingProfiles({
name: "test",
type: "default",
})
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const serviceZone = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
// TODO: change that for a real provider instead of fake data manual inserted data
const [{ id: providerId }] =
await MikroOrmWrapper.forkManager().execute(
"insert into service_provider (id) values ('sp_jdafwfleiwuonl') returning id"
)
const createData: CreateShippingOptionDTO = {
name: "test-option",
price_type: "flat",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: providerId,
type: {
code: "test-type",
description: "test-description",
label: "test-label",
},
data: {
amount: 1000,
},
rules: [
{
attribute: "test-attribute",
operator: "invalid",
value: "test-value",
},
],
}
const err = await service
.createShippingOptions(createData)
.catch((e) => e)
expect(err).toBeDefined()
expect(err.message).toBe(
"Rule operator invalid is not supported. Must be one of in, eq, ne, gt, gte, lt, lte, nin"
)
})
})
describe("on create shipping option rules", () => {
it("should create a new rule", async () => {
const shippingProfile = await service.createShippingProfiles({
name: "test",
type: "default",
})
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const serviceZone = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
// service provider
const [{ id: providerId }] =
await MikroOrmWrapper.forkManager().execute(
"insert into service_provider (id) values ('sp_jdafwfleiwuonl') returning id"
)
const shippingOption = await service.createShippingOptions({
name: "test-option",
price_type: "flat",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: providerId,
type: {
code: "test-type",
description: "test-description",
label: "test-label",
},
data: {
amount: 1000,
},
rules: [
{
attribute: "test-attribute",
operator: "eq",
value: "test-value",
},
],
})
const ruleData = {
attribute: "test-attribute",
operator: "eq",
value: "test-value",
shipping_option_id: shippingOption.id,
}
const rule = await service.createShippingOptionRules(ruleData)
expect(rule).toEqual(
expect.objectContaining({
id: expect.any(String),
attribute: ruleData.attribute,
operator: ruleData.operator,
value: ruleData.value,
shipping_option_id: ruleData.shipping_option_id,
})
)
const rules = await service.listShippingOptionRules()
expect(rules).toHaveLength(2)
expect(rules).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rule.id,
attribute: ruleData.attribute,
operator: ruleData.operator,
value: ruleData.value,
shipping_option_id: shippingOption.id,
}),
expect.objectContaining({
id: shippingOption.rules[0].id,
attribute: shippingOption.rules[0].attribute,
operator: shippingOption.rules[0].operator,
value: shippingOption.rules[0].value,
shipping_option_id: shippingOption.id,
}),
])
)
})
})
describe("on update", () => {
@@ -1680,6 +1819,698 @@ moduleIntegrationTestRunner({
}
})
})
describe("on update shipping options", () => {
it("should update a shipping option", async () => {
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const serviceZone = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
const shippingProfile = await service.createShippingProfiles({
name: "test",
type: "default",
})
const [serviceProvider] =
await MikroOrmWrapper.forkManager().execute(
"insert into service_provider (id) values ('sp_jdafwfleiwuonl') returning id"
)
const shippingOptionData = {
name: "test",
price_type: "flat",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: serviceProvider.id,
type: {
code: "test",
description: "test",
label: "test",
},
data: {
amount: 1000,
},
rules: [
{
attribute: "test",
operator: "eq",
value: "test",
},
],
}
const shippingOption = await service.createShippingOptions(
shippingOptionData
)
const updateData = {
id: shippingOption.id,
name: "updated-test",
price_type: "calculated",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: serviceProvider.id,
type: {
code: "updated-test",
description: "updated-test",
label: "updated-test",
},
data: {
amount: 2000,
},
rules: [
{
attribute: "new-test",
operator: "eq",
value: "new-test",
},
],
}
const updatedShippingOption = await service.updateShippingOptions(
updateData
)
expect(updatedShippingOption).toEqual(
expect.objectContaining({
id: updateData.id,
name: updateData.name,
price_type: updateData.price_type,
service_zone_id: updateData.service_zone_id,
shipping_profile_id: updateData.shipping_profile_id,
service_provider_id: updateData.service_provider_id,
shipping_option_type_id: expect.any(String),
type: expect.objectContaining({
id: expect.any(String),
code: updateData.type.code,
description: updateData.type.description,
label: updateData.type.label,
}),
data: updateData.data,
rules: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
attribute: updateData.rules[0].attribute,
operator: updateData.rules[0].operator,
value: updateData.rules[0].value,
}),
]),
})
)
const rules = await service.listShippingOptionRules()
expect(rules).toHaveLength(1)
expect(rules[0]).toEqual(
expect.objectContaining({
id: updatedShippingOption.rules[0].id,
})
)
const types = await service.listShippingOptionTypes()
expect(types).toHaveLength(1)
expect(types[0]).toEqual(
expect.objectContaining({
code: updateData.type.code,
description: updateData.type.description,
label: updateData.type.label,
})
)
})
it("should update a shipping option without updating the rules or the type", async () => {
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const serviceZone = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
const shippingProfile = await service.createShippingProfiles({
name: "test",
type: "default",
})
const [serviceProvider] =
await MikroOrmWrapper.forkManager().execute(
"insert into service_provider (id) values ('sp_jdafwfleiwuonl') returning id"
)
const shippingOptionData = {
name: "test",
price_type: "flat",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: serviceProvider.id,
type: {
code: "test",
description: "test",
label: "test",
},
data: {
amount: 1000,
},
rules: [
{
attribute: "test",
operator: "eq",
value: "test",
},
],
}
const shippingOption = await service.createShippingOptions(
shippingOptionData
)
const updateData = {
id: shippingOption.id,
name: "updated-test",
price_type: "calculated",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: serviceProvider.id,
data: {
amount: 2000,
},
}
await service.updateShippingOptions(updateData)
const updatedShippingOption = await service.retrieveShippingOption(
shippingOption.id,
{
relations: ["rules", "type"],
}
)
expect(updatedShippingOption).toEqual(
expect.objectContaining({
id: updateData.id,
name: updateData.name,
price_type: updateData.price_type,
service_zone_id: updateData.service_zone_id,
shipping_profile_id: updateData.shipping_profile_id,
service_provider_id: updateData.service_provider_id,
shipping_option_type_id: expect.any(String),
type: expect.objectContaining({
id: expect.any(String),
code: shippingOptionData.type.code,
description: shippingOptionData.type.description,
label: shippingOptionData.type.label,
}),
data: updateData.data,
rules: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
attribute: shippingOptionData.rules[0].attribute,
operator: shippingOptionData.rules[0].operator,
value: shippingOptionData.rules[0].value,
}),
]),
})
)
const rules = await service.listShippingOptionRules()
expect(rules).toHaveLength(1)
expect(rules[0]).toEqual(
expect.objectContaining({
id: updatedShippingOption.rules[0].id,
})
)
const types = await service.listShippingOptionTypes()
expect(types).toHaveLength(1)
expect(types[0]).toEqual(
expect.objectContaining({
code: shippingOptionData.type.code,
description: shippingOptionData.type.description,
label: shippingOptionData.type.label,
})
)
})
it("should update a collection of shipping options", async () => {
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const serviceZone = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
const shippingProfile = await service.createShippingProfiles({
name: "test",
type: "default",
})
const [serviceProvider] =
await MikroOrmWrapper.forkManager().execute(
"insert into service_provider (id) values ('sp_jdafwfleiwuonl') returning id"
)
const shippingOptionData = [
{
name: "test",
price_type: "flat",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: serviceProvider.id,
type: {
code: "test",
description: "test",
label: "test",
},
data: {
amount: 1000,
},
rules: [
{
attribute: "test",
operator: "eq",
value: "test",
},
],
},
{
name: "test2",
price_type: "calculated",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: serviceProvider.id,
type: {
code: "test",
description: "test",
label: "test",
},
data: {
amount: 1000,
},
rules: [
{
attribute: "test",
operator: "eq",
value: "test",
},
],
},
]
const shippingOptions = await service.createShippingOptions(
shippingOptionData
)
const updateData = [
{
id: shippingOptions[0].id,
name: "updated-test",
price_type: "calculated",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: serviceProvider.id,
type: {
code: "updated-test",
description: "updated-test",
label: "updated-test",
},
data: {
amount: 2000,
},
rules: [
{
attribute: "new-test",
operator: "eq",
value: "new-test",
},
],
},
{
id: shippingOptions[1].id,
name: "updated-test",
price_type: "calculated",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: serviceProvider.id,
type: {
code: "updated-test",
description: "updated-test",
label: "updated-test",
},
data: {
amount: 2000,
},
rules: [
{
attribute: "new-test",
operator: "eq",
value: "new-test",
},
],
},
]
const updatedShippingOption = await service.updateShippingOptions(
updateData
)
for (const data_ of updateData) {
const expectedShippingOption = updatedShippingOption.find(
(shippingOption) => shippingOption.id === data_.id
)
expect(expectedShippingOption).toEqual(
expect.objectContaining({
id: data_.id,
name: data_.name,
price_type: data_.price_type,
service_zone_id: data_.service_zone_id,
shipping_profile_id: data_.shipping_profile_id,
service_provider_id: data_.service_provider_id,
shipping_option_type_id: expect.any(String),
type: expect.objectContaining({
id: expect.any(String),
code: data_.type.code,
description: data_.type.description,
label: data_.type.label,
}),
data: data_.data,
rules: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
attribute: data_.rules[0].attribute,
operator: data_.rules[0].operator,
value: data_.rules[0].value,
}),
]),
})
)
}
const rules = await service.listShippingOptionRules()
expect(rules).toHaveLength(2)
expect(rules).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: updatedShippingOption[0].rules[0].id,
}),
expect.objectContaining({
id: updatedShippingOption[1].rules[0].id,
}),
])
)
const types = await service.listShippingOptionTypes()
expect(types).toHaveLength(2)
expect(types).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: updateData[0].type.code,
description: updateData[0].type.description,
label: updateData[0].type.label,
}),
expect.objectContaining({
code: updateData[1].type.code,
description: updateData[1].type.description,
label: updateData[1].type.label,
}),
])
)
})
it("should fail to update a non-existent shipping option", async () => {
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const serviceZone = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
const shippingProfile = await service.createShippingProfiles({
name: "test",
type: "default",
})
const [serviceProvider] =
await MikroOrmWrapper.forkManager().execute(
"insert into service_provider (id) values ('sp_jdafwfleiwuonl') returning id"
)
const shippingOptionData = {
id: "sp_jdafwfleiwuonl",
name: "test",
price_type: "flat",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: serviceProvider.id,
type: {
code: "test",
description: "test",
label: "test",
},
data: {
amount: 1000,
},
rules: [
{
attribute: "test",
operator: "eq",
value: "test",
},
],
}
const err = await service
.updateShippingOptions(shippingOptionData)
.catch((e) => e)
expect(err).toBeDefined()
expect(err.message).toBe(
`The following shipping options do not exist: ${shippingOptionData.id}`
)
})
it("should fail to update a shipping option when adding non existing rules", async () => {
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const serviceZone = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
const shippingProfile = await service.createShippingProfiles({
name: "test",
type: "default",
})
const [serviceProvider] =
await MikroOrmWrapper.forkManager().execute(
"insert into service_provider (id) values ('sp_jdafwfleiwuonl') returning id"
)
const shippingOptionData = {
name: "test",
price_type: "flat",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: serviceProvider.id,
type: {
code: "test",
description: "test",
label: "test",
},
data: {
amount: 1000,
},
rules: [
{
attribute: "test",
operator: "eq",
value: "test",
},
],
}
const shippingOption = await service.createShippingOptions(
shippingOptionData
)
const updateData = [
{
id: shippingOption.id,
rules: [
{
id: "sp_jdafwfleiwuonl",
},
],
},
]
const err = await service
.updateShippingOptions(updateData)
.catch((e) => e)
expect(err).toBeDefined()
expect(err.message).toBe(
`The following rules does not exists: ${updateData[0].rules[0].id} on shipping option ${shippingOption.id}`
)
})
it("should fail to update a shipping option when adding invalid rules", async () => {
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const serviceZone = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
const shippingProfile = await service.createShippingProfiles({
name: "test",
type: "default",
})
const [serviceProvider] =
await MikroOrmWrapper.forkManager().execute(
"insert into service_provider (id) values ('sp_jdafwfleiwuonl') returning id"
)
const shippingOptionData = {
name: "test",
price_type: "flat",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: serviceProvider.id,
type: {
code: "test",
description: "test",
label: "test",
},
data: {
amount: 1000,
},
rules: [
{
attribute: "test",
operator: "eq",
value: "test",
},
],
}
const shippingOption = await service.createShippingOptions(
shippingOptionData
)
const updateData = [
{
id: shippingOption.id,
rules: [
{
attribute: "test",
operator: "invalid",
value: "test",
},
],
},
]
const err = await service
.updateShippingOptions(updateData)
.catch((e) => e)
expect(err).toBeDefined()
expect(err.message).toBe(
`Rule operator invalid is not supported. Must be one of in, eq, ne, gt, gte, lt, lte, nin`
)
})
})
describe("on update shipping option rules", () => {
it("should update a shipping option rule", async () => {
const shippingProfile = await service.createShippingProfiles({
name: "test",
type: "default",
})
const fulfillmentSet = await service.create({
name: "test",
type: "test-type",
})
const serviceZone = await service.createServiceZones({
name: "test",
fulfillment_set_id: fulfillmentSet.id,
})
const [serviceProvider] =
await MikroOrmWrapper.forkManager().execute(
"insert into service_provider (id) values ('sp_jdafwfleiwuonl') returning id"
)
const shippingOption = await service.createShippingOptions({
name: "test",
price_type: "flat",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
service_provider_id: serviceProvider.id,
type: {
code: "test",
description: "test",
label: "test",
},
data: {
amount: 1000,
},
rules: [
{
attribute: "test",
operator: "eq",
value: "test",
},
],
})
const updateData = {
id: shippingOption.rules[0].id,
attribute: "updated-test",
operator: "eq",
value: "updated-test",
}
const updatedRule = await service.updateShippingOptionRules(
updateData
)
expect(updatedRule).toEqual(
expect.objectContaining({
id: updateData.id,
attribute: updateData.attribute,
operator: updateData.operator,
value: updateData.value,
})
)
})
it("should fail to update a non-existent shipping option rule", async () => {
const updateData = {
id: "sp_jdafwfleiwuonl",
attribute: "updated-test",
operator: "eq",
value: "updated-test",
}
const err = await service
.updateShippingOptionRules(updateData)
.catch((e) => e)
expect(err).toBeDefined()
expect(err.message).toBe(
`ShippingOptionRule with id "${updateData.id}" not found`
)
})
})
})
})
},

View File

@@ -4,6 +4,7 @@ module.exports = {
"^@services": "<rootDir>/src/services",
"^@repositories": "<rootDir>/src/repositories",
"^@types": "<rootDir>/src/types",
"^@utils": "<rootDir>/src/utils",
},
transform: {
"^.+\\.[jt]s?$": [

View File

@@ -31,7 +31,7 @@
"test": "jest --runInBand --bail --forceExit -- src/**/__tests__/**/*.ts",
"test:integration": "jest --runInBand --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",
"migration:initial": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create --initial -n InitialSetupMigration",
"migration:create": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create",
"migration:up": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:up",
"orm:cache:clear": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm cache:clear"

View File

@@ -493,7 +493,20 @@
}
],
"checks": [],
"foreignKeys": {}
"foreignKeys": {
"service_zone_fulfillment_set_id_foreign": {
"constraintName": "service_zone_fulfillment_set_id_foreign",
"columnNames": [
"fulfillment_set_id"
],
"localTableName": "public.service_zone",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.fulfillment_set",
"updateRule": "cascade"
}
}
},
{
"columns": {
@@ -673,7 +686,20 @@
}
],
"checks": [],
"foreignKeys": {}
"foreignKeys": {
"geo_zone_service_zone_id_foreign": {
"constraintName": "geo_zone_service_zone_id_foreign",
"columnNames": [
"service_zone_id"
],
"localTableName": "public.geo_zone",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.service_zone",
"updateRule": "cascade"
}
}
},
{
"columns": {
@@ -713,15 +739,6 @@
"nullable": false,
"mappedType": "text"
},
"shipping_option_id": {
"name": "shipping_option_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
@@ -758,16 +775,6 @@
"name": "shipping_option_type",
"schema": "public",
"indexes": [
{
"keyName": "IDX_shipping_option_type_shipping_option_id",
"columnNames": [
"shipping_option_id"
],
"composite": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_shipping_option_type_shipping_option_id\" ON \"shipping_option_type\" (shipping_option_id) WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_shipping_option_type_deleted_at",
"columnNames": [
@@ -1093,6 +1100,18 @@
],
"checks": [],
"foreignKeys": {
"shipping_option_service_zone_id_foreign": {
"constraintName": "shipping_option_service_zone_id_foreign",
"columnNames": [
"service_zone_id"
],
"localTableName": "public.shipping_option",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.service_zone",
"updateRule": "cascade"
},
"shipping_option_shipping_profile_id_foreign": {
"constraintName": "shipping_option_shipping_profile_id_foreign",
"columnNames": [
@@ -1161,7 +1180,17 @@
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
"enumItems": [
"in",
"eq",
"ne",
"gt",
"gte",
"lt",
"lte",
"nin"
],
"mappedType": "enum"
},
"value": {
"name": "value",
@@ -1248,7 +1277,20 @@
}
],
"checks": [],
"foreignKeys": {}
"foreignKeys": {
"shipping_option_rule_shipping_option_id_foreign": {
"constraintName": "shipping_option_rule_shipping_option_id_foreign",
"columnNames": [
"shipping_option_id"
],
"localTableName": "public.shipping_option_rule",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.shipping_option",
"updateRule": "cascade"
}
}
},
{
"columns": {

View File

@@ -1,6 +1,6 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20240219115644 extends Migration {
export class Migration20240221164918_InitialSetupMigration extends Migration {
async up(): Promise<void> {
this.addSql('create table if not exists "fulfillment_address" ("id" text not null, "fulfillment_id" text null, "company" text null, "first_name" text null, "last_name" text null, "address_1" text null, "address_2" text null, "city" text null, "country_code" text null, "province" text null, "postal_code" text null, "phone" text null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "fulfillment_address_pkey" primary key ("id"));');
@@ -26,8 +26,7 @@ export class Migration20240219115644 extends Migration {
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_geo_zone_service_zone_id" ON "geo_zone" (service_zone_id) WHERE deleted_at IS NULL;');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_geo_zone_deleted_at" ON "geo_zone" (deleted_at) WHERE deleted_at IS NOT NULL;');
this.addSql('create table if not exists "shipping_option_type" ("id" text not null, "label" text not null, "description" text null, "code" text not null, "shipping_option_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "shipping_option_type_pkey" primary key ("id"));');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_shipping_option_type_shipping_option_id" ON "shipping_option_type" (shipping_option_id) WHERE deleted_at IS NULL;');
this.addSql('create table if not exists "shipping_option_type" ("id" text not null, "label" text not null, "description" text null, "code" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "shipping_option_type_pkey" primary key ("id"));');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_shipping_option_type_deleted_at" ON "shipping_option_type" (deleted_at) WHERE deleted_at IS NOT NULL;');
this.addSql('create table if not exists "shipping_profile" ("id" text not null, "name" text not null, "type" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "shipping_profile_pkey" primary key ("id"));');
@@ -42,7 +41,7 @@ export class Migration20240219115644 extends Migration {
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_shipping_option_shipping_option_type_id" ON "shipping_option" (shipping_option_type_id) WHERE deleted_at IS NULL;');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_shipping_option_deleted_at" ON "shipping_option" (deleted_at) WHERE deleted_at IS NOT NULL;');
this.addSql('create table if not exists "shipping_option_rule" ("id" text not null, "attribute" text not null, "operator" text not null, "value" jsonb null, "shipping_option_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "shipping_option_rule_pkey" primary key ("id"));');
this.addSql('create table if not exists "shipping_option_rule" ("id" text not null, "attribute" text not null, "operator" text check ("operator" in (\'in\', \'eq\', \'ne\', \'gt\', \'gte\', \'lt\', \'lte\', \'nin\')) not null, "value" jsonb null, "shipping_option_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "shipping_option_rule_pkey" primary key ("id"));');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_shipping_option_rule_shipping_option_id" ON "shipping_option_rule" (shipping_option_id) WHERE deleted_at IS NULL;');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_shipping_option_rule_deleted_at" ON "shipping_option_rule" (deleted_at) WHERE deleted_at IS NOT NULL;');
@@ -62,10 +61,17 @@ export class Migration20240219115644 extends Migration {
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_fulfillment_item_fulfillment_id" ON "fulfillment_item" (fulfillment_id) WHERE deleted_at IS NULL;');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_fulfillment_item_deleted_at" ON "fulfillment_item" (deleted_at) WHERE deleted_at IS NOT NULL;');
this.addSql('alter table if exists "service_zone" add constraint "service_zone_fulfillment_set_id_foreign" foreign key ("fulfillment_set_id") references "fulfillment_set" ("id") on update cascade;');
this.addSql('alter table if exists "geo_zone" add constraint "geo_zone_service_zone_id_foreign" foreign key ("service_zone_id") references "service_zone" ("id") on update cascade;');
this.addSql('alter table if exists "shipping_option" add constraint "shipping_option_service_zone_id_foreign" foreign key ("service_zone_id") references "service_zone" ("id") on update cascade;');
this.addSql('alter table if exists "shipping_option" add constraint "shipping_option_shipping_profile_id_foreign" foreign key ("shipping_profile_id") references "shipping_profile" ("id") on update cascade on delete set null;');
this.addSql('alter table if exists "shipping_option" add constraint "shipping_option_service_provider_id_foreign" foreign key ("service_provider_id") references "service_provider" ("id") on update cascade on delete set null;');
this.addSql('alter table if exists "shipping_option" add constraint "shipping_option_shipping_option_type_id_foreign" foreign key ("shipping_option_type_id") references "shipping_option_type" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "shipping_option_rule" add constraint "shipping_option_rule_shipping_option_id_foreign" foreign key ("shipping_option_id") references "shipping_option" ("id") on update cascade;');
this.addSql('alter table if exists "fulfillment" add constraint "fulfillment_shipping_option_id_foreign" foreign key ("shipping_option_id") references "shipping_option" ("id") on update cascade on delete set null;');
this.addSql('alter table if exists "fulfillment" add constraint "fulfillment_provider_id_foreign" foreign key ("provider_id") references "service_provider" ("id") on update cascade;');
this.addSql('alter table if exists "fulfillment" add constraint "fulfillment_delivery_address_id_foreign" foreign key ("delivery_address_id") references "fulfillment_address" ("id") on update cascade;');

View File

@@ -74,7 +74,11 @@ export default class GeoZone {
@Property({ columnType: "text", nullable: true })
city: string | null = null
@Property({ columnType: "text" })
@ManyToOne(() => ServiceZone, {
type: "text",
mapToPk: true,
fieldName: "service_zone_id",
})
@ServiceZoneIdIndex.MikroORMIndex()
service_zone_id: string

View File

@@ -61,7 +61,11 @@ export default class ServiceZone {
@Property({ columnType: "jsonb", nullable: true })
metadata: Record<string, unknown> | null = null
@Property({ columnType: "text" })
@ManyToOne(() => FulfillmentSet, {
type: "text",
mapToPk: true,
fieldName: "fulfillment_set_id",
})
@FulfillmentSetIdIndex.MikroORMIndex()
fulfillment_set_id: string

View File

@@ -8,6 +8,7 @@ import { DAL } from "@medusajs/types"
import {
BeforeCreate,
Entity,
Enum,
Filter,
ManyToOne,
OnInit,
@@ -16,6 +17,7 @@ import {
Property,
} from "@mikro-orm/core"
import ShippingOption from "./shipping-option"
import { RuleOperator } from "@utils"
type ShippingOptionRuleOptionalProps = DAL.SoftDeletableEntityDateColumns
@@ -42,17 +44,26 @@ export default class ShippingOptionRule {
@Property({ columnType: "text" })
attribute: string
@Property({ columnType: "text" })
operator: string
@Enum({
items: () => Object.values(RuleOperator),
columnType: "text",
})
operator: Lowercase<keyof typeof RuleOperator>
@Property({ columnType: "jsonb", nullable: true })
value: { value: string | string[] } | null = null
value: string | string[] | null = null
@Property({ columnType: "text" })
@ManyToOne(() => ShippingOption, {
type: "text",
mapToPk: true,
fieldName: "shipping_option_id",
})
@ShippingOptionIdIndex.MikroORMIndex()
shipping_option_id: string
@ManyToOne(() => ShippingOption, { persist: false })
@ManyToOne(() => ShippingOption, {
persist: false,
})
shipping_option: ShippingOption
@Property({

View File

@@ -48,12 +48,8 @@ export default class ShippingOptionType {
@Property({ columnType: "text" })
code: string
@Property({ columnType: "text" })
@ShippingOptionIdIndex.MikroORMIndex()
shipping_option_id: string
@OneToOne(() => ShippingOption, (so) => so.type, {
persist: false,
type: "text",
})
shipping_option: ShippingOption
@@ -79,12 +75,10 @@ export default class ShippingOptionType {
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "sotype")
this.shipping_option_id ??= this.shipping_option?.id
}
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "sotype")
this.shipping_option_id ??= this.shipping_option?.id
}
}

View File

@@ -77,7 +77,7 @@ export default class ShippingOption {
})
price_type: ShippingOptionPriceType
@ManyToOne(() => ShippingProfile, {
@ManyToOne(() => ServiceZone, {
type: "text",
fieldName: "service_zone_id",
mapToPk: true,
@@ -129,6 +129,7 @@ export default class ShippingOption {
@OneToOne(() => ShippingOptionType, (so) => so.shipping_option, {
owner: true,
cascade: [Cascade.PERSIST, Cascade.REMOVE, "soft-remove"] as any,
orphanRemoval: true,
fieldName: "shipping_option_type_id",
})
type: ShippingOptionType

View File

@@ -9,6 +9,7 @@ import {
UpdateFulfillmentSetDTO,
} from "@medusajs/types"
import {
arrayDifference,
getSetDifference,
InjectManager,
InjectTransactionManager,
@@ -24,14 +25,19 @@ import {
GeoZone,
ServiceZone,
ShippingOption,
ShippingOptionRule,
ShippingOptionType,
ShippingProfile,
} from "@models"
import { validateRules } from "@utils"
const generateMethodForModels = [
ServiceZone,
ShippingOption,
GeoZone,
ShippingProfile,
ShippingOptionRule,
ShippingOptionType,
]
type InjectedDependencies = {
@@ -41,6 +47,8 @@ type InjectedDependencies = {
geoZoneService: ModulesSdkTypes.InternalModuleService<any>
shippingProfileService: ModulesSdkTypes.InternalModuleService<any>
shippingOptionService: ModulesSdkTypes.InternalModuleService<any>
shippingOptionRuleService: ModulesSdkTypes.InternalModuleService<any>
shippingOptionTypeService: ModulesSdkTypes.InternalModuleService<any>
}
export default class FulfillmentModuleService<
@@ -48,7 +56,9 @@ export default class FulfillmentModuleService<
TServiceZoneEntity extends ServiceZone = ServiceZone,
TGeoZoneEntity extends GeoZone = GeoZone,
TShippingProfileEntity extends ShippingProfile = ShippingProfile,
TShippingOptionEntity extends ShippingOption = ShippingOption
TShippingOptionEntity extends ShippingOption = ShippingOption,
TShippingOptionRuleEntity extends ShippingOptionRule = ShippingOptionRule,
TSippingOptionTypeEntity extends ShippingOptionType = ShippingOptionType
>
extends ModulesSdkUtils.abstractModuleServiceFactory<
InjectedDependencies,
@@ -59,6 +69,8 @@ export default class FulfillmentModuleService<
ShippingOption: { dto: FulfillmentTypes.ShippingOptionDTO }
GeoZone: { dto: FulfillmentTypes.GeoZoneDTO }
ShippingProfile: { dto: FulfillmentTypes.ShippingProfileDTO }
ShippingOptionRule: { dto: FulfillmentTypes.ShippingOptionRuleDTO }
ShippingOptionType: { dto: FulfillmentTypes.ShippingOptionTypeDTO }
}
>(FulfillmentSet, generateMethodForModels, entityNameToLinkableKeysMap)
implements IFulfillmentModuleService
@@ -69,6 +81,8 @@ export default class FulfillmentModuleService<
protected readonly geoZoneService_: ModulesSdkTypes.InternalModuleService<TGeoZoneEntity>
protected readonly shippingProfileService_: ModulesSdkTypes.InternalModuleService<TShippingProfileEntity>
protected readonly shippingOptionService_: ModulesSdkTypes.InternalModuleService<TShippingOptionEntity>
protected readonly shippingOptionRuleService_: ModulesSdkTypes.InternalModuleService<TShippingOptionRuleEntity>
protected readonly shippingOptionTypeService_: ModulesSdkTypes.InternalModuleService<TSippingOptionTypeEntity>
constructor(
{
@@ -78,6 +92,8 @@ export default class FulfillmentModuleService<
geoZoneService,
shippingProfileService,
shippingOptionService,
shippingOptionRuleService,
shippingOptionTypeService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
@@ -89,6 +105,8 @@ export default class FulfillmentModuleService<
this.geoZoneService_ = geoZoneService
this.shippingProfileService_ = shippingProfileService
this.shippingOptionService_ = shippingOptionService
this.shippingOptionRuleService_ = shippingOptionRuleService
this.shippingOptionTypeService_ = shippingOptionTypeService
}
__joinerConfig(): ModuleJoinerConfig {
@@ -235,6 +253,11 @@ export default class FulfillmentModuleService<
return []
}
const rules = data_.flatMap((d) => d.rules)
if (rules.length) {
validateRules(rules as Record<string, unknown>[])
}
const createdShippingOptions = await this.shippingOptionService_.create(
data_,
sharedContext
@@ -328,6 +351,61 @@ export default class FulfillmentModuleService<
)
}
async createShippingOptionRules(
data: FulfillmentTypes.CreateShippingOptionRuleDTO[],
sharedContext?: Context
): Promise<FulfillmentTypes.ShippingOptionRuleDTO[]>
async createShippingOptionRules(
data: FulfillmentTypes.CreateShippingOptionRuleDTO,
sharedContext?: Context
): Promise<FulfillmentTypes.ShippingOptionRuleDTO>
@InjectManager("baseRepository_")
async createShippingOptionRules(
data:
| FulfillmentTypes.CreateShippingOptionRuleDTO[]
| FulfillmentTypes.CreateShippingOptionRuleDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
| FulfillmentTypes.ShippingOptionRuleDTO
| FulfillmentTypes.ShippingOptionRuleDTO[]
> {
const createdShippingOptionRules = await this.createShippingOptionRules_(
data,
sharedContext
)
return await this.baseRepository_.serialize<
| FulfillmentTypes.ShippingOptionRuleDTO
| FulfillmentTypes.ShippingOptionRuleDTO[]
>(createdShippingOptionRules, {
populate: true,
})
}
@InjectTransactionManager("baseRepository_")
async createShippingOptionRules_(
data:
| FulfillmentTypes.CreateShippingOptionRuleDTO[]
| FulfillmentTypes.CreateShippingOptionRuleDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<TShippingOptionRuleEntity | TShippingOptionRuleEntity[]> {
const data_ = Array.isArray(data) ? data : [data]
if (!data_.length) {
return []
}
validateRules(data_ as unknown as Record<string, unknown>[])
const createdShippingOptionRules =
await this.shippingOptionRuleService_.create(data_, sharedContext)
return Array.isArray(data)
? createdShippingOptionRules
: createdShippingOptionRules[0]
}
update(
data: FulfillmentTypes.UpdateFulfillmentSetDTO[],
sharedContext?: Context
@@ -375,6 +453,7 @@ export default class FulfillmentModuleService<
},
{
relations: ["service_zones", "service_zones.geo_zones"],
take: fulfillmentSetIds.length,
},
sharedContext
)
@@ -557,6 +636,7 @@ export default class FulfillmentModuleService<
},
{
relations: ["geo_zones"],
take: serviceZoneIds.length,
},
sharedContext
)
@@ -662,7 +742,7 @@ export default class FulfillmentModuleService<
sharedContext?: Context
): Promise<FulfillmentTypes.ShippingOptionDTO>
@InjectTransactionManager("baseRepository_")
@InjectManager("baseRepository_")
async updateShippingOptions(
data:
| FulfillmentTypes.UpdateShippingOptionDTO[]
@@ -671,7 +751,123 @@ export default class FulfillmentModuleService<
): Promise<
FulfillmentTypes.ShippingOptionDTO[] | FulfillmentTypes.ShippingOptionDTO
> {
return []
const updatedShippingOptions = await this.updateShippingOptions_(
data,
sharedContext
)
return await this.baseRepository_.serialize<
FulfillmentTypes.ShippingOptionDTO | FulfillmentTypes.ShippingOptionDTO[]
>(updatedShippingOptions, {
populate: true,
})
}
@InjectTransactionManager("baseRepository_")
async updateShippingOptions_(
data:
| FulfillmentTypes.UpdateShippingOptionDTO[]
| FulfillmentTypes.UpdateShippingOptionDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<TShippingOptionEntity | TShippingOptionEntity[]> {
const dataArray = Array.isArray(data) ? data : [data]
if (!dataArray.length) {
return []
}
const shippingOptionIds = dataArray.map((s) => s.id)
if (!shippingOptionIds.length) {
return []
}
const shippingOptions = await this.shippingOptionService_.list(
{
id: shippingOptionIds,
},
{
relations: ["rules"],
take: shippingOptionIds.length,
},
sharedContext
)
const existingShippingOptions = new Map(
shippingOptions.map((s) => [s.id, s])
)
FulfillmentModuleService.validateMissingShippingOptions_(
shippingOptions,
dataArray
)
const ruleIdsToDelete: string[] = []
dataArray.forEach((shippingOption) => {
if (!shippingOption.rules) {
return
}
const existingShippingOption = existingShippingOptions.get(
shippingOption.id
)! // Garuantueed to exist since the validation above have been performed
const existingRules = existingShippingOption.rules
FulfillmentModuleService.validateMissingShippingOptionRules(
existingShippingOption,
shippingOption
)
const existingRulesMap = new Map(
existingRules.map((rule) => [rule.id, rule])
)
const updatedRules = shippingOption.rules
const updatedRuleIds = updatedRules
.map((r) => "id" in r && r.id)
.filter((id): id is string => !!id)
const toDeleteRuleIds = arrayDifference(
updatedRuleIds,
Array.from(existingRulesMap.keys())
) as string[]
if (toDeleteRuleIds.length) {
ruleIdsToDelete.push(...toDeleteRuleIds)
}
const newRules = updatedRules
.map((rule) => {
if (!("id" in rule)) {
return rule
}
return
})
.filter(Boolean)
validateRules(newRules as Record<string, unknown>[])
shippingOption.rules = shippingOption.rules.map((rule) => {
if (!("id" in rule)) {
return rule
}
return existingRulesMap.get(rule.id)!
})
})
if (ruleIdsToDelete.length) {
await this.shippingOptionRuleService_.delete(
ruleIdsToDelete,
sharedContext
)
}
const updatedShippingOptions = await this.shippingOptionService_.update(
dataArray,
sharedContext
)
return Array.isArray(data)
? updatedShippingOptions
: updatedShippingOptions[0]
}
updateShippingProfiles(
@@ -724,4 +920,107 @@ export default class FulfillmentModuleService<
return Array.isArray(data) ? serialized : serialized[0]
}
updateShippingOptionRules(
data: FulfillmentTypes.UpdateShippingOptionRuleDTO[],
sharedContext?: Context
): Promise<FulfillmentTypes.ShippingOptionRuleDTO[]>
updateShippingOptionRules(
data: FulfillmentTypes.UpdateShippingOptionRuleDTO,
sharedContext?: Context
): Promise<FulfillmentTypes.ShippingOptionRuleDTO>
@InjectManager("baseRepository_")
async updateShippingOptionRules(
data:
| FulfillmentTypes.UpdateShippingOptionRuleDTO[]
| FulfillmentTypes.UpdateShippingOptionRuleDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
| FulfillmentTypes.ShippingOptionRuleDTO[]
| FulfillmentTypes.ShippingOptionRuleDTO
> {
const updatedShippingOptionRules = await this.updateShippingOptionRules_(
data,
sharedContext
)
return await this.baseRepository_.serialize<
| FulfillmentTypes.ShippingOptionRuleDTO
| FulfillmentTypes.ShippingOptionRuleDTO[]
>(updatedShippingOptionRules, {
populate: true,
})
}
@InjectTransactionManager("baseRepository_")
async updateShippingOptionRules_(
data:
| FulfillmentTypes.UpdateShippingOptionRuleDTO[]
| FulfillmentTypes.UpdateShippingOptionRuleDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<TShippingOptionRuleEntity | TShippingOptionRuleEntity[]> {
const data_ = Array.isArray(data) ? data : [data]
if (!data_.length) {
return []
}
validateRules(data_ as unknown as Record<string, unknown>[])
const updatedShippingOptionRules =
await this.shippingOptionRuleService_.update(data_, sharedContext)
return Array.isArray(data)
? updatedShippingOptionRules
: updatedShippingOptionRules[0]
}
protected static validateMissingShippingOptions_(
shippingOptions: ShippingOption[],
shippingOptionsData: FulfillmentTypes.UpdateShippingOptionDTO[]
) {
const missingShippingOptionIds = arrayDifference(
shippingOptionsData.map((s) => s.id),
shippingOptions.map((s) => s.id)
)
if (missingShippingOptionIds.length) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`The following shipping options do not exist: ${Array.from(
missingShippingOptionIds
).join(", ")}`
)
}
}
protected static validateMissingShippingOptionRules(
shippingOption: ShippingOption,
shippingOptionUpdateData: FulfillmentTypes.UpdateShippingOptionDTO
) {
if (!shippingOptionUpdateData.rules) {
return
}
const existingRules = shippingOption.rules
const rulesSet = new Set(existingRules.map((r) => r.id))
// Only validate the rules that have an id to validate that they really exists in the shipping option
const expectedRuleSet = new Set(
shippingOptionUpdateData.rules
.map((r) => "id" in r && r.id)
.filter((id): id is string => !!id)
)
const nonAlreadyExistingRules = getSetDifference(expectedRuleSet, rulesSet)
if (nonAlreadyExistingRules.size) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`The following rules does not exists: ${Array.from(
nonAlreadyExistingRules
).join(", ")} on shipping option ${shippingOptionUpdateData.id}`
)
}
}
}

View File

@@ -0,0 +1,248 @@
import { isContextValidForRules, RuleOperator } from "../utils"
describe("isContextValidForRules", () => {
const context = {
attribute1: "value1",
attribute2: "value2",
attribute3: "value3",
}
const validRule = {
attribute: "attribute1",
operator: RuleOperator.EQ,
value: "value1",
}
const invalidRule = {
attribute: "attribute2",
operator: RuleOperator.EQ,
value: "wrongValue",
}
it("returns true when all rules are valid and atLeastOneValidRule is false", () => {
const rules = [validRule, validRule]
expect(isContextValidForRules(context, rules)).toBe(true)
})
it("returns true when all rules are valid and atLeastOneValidRule is true", () => {
const rules = [validRule, validRule]
const options = { atLeastOneValidRule: true }
expect(isContextValidForRules(context, rules, options)).toBe(true)
})
it("returns true when some rules are valid and atLeastOneValidRule is true", () => {
const rules = [validRule, invalidRule]
const options = { atLeastOneValidRule: true }
expect(isContextValidForRules(context, rules, options)).toBe(true)
})
it("returns false when some rules are valid and atLeastOneValidRule is false", () => {
const rules = [validRule, invalidRule]
expect(isContextValidForRules(context, rules)).toBe(false)
})
it("returns false when no rules are valid and atLeastOneValidRule is true", () => {
const rules = [invalidRule, invalidRule]
const options = { atLeastOneValidRule: true }
expect(isContextValidForRules(context, rules, options)).toBe(false)
})
it("returns false when no rules are valid and atLeastOneValidRule is false", () => {
const rules = [invalidRule, invalidRule]
expect(isContextValidForRules(context, rules)).toBe(false)
})
it("returns true when the 'gt' operator is valid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.GT,
value: "1", // 2 > 1
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(true)
})
it("returns false when the 'gt' operator is invalid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.GT,
value: "0", // 0 > 0
},
]
const context = { attribute1: "0" }
expect(isContextValidForRules(context, rules)).toBe(false)
})
it("returns true when the 'gte' operator is valid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.GTE,
value: "2", // 2 >= 2
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(true)
})
it("returns false when the 'gte' operator is invalid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.GTE,
value: "3", // 2 >= 3
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(false)
})
it("returns true when the 'lt' operator is valid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.LT,
value: "3", // 2 < 3
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(true)
})
it("returns false when the 'lt' operator is invalid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.LT,
value: "2", // 2 < 2
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(false)
})
it("returns true when the 'lte' operator is valid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.LTE,
value: "2", // 2 <= 2
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(true)
})
// ... existing tests ...
it("returns false when the 'lte' operator is invalid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.LTE,
value: "1", // 2 <= 1
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(false)
})
it("returns true when the 'in' operator is valid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.IN,
value: ["1", "2", "3"], // 2 in [1, 2, 3]
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(true)
})
it("returns false when the 'in' operator is invalid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.IN,
value: ["1", "3", "4"], // 2 in [1, 3, 4]
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(false)
})
it("returns true when the 'nin' operator is valid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.NIN,
value: ["1", "3", "4"], // 2 not in [1, 3, 4]
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(true)
})
it("returns false when the 'nin' operator is invalid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.NIN,
value: ["1", "2", "3"], // 2 not in [1, 2, 3]
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(false)
})
it("returns true when the 'ne' operator is valid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.NE,
value: "1", // 2 != 1
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(true)
})
it("returns false when the 'ne' operator is invalid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.NE,
value: "2", // 2 != 2
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(false)
})
it("returns true when the 'eq' operator is valid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.EQ,
value: "2", // 2 == 2
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(true)
})
it("returns false when the 'eq' operator is invalid", () => {
const rules = [
{
attribute: "attribute1",
operator: RuleOperator.EQ,
value: "1", // 2 == 1
},
]
const context = { attribute1: "2" }
expect(isContextValidForRules(context, rules)).toBe(false)
})
})

View File

@@ -0,0 +1 @@
export * from './utils'

View File

@@ -0,0 +1,152 @@
import { isString, MedusaError, pickValueFromObject } from "@medusajs/utils"
/**
* The rule engine here is kept inside the module as of now, but it could be moved
* to the utils package and be used across the different modules that provides context
* based rule filtering.
*
* TODO: discussion around that should happen at some point
*/
export type Rule = {
attribute: string
operator: RuleOperator
value: string | string[]
}
export enum RuleOperator {
IN = "in",
EQ = "eq",
NE = "ne",
GT = "gt",
GTE = "gte",
LT = "lt",
LTE = "lte",
NIN = "nin",
}
export const availableOperators = Object.values(RuleOperator)
const isDate = (str: string) => {
return !isNaN(Date.parse(str))
}
const operatorsPredicate = {
in: (contextValue: string, ruleValue: string[]) =>
ruleValue.includes(contextValue),
nin: (contextValue: string, ruleValue: string[]) =>
!ruleValue.includes(contextValue),
eq: (contextValue: string, ruleValue: string) => contextValue === ruleValue,
ne: (contextValue: string, ruleValue: string) => contextValue !== ruleValue,
gt: (contextValue: string, ruleValue: string) => {
if (isDate(contextValue) && isDate(ruleValue)) {
return new Date(contextValue) > new Date(ruleValue)
}
return Number(contextValue) > Number(ruleValue)
},
gte: (contextValue: string, ruleValue: string) => {
if (isDate(contextValue) && isDate(ruleValue)) {
return new Date(contextValue) >= new Date(ruleValue)
}
return Number(contextValue) >= Number(ruleValue)
},
lt: (contextValue: string, ruleValue: string) => {
if (isDate(contextValue) && isDate(ruleValue)) {
return new Date(contextValue) < new Date(ruleValue)
}
return Number(contextValue) < Number(ruleValue)
},
lte: (contextValue: string, ruleValue: string) => {
if (isDate(contextValue) && isDate(ruleValue)) {
return new Date(contextValue) <= new Date(ruleValue)
}
return Number(contextValue) <= Number(ruleValue)
},
}
/**
* Validate contextValue context object from contextValue set of rules.
* By default, all rules must be valid to return true unless the option atLeastOneValidRule is set to true.
* @param context
* @param rules
* @param options
*/
export function isContextValidForRules(
context: Record<string, any>,
rules: Rule[],
options: {
atLeastOneValidRule: boolean
} = {
atLeastOneValidRule: false,
}
) {
const { atLeastOneValidRule } = options
const loopComparator = atLeastOneValidRule ? rules.some : rules.every
const predicate = (rule) => {
const { attribute, operator, value } = rule
const contextValue = pickValueFromObject(attribute, context)
return operatorsPredicate[operator](
contextValue,
value as string & string[]
)
}
return loopComparator.apply(rules, [predicate])
}
/**
* Validate contextValue rule object
* @param rule
*/
export function validateRule(rule: Record<string, unknown>): boolean {
if (!rule.attribute || !rule.operator || !rule.value) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Rule must have an attribute, an operator and contextValue value"
)
}
if (!isString(rule.attribute)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Rule attribute must be contextValue string"
)
}
if (!isString(rule.operator)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Rule operator must be contextValue string"
)
}
if (!availableOperators.includes(rule.operator as RuleOperator)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Rule operator ${
rule.operator
} is not supported. Must be one of ${availableOperators.join(", ")}`
)
}
if (rule.operator === RuleOperator.IN || rule.operator === RuleOperator.NIN) {
if (!Array.isArray(rule.value)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Rule value must be an array for in/nin operators"
)
}
}
return true
}
/**
* Validate contextValue set of rules
* @param rules
*/
export function validateRules(rules: Record<string, unknown>[]): boolean {
rules.forEach(validateRule)
return true
}

View File

@@ -23,7 +23,8 @@
"@models": ["./src/models"],
"@services": ["./src/services"],
"@repositories": ["./src/repositories"],
"@types": ["./src/types"]
"@types": ["./src/types"],
"@utils": ["./src/utils"]
}
},
"include": ["src"],

View File

@@ -1,7 +1,7 @@
import type { NextFunction, Request, Response } from "express"
import type { Customer, User } from "../models"
import type { MedusaContainer } from "./global"
import { MedusaContainer } from "@medusajs/types"
export interface MedusaRequest extends Request {
user?: (User | Customer) & { customer_id?: string; userId?: string }

View File

@@ -1,6 +1,6 @@
export interface CreateShippingOptionRuleDTO {
attribute: string
operator: string
operator: "in" | "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "nin"
value: string | string[]
shipping_option_id: string
}

View File

@@ -4,11 +4,15 @@ import {
FilterableGeoZoneProps,
FilterableServiceZoneProps,
FilterableShippingOptionProps,
FilterableShippingOptionRuleProps,
FilterableShippingOptionTypeProps,
FilterableShippingProfileProps,
FulfillmentSetDTO,
GeoZoneDTO,
ServiceZoneDTO,
ShippingOptionDTO,
ShippingOptionRuleDTO,
ShippingOptionTypeDTO,
ShippingProfileDTO,
} from "./common"
import { FindConfig } from "../common"
@@ -19,10 +23,12 @@ import {
CreateGeoZoneDTO,
CreateServiceZoneDTO,
CreateShippingOptionDTO,
CreateShippingOptionRuleDTO,
UpdateFulfillmentSetDTO,
UpdateGeoZoneDTO,
UpdateServiceZoneDTO,
UpdateShippingOptionDTO,
UpdateShippingOptionRuleDTO,
} from "./mutations"
import { CreateShippingProfileDTO } from "./mutations/shipping-profile"
@@ -98,6 +104,20 @@ export interface IFulfillmentModuleService extends IModuleService {
sharedContext?: Context
): Promise<GeoZoneDTO>
/**
* Create a new shipping option rules
* @param data
* @param sharedContext
*/
createShippingOptionRules(
data: CreateShippingOptionRuleDTO[],
sharedContext?: Context
): Promise<ShippingOptionRuleDTO[]>
createShippingOptionRules(
data: CreateShippingOptionRuleDTO,
sharedContext?: Context
): Promise<ShippingOptionRuleDTO>
/**
* Update a fulfillment set
* @param data
@@ -168,6 +188,20 @@ export interface IFulfillmentModuleService extends IModuleService {
sharedContext?: Context
): Promise<GeoZoneDTO>
/**
* Update a shipping option rule
* @param data
* @param sharedContext
*/
updateShippingOptionRules(
data: UpdateShippingOptionRuleDTO[],
sharedContext?: Context
): Promise<ShippingOptionRuleDTO[]>
updateShippingOptionRules(
data: UpdateShippingOptionRuleDTO,
sharedContext?: Context
): Promise<ShippingOptionRuleDTO>
/**
* Delete a fulfillment set
* @param ids
@@ -208,6 +242,17 @@ export interface IFulfillmentModuleService extends IModuleService {
deleteGeoZones(ids: string[], sharedContext?: Context): Promise<void>
deleteGeoZones(id: string, sharedContext?: Context): Promise<void>
/**
* Delete a shipping option rule
* @param ids
* @param sharedContext
*/
deleteShippingOptionRules(
ids: string[],
sharedContext?: Context
): Promise<void>
deleteShippingOptionRules(id: string, sharedContext?: Context): Promise<void>
/**
* Retrieve a fulfillment set
* @param id
@@ -268,6 +313,30 @@ export interface IFulfillmentModuleService extends IModuleService {
sharedContext?: Context
): Promise<GeoZoneDTO>
/**
* Retrieve a shipping option rule
* @param id
* @param config
* @param sharedContext
*/
retrieveShippingOptionRule(
id: string,
config?: FindConfig<ShippingOptionRuleDTO>,
sharedContext?: Context
): Promise<ShippingOptionRuleDTO>
/**
* Retrieve a shipping option type
* @param id
* @param config
* @param sharedContext
*/
retrieveShippingOptionType(
id: string,
config?: FindConfig<ShippingOptionTypeDTO>,
sharedContext?: Context
): Promise<ShippingOptionTypeDTO>
/**
* List fulfillment sets
* @param filters
@@ -328,6 +397,30 @@ export interface IFulfillmentModuleService extends IModuleService {
sharedContext?: Context
): Promise<GeoZoneDTO[]>
/**
* List shipping option rules
* @param filters
* @param config
* @param sharedContext
*/
listShippingOptionRules(
filters?: FilterableShippingOptionRuleProps,
config?: FindConfig<ShippingOptionRuleDTO>,
sharedContext?: Context
): Promise<ShippingOptionRuleDTO[]>
/**
* List shipping option types
* @param filters
* @param config
* @param sharedContext
*/
listShippingOptionTypes(
filters?: FilterableShippingOptionTypeProps,
config?: FindConfig<ShippingOptionTypeDTO>,
sharedContext?: Context
): Promise<ShippingOptionTypeDTO[]>
/**
* List and count fulfillment sets
* @param filters
@@ -388,6 +481,30 @@ export interface IFulfillmentModuleService extends IModuleService {
sharedContext?: Context
): Promise<[GeoZoneDTO[], number]>
/**
* List and count shipping option rules
* @param filters
* @param config
* @param sharedContext
*/
listAndCountShippingOptionRules(
filters?: FilterableShippingOptionRuleProps,
config?: FindConfig<ShippingOptionRuleDTO>,
sharedContext?: Context
): Promise<[ShippingOptionRuleDTO[], number]>
/**
* List and count shipping options types
* @param filters
* @param config
* @param sharedContext
*/
listAndCountShippingOptionTypes(
filters?: FilterableShippingOptionTypeProps,
config?: FindConfig<ShippingOptionTypeDTO>,
sharedContext?: Context
): Promise<[ShippingOptionTypeDTO[], number]>
/**
* Soft delete fulfillment sets
* @param fulfillmentIds
@@ -454,5 +571,5 @@ export interface IFulfillmentModuleService extends IModuleService {
sharedContext?: Context
): Promise<Record<string, string[]> | void>
// TODO defined the other restore methods
// TODO define needed soft delete/delete/restore methods
}

View File

@@ -60,7 +60,7 @@ export function internalModuleServiceFactory<
}
static buildUniqueCompositeKeyValue(keys: string[], data: object) {
return keys.map((k) => data[k]).join("_")
return keys.map((k) => data[k]).join(":")
}
/**
@@ -287,7 +287,7 @@ export function internalModuleServiceFactory<
;[...keySelectorDataMap.keys()].filter((key) => {
if (!compositeKeysValuesForFoundEntities.has(key)) {
const value = key.replace(/_/gi, " - ")
const value = key.replace(/:/gi, " - ")
missingEntityValues.push(value)
}
})