diff --git a/packages/fulfillment/integration-tests/__tests__/fulfillment-module-service.spec.ts b/packages/fulfillment/integration-tests/__tests__/fulfillment-module-service.spec.ts index 6779ffafac9e3..eb48b5a324d5d 100644 --- a/packages/fulfillment/integration-tests/__tests__/fulfillment-module-service.spec.ts +++ b/packages/fulfillment/integration-tests/__tests__/fulfillment-module-service.spec.ts @@ -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` + ) + }) + }) }) }) }, diff --git a/packages/fulfillment/jest.config.js b/packages/fulfillment/jest.config.js index 62656d9c235b9..d634e47925e4e 100644 --- a/packages/fulfillment/jest.config.js +++ b/packages/fulfillment/jest.config.js @@ -4,6 +4,7 @@ module.exports = { "^@services": "/src/services", "^@repositories": "/src/repositories", "^@types": "/src/types", + "^@utils": "/src/utils", }, transform: { "^.+\\.[jt]s?$": [ diff --git a/packages/fulfillment/package.json b/packages/fulfillment/package.json index 970e92e8ee050..88bc00a3e9048 100644 --- a/packages/fulfillment/package.json +++ b/packages/fulfillment/package.json @@ -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" diff --git a/packages/fulfillment/src/migrations/.snapshot-medusa-fulfillment.json b/packages/fulfillment/src/migrations/.snapshot-medusa-fulfillment.json index d1b7292ae5c14..da2599d31963e 100644 --- a/packages/fulfillment/src/migrations/.snapshot-medusa-fulfillment.json +++ b/packages/fulfillment/src/migrations/.snapshot-medusa-fulfillment.json @@ -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": { diff --git a/packages/fulfillment/src/migrations/Migration20240219115644.ts b/packages/fulfillment/src/migrations/Migration20240221164918_InitialSetupMigration.ts similarity index 88% rename from packages/fulfillment/src/migrations/Migration20240219115644.ts rename to packages/fulfillment/src/migrations/Migration20240221164918_InitialSetupMigration.ts index aa1294981b732..de4028689cc47 100644 --- a/packages/fulfillment/src/migrations/Migration20240219115644.ts +++ b/packages/fulfillment/src/migrations/Migration20240221164918_InitialSetupMigration.ts @@ -1,6 +1,6 @@ import { Migration } from '@mikro-orm/migrations'; -export class Migration20240219115644 extends Migration { +export class Migration20240221164918_InitialSetupMigration extends Migration { async up(): Promise { 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;'); diff --git a/packages/fulfillment/src/models/geo-zone.ts b/packages/fulfillment/src/models/geo-zone.ts index 3b557ead45623..61f6a187d54a2 100644 --- a/packages/fulfillment/src/models/geo-zone.ts +++ b/packages/fulfillment/src/models/geo-zone.ts @@ -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 diff --git a/packages/fulfillment/src/models/service-zone.ts b/packages/fulfillment/src/models/service-zone.ts index cb73a9f39aedc..de6ad100dc47c 100644 --- a/packages/fulfillment/src/models/service-zone.ts +++ b/packages/fulfillment/src/models/service-zone.ts @@ -61,7 +61,11 @@ export default class ServiceZone { @Property({ columnType: "jsonb", nullable: true }) metadata: Record | null = null - @Property({ columnType: "text" }) + @ManyToOne(() => FulfillmentSet, { + type: "text", + mapToPk: true, + fieldName: "fulfillment_set_id", + }) @FulfillmentSetIdIndex.MikroORMIndex() fulfillment_set_id: string diff --git a/packages/fulfillment/src/models/shipping-option-rule.ts b/packages/fulfillment/src/models/shipping-option-rule.ts index 5a0f4a7582682..4457708bf7688 100644 --- a/packages/fulfillment/src/models/shipping-option-rule.ts +++ b/packages/fulfillment/src/models/shipping-option-rule.ts @@ -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 @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({ diff --git a/packages/fulfillment/src/models/shipping-option-type.ts b/packages/fulfillment/src/models/shipping-option-type.ts index ea643211fc0d3..7fce83ee68c27 100644 --- a/packages/fulfillment/src/models/shipping-option-type.ts +++ b/packages/fulfillment/src/models/shipping-option-type.ts @@ -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 } } diff --git a/packages/fulfillment/src/models/shipping-option.ts b/packages/fulfillment/src/models/shipping-option.ts index 7c5dfa43c62ec..bf98ddb87a96a 100644 --- a/packages/fulfillment/src/models/shipping-option.ts +++ b/packages/fulfillment/src/models/shipping-option.ts @@ -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 diff --git a/packages/fulfillment/src/services/fulfillment-module-service.ts b/packages/fulfillment/src/services/fulfillment-module-service.ts index e65c3be8c179e..b64a9d95154fd 100644 --- a/packages/fulfillment/src/services/fulfillment-module-service.ts +++ b/packages/fulfillment/src/services/fulfillment-module-service.ts @@ -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 shippingProfileService: ModulesSdkTypes.InternalModuleService shippingOptionService: ModulesSdkTypes.InternalModuleService + shippingOptionRuleService: ModulesSdkTypes.InternalModuleService + shippingOptionTypeService: ModulesSdkTypes.InternalModuleService } 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 protected readonly shippingProfileService_: ModulesSdkTypes.InternalModuleService protected readonly shippingOptionService_: ModulesSdkTypes.InternalModuleService + protected readonly shippingOptionRuleService_: ModulesSdkTypes.InternalModuleService + protected readonly shippingOptionTypeService_: ModulesSdkTypes.InternalModuleService 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[]) + } + const createdShippingOptions = await this.shippingOptionService_.create( data_, sharedContext @@ -328,6 +351,61 @@ export default class FulfillmentModuleService< ) } + async createShippingOptionRules( + data: FulfillmentTypes.CreateShippingOptionRuleDTO[], + sharedContext?: Context + ): Promise + async createShippingOptionRules( + data: FulfillmentTypes.CreateShippingOptionRuleDTO, + sharedContext?: Context + ): Promise + + @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 { + const data_ = Array.isArray(data) ? data : [data] + + if (!data_.length) { + return [] + } + + validateRules(data_ as unknown as Record[]) + + 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 - @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 { + 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[]) + + 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 + updateShippingOptionRules( + data: FulfillmentTypes.UpdateShippingOptionRuleDTO, + sharedContext?: Context + ): Promise + + @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 { + const data_ = Array.isArray(data) ? data : [data] + + if (!data_.length) { + return [] + } + + validateRules(data_ as unknown as Record[]) + + 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}` + ) + } + } } diff --git a/packages/fulfillment/src/utils/__tests__/utils.spec.ts b/packages/fulfillment/src/utils/__tests__/utils.spec.ts new file mode 100644 index 0000000000000..7bdecf3adb7fd --- /dev/null +++ b/packages/fulfillment/src/utils/__tests__/utils.spec.ts @@ -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) + }) +}) diff --git a/packages/fulfillment/src/utils/index.ts b/packages/fulfillment/src/utils/index.ts new file mode 100644 index 0000000000000..cfa334d6ac3ad --- /dev/null +++ b/packages/fulfillment/src/utils/index.ts @@ -0,0 +1 @@ +export * from './utils' \ No newline at end of file diff --git a/packages/fulfillment/src/utils/utils.ts b/packages/fulfillment/src/utils/utils.ts new file mode 100644 index 0000000000000..481172967d2b8 --- /dev/null +++ b/packages/fulfillment/src/utils/utils.ts @@ -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, + 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): 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[]): boolean { + rules.forEach(validateRule) + return true +} diff --git a/packages/fulfillment/tsconfig.json b/packages/fulfillment/tsconfig.json index 4b79cd603235c..7adee6410cdfb 100644 --- a/packages/fulfillment/tsconfig.json +++ b/packages/fulfillment/tsconfig.json @@ -23,7 +23,8 @@ "@models": ["./src/models"], "@services": ["./src/services"], "@repositories": ["./src/repositories"], - "@types": ["./src/types"] + "@types": ["./src/types"], + "@utils": ["./src/utils"] } }, "include": ["src"], diff --git a/packages/medusa/src/types/routing.ts b/packages/medusa/src/types/routing.ts index 47f1dcfb719c7..d507385f2506a 100644 --- a/packages/medusa/src/types/routing.ts +++ b/packages/medusa/src/types/routing.ts @@ -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 } diff --git a/packages/types/src/fulfillment/mutations/shipping-option-rule.ts b/packages/types/src/fulfillment/mutations/shipping-option-rule.ts index 7fa40eacc94d2..41a2b87c346e0 100644 --- a/packages/types/src/fulfillment/mutations/shipping-option-rule.ts +++ b/packages/types/src/fulfillment/mutations/shipping-option-rule.ts @@ -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 } diff --git a/packages/types/src/fulfillment/service.ts b/packages/types/src/fulfillment/service.ts index 32a74855c5558..af8b3b864b403 100644 --- a/packages/types/src/fulfillment/service.ts +++ b/packages/types/src/fulfillment/service.ts @@ -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 + /** + * Create a new shipping option rules + * @param data + * @param sharedContext + */ + createShippingOptionRules( + data: CreateShippingOptionRuleDTO[], + sharedContext?: Context + ): Promise + createShippingOptionRules( + data: CreateShippingOptionRuleDTO, + sharedContext?: Context + ): Promise + /** * Update a fulfillment set * @param data @@ -168,6 +188,20 @@ export interface IFulfillmentModuleService extends IModuleService { sharedContext?: Context ): Promise + /** + * Update a shipping option rule + * @param data + * @param sharedContext + */ + updateShippingOptionRules( + data: UpdateShippingOptionRuleDTO[], + sharedContext?: Context + ): Promise + updateShippingOptionRules( + data: UpdateShippingOptionRuleDTO, + sharedContext?: Context + ): Promise + /** * Delete a fulfillment set * @param ids @@ -208,6 +242,17 @@ export interface IFulfillmentModuleService extends IModuleService { deleteGeoZones(ids: string[], sharedContext?: Context): Promise deleteGeoZones(id: string, sharedContext?: Context): Promise + /** + * Delete a shipping option rule + * @param ids + * @param sharedContext + */ + deleteShippingOptionRules( + ids: string[], + sharedContext?: Context + ): Promise + deleteShippingOptionRules(id: string, sharedContext?: Context): Promise + /** * Retrieve a fulfillment set * @param id @@ -268,6 +313,30 @@ export interface IFulfillmentModuleService extends IModuleService { sharedContext?: Context ): Promise + /** + * Retrieve a shipping option rule + * @param id + * @param config + * @param sharedContext + */ + retrieveShippingOptionRule( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + /** + * Retrieve a shipping option type + * @param id + * @param config + * @param sharedContext + */ + retrieveShippingOptionType( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + /** * List fulfillment sets * @param filters @@ -328,6 +397,30 @@ export interface IFulfillmentModuleService extends IModuleService { sharedContext?: Context ): Promise + /** + * List shipping option rules + * @param filters + * @param config + * @param sharedContext + */ + listShippingOptionRules( + filters?: FilterableShippingOptionRuleProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + /** + * List shipping option types + * @param filters + * @param config + * @param sharedContext + */ + listShippingOptionTypes( + filters?: FilterableShippingOptionTypeProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + /** * 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, + sharedContext?: Context + ): Promise<[ShippingOptionRuleDTO[], number]> + + /** + * List and count shipping options types + * @param filters + * @param config + * @param sharedContext + */ + listAndCountShippingOptionTypes( + filters?: FilterableShippingOptionTypeProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[ShippingOptionTypeDTO[], number]> + /** * Soft delete fulfillment sets * @param fulfillmentIds @@ -454,5 +571,5 @@ export interface IFulfillmentModuleService extends IModuleService { sharedContext?: Context ): Promise | void> - // TODO defined the other restore methods + // TODO define needed soft delete/delete/restore methods } diff --git a/packages/utils/src/modules-sdk/internal-module-service-factory.ts b/packages/utils/src/modules-sdk/internal-module-service-factory.ts index d792d759929ac..9c8a4ecafc817 100644 --- a/packages/utils/src/modules-sdk/internal-module-service-factory.ts +++ b/packages/utils/src/modules-sdk/internal-module-service-factory.ts @@ -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) } })