From 42cc8ae3f89ed7d642e51654d1a3cca011f13155 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Wed, 3 Jan 2024 09:54:48 +0100 Subject: [PATCH] feat(types,utils): added promotion create with rules and application target rules (#5957) * feat(types,utils): added promotion create with rules * chore: add rules to promotion and application method * chore: use common code for rule and values * chore: address pr reviews * chore: fix test --- .changeset/breezy-horses-destroy.md | 6 + .../promotion-module/promotion.spec.ts | 224 +++++++++++- packages/promotion/src/loaders/container.ts | 12 + .../.snapshot-medusa-promotion.json | 338 +++++++++++++++--- .../src/migrations/Migration20231221104256.ts | 34 -- .../src/migrations/Migration20240102130345.ts | 77 ++++ .../src/models/application-method.ts | 32 +- packages/promotion/src/models/index.ts | 2 + .../src/models/promotion-rule-value.ts | 37 ++ .../promotion/src/models/promotion-rule.ts | 83 +++++ packages/promotion/src/models/promotion.ts | 22 +- packages/promotion/src/repositories/index.ts | 2 + .../src/repositories/promotion-rule-value.ts | 128 +++++++ .../src/repositories/promotion-rule.ts | 124 +++++++ packages/promotion/src/services/index.ts | 2 + .../src/services/promotion-module.ts | 124 ++++++- .../src/services/promotion-rule-value.ts | 108 ++++++ .../promotion/src/services/promotion-rule.ts | 105 ++++++ packages/promotion/src/types/index.ts | 2 + .../src/types/promotion-rule-value.ts | 12 + .../promotion/src/types/promotion-rule.ts | 11 + .../promotion/src/utils/validations/index.ts | 1 + .../src/utils/validations/promotion-rule.ts | 39 ++ packages/promotion/tsconfig.json | 4 +- .../promotion/common/application-method.ts | 2 + packages/types/src/promotion/common/index.ts | 2 + .../promotion/common/promotion-rule-value.ts | 20 ++ .../src/promotion/common/promotion-rule.ts | 30 ++ .../types/src/promotion/common/promotion.ts | 2 + .../src/common/__tests__/is-present.spec.ts | 61 ++++ packages/utils/src/common/index.ts | 3 +- packages/utils/src/common/is-present.ts | 23 ++ packages/utils/src/promotion/index.ts | 10 + 33 files changed, 1560 insertions(+), 122 deletions(-) create mode 100644 .changeset/breezy-horses-destroy.md delete mode 100644 packages/promotion/src/migrations/Migration20231221104256.ts create mode 100644 packages/promotion/src/migrations/Migration20240102130345.ts create mode 100644 packages/promotion/src/models/promotion-rule-value.ts create mode 100644 packages/promotion/src/models/promotion-rule.ts create mode 100644 packages/promotion/src/repositories/promotion-rule-value.ts create mode 100644 packages/promotion/src/repositories/promotion-rule.ts create mode 100644 packages/promotion/src/services/promotion-rule-value.ts create mode 100644 packages/promotion/src/services/promotion-rule.ts create mode 100644 packages/promotion/src/types/promotion-rule-value.ts create mode 100644 packages/promotion/src/types/promotion-rule.ts create mode 100644 packages/promotion/src/utils/validations/promotion-rule.ts create mode 100644 packages/types/src/promotion/common/promotion-rule-value.ts create mode 100644 packages/types/src/promotion/common/promotion-rule.ts create mode 100644 packages/utils/src/common/__tests__/is-present.spec.ts create mode 100644 packages/utils/src/common/is-present.ts diff --git a/.changeset/breezy-horses-destroy.md b/.changeset/breezy-horses-destroy.md new file mode 100644 index 0000000000..18ff3ec77c --- /dev/null +++ b/.changeset/breezy-horses-destroy.md @@ -0,0 +1,6 @@ +--- +"@medusajs/types": patch +"@medusajs/utils": patch +--- + +feat(types,utils): added promotion create with rules diff --git a/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts b/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts index 1f64f0352c..5c5e8742ef 100644 --- a/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts @@ -75,15 +75,82 @@ describe("Promotion Service", () => { }, ]) - const [promotion] = await service.list({ - id: [createdPromotion.id], - }) + const [promotion] = await service.list( + { + id: [createdPromotion.id], + }, + { + relations: ["application_method"], + } + ) expect(promotion).toEqual( expect.objectContaining({ code: "PROMOTION_TEST", is_automatic: false, type: "standard", + application_method: expect.objectContaining({ + type: "fixed", + target_type: "order", + value: 100, + }), + }) + ) + }) + + it("should create a promotion with order application method with rules successfully", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + application_method: { + type: "fixed", + target_type: "order", + value: 100, + target_rules: [ + { + attribute: "product_id", + operator: "eq", + values: ["prod_tshirt"], + }, + ], + }, + }, + ]) + + const [promotion] = await service.list( + { + id: [createdPromotion.id], + }, + { + relations: [ + "application_method", + "application_method.target_rules.values", + ], + } + ) + + expect(promotion).toEqual( + expect.objectContaining({ + code: "PROMOTION_TEST", + is_automatic: false, + type: "standard", + application_method: expect.objectContaining({ + type: "fixed", + target_type: "order", + value: 100, + target_rules: [ + expect.objectContaining({ + attribute: "product_id", + operator: "eq", + values: expect.arrayContaining([ + expect.objectContaining({ + value: "prod_tshirt", + }), + ]), + }), + ], + }), }) ) }) @@ -128,5 +195,156 @@ describe("Promotion Service", () => { "application_method.max_quantity is required when application_method.allocation is 'each'" ) }) + + it("should create a promotion with rules successfully", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer_group_id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + }, + ]) + + const [promotion] = await service.list( + { + id: [createdPromotion.id], + }, + { + relations: ["rules", "rules.values"], + } + ) + + expect(promotion).toEqual( + expect.objectContaining({ + code: "PROMOTION_TEST", + is_automatic: false, + type: "standard", + rules: [ + expect.objectContaining({ + attribute: "customer_group_id", + operator: "in", + values: expect.arrayContaining([ + expect.objectContaining({ + value: "VIP", + }), + expect.objectContaining({ + value: "top100", + }), + ]), + }), + ], + }) + ) + }) + + it("should create a promotion with rules with single value successfully", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer_group_id", + operator: "eq", + values: "VIP", + }, + ], + }, + ]) + + const [promotion] = await service.list( + { + id: [createdPromotion.id], + }, + { + relations: ["rules", "rules.values"], + } + ) + + expect(promotion).toEqual( + expect.objectContaining({ + code: "PROMOTION_TEST", + is_automatic: false, + type: "standard", + rules: [ + expect.objectContaining({ + attribute: "customer_group_id", + operator: "eq", + values: expect.arrayContaining([ + expect.objectContaining({ + value: "VIP", + }), + ]), + }), + ], + }) + ) + }) + + it("should throw an error when rule attribute is invalid", async () => { + const error = await service + .create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "", + operator: "eq", + values: "VIP", + } as any, + ], + }, + ]) + .catch((e) => e) + + expect(error.message).toContain("rules[].attribute is a required field") + }) + + it("should throw an error when rule operator is invalid", async () => { + let error = await service + .create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer_group", + operator: "", + values: "VIP", + } as any, + ], + }, + ]) + .catch((e) => e) + + expect(error.message).toContain("rules[].operator is a required field") + + error = await service + .create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer_group", + operator: "doesnotexist", + values: "VIP", + } as any, + ], + }, + ]) + .catch((e) => e) + + expect(error.message).toContain( + "rules[].operator (doesnotexist) is invalid. It should be one of gte, lte, gt, lt, eq, ne, in" + ) + }) }) }) diff --git a/packages/promotion/src/loaders/container.ts b/packages/promotion/src/loaders/container.ts index aa9eac90d7..39f3881115 100644 --- a/packages/promotion/src/loaders/container.ts +++ b/packages/promotion/src/loaders/container.ts @@ -19,6 +19,12 @@ export default async ({ container.register({ promotionService: asClass(defaultServices.PromotionService).singleton(), + promotionRuleService: asClass( + defaultServices.PromotionRuleService + ).singleton(), + promotionRuleValueService: asClass( + defaultServices.PromotionRuleValueService + ).singleton(), applicationMethodService: asClass( defaultServices.ApplicationMethodService ).singleton(), @@ -44,5 +50,11 @@ function loadDefaultRepositories({ container }) { promotionRepository: asClass( defaultRepositories.PromotionRepository ).singleton(), + promotionRuleRepository: asClass( + defaultRepositories.PromotionRuleRepository + ).singleton(), + promotionRuleValueRepository: asClass( + defaultRepositories.PromotionRuleValueRepository + ).singleton(), }) } diff --git a/packages/promotion/src/migrations/.snapshot-medusa-promotion.json b/packages/promotion/src/migrations/.snapshot-medusa-promotion.json index bb21f5e21d..70f2559a81 100644 --- a/packages/promotion/src/migrations/.snapshot-medusa-promotion.json +++ b/packages/promotion/src/migrations/.snapshot-medusa-promotion.json @@ -1,7 +1,5 @@ { - "namespaces": [ - "public" - ], + "namespaces": ["public"], "name": "public", "tables": [ { @@ -41,10 +39,7 @@ "autoincrement": false, "primary": false, "nullable": false, - "enumItems": [ - "standard", - "buyget" - ], + "enumItems": ["standard", "buyget"], "mappedType": "enum" }, "created_at": { @@ -84,18 +79,14 @@ "schema": "public", "indexes": [ { - "columnNames": [ - "code" - ], + "columnNames": ["code"], "composite": false, "keyName": "IDX_promotion_code", "primary": false, "unique": false }, { - "columnNames": [ - "type" - ], + "columnNames": ["type"], "composite": false, "keyName": "IDX_promotion_type", "primary": false, @@ -103,18 +94,14 @@ }, { "keyName": "IDX_promotion_code_unique", - "columnNames": [ - "code" - ], + "columnNames": ["code"], "composite": false, "primary": false, "unique": true }, { "keyName": "promotion_pkey", - "columnNames": [ - "id" - ], + "columnNames": ["id"], "composite": false, "primary": true, "unique": true @@ -159,10 +146,7 @@ "autoincrement": false, "primary": false, "nullable": false, - "enumItems": [ - "fixed", - "percentage" - ], + "enumItems": ["fixed", "percentage"], "mappedType": "enum" }, "target_type": { @@ -172,11 +156,7 @@ "autoincrement": false, "primary": false, "nullable": false, - "enumItems": [ - "order", - "shipping", - "item" - ], + "enumItems": ["order", "shipping", "item"], "mappedType": "enum" }, "allocation": { @@ -186,10 +166,7 @@ "autoincrement": false, "primary": false, "nullable": true, - "enumItems": [ - "each", - "across" - ], + "enumItems": ["each", "across"], "mappedType": "enum" }, "promotion_id": { @@ -238,36 +215,28 @@ "schema": "public", "indexes": [ { - "columnNames": [ - "type" - ], + "columnNames": ["type"], "composite": false, "keyName": "IDX_application_method_type", "primary": false, "unique": false }, { - "columnNames": [ - "target_type" - ], + "columnNames": ["target_type"], "composite": false, "keyName": "IDX_application_method_target_type", "primary": false, "unique": false }, { - "columnNames": [ - "allocation" - ], + "columnNames": ["allocation"], "composite": false, "keyName": "IDX_application_method_allocation", "primary": false, "unique": false }, { - "columnNames": [ - "promotion_id" - ], + "columnNames": ["promotion_id"], "composite": false, "keyName": "application_method_promotion_id_unique", "primary": false, @@ -275,9 +244,7 @@ }, { "keyName": "application_method_pkey", - "columnNames": [ - "id" - ], + "columnNames": ["id"], "composite": false, "primary": true, "unique": true @@ -287,17 +254,282 @@ "foreignKeys": { "application_method_promotion_id_foreign": { "constraintName": "application_method_promotion_id_foreign", - "columnNames": [ - "promotion_id" - ], + "columnNames": ["promotion_id"], "localTableName": "public.application_method", - "referencedColumnNames": [ - "id" - ], + "referencedColumnNames": ["id"], "referencedTableName": "public.promotion", "updateRule": "cascade" } } + }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "description": { + "name": "description", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "attribute": { + "name": "attribute", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "operator": { + "name": "operator", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "enumItems": ["gte", "lte", "gt", "lt", "eq", "ne", "in"], + "mappedType": "enum" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "promotion_rule", + "schema": "public", + "indexes": [ + { + "columnNames": ["attribute"], + "composite": false, + "keyName": "IDX_promotion_rule_attribute", + "primary": false, + "unique": false + }, + { + "columnNames": ["operator"], + "composite": false, + "keyName": "IDX_promotion_rule_operator", + "primary": false, + "unique": false + }, + { + "keyName": "promotion_rule_pkey", + "columnNames": ["id"], + "composite": false, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {} + }, + { + "columns": { + "promotion_id": { + "name": "promotion_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "promotion_rule_id": { + "name": "promotion_rule_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + } + }, + "name": "promotion_promotion_rule", + "schema": "public", + "indexes": [ + { + "keyName": "promotion_promotion_rule_pkey", + "columnNames": ["promotion_id", "promotion_rule_id"], + "composite": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "promotion_promotion_rule_promotion_id_foreign": { + "constraintName": "promotion_promotion_rule_promotion_id_foreign", + "columnNames": ["promotion_id"], + "localTableName": "public.promotion_promotion_rule", + "referencedColumnNames": ["id"], + "referencedTableName": "public.promotion", + "deleteRule": "cascade", + "updateRule": "cascade" + }, + "promotion_promotion_rule_promotion_rule_id_foreign": { + "constraintName": "promotion_promotion_rule_promotion_rule_id_foreign", + "columnNames": ["promotion_rule_id"], + "localTableName": "public.promotion_promotion_rule", + "referencedColumnNames": ["id"], + "referencedTableName": "public.promotion_rule", + "deleteRule": "cascade", + "updateRule": "cascade" + } + } + }, + { + "columns": { + "application_method_id": { + "name": "application_method_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "promotion_rule_id": { + "name": "promotion_rule_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + } + }, + "name": "application_method_promotion_rule", + "schema": "public", + "indexes": [ + { + "keyName": "application_method_promotion_rule_pkey", + "columnNames": ["application_method_id", "promotion_rule_id"], + "composite": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "application_method_promotion_rule_application_method_id_foreign": { + "constraintName": "application_method_promotion_rule_application_method_id_foreign", + "columnNames": ["application_method_id"], + "localTableName": "public.application_method_promotion_rule", + "referencedColumnNames": ["id"], + "referencedTableName": "public.application_method", + "deleteRule": "cascade", + "updateRule": "cascade" + }, + "application_method_promotion_rule_promotion_rule_id_foreign": { + "constraintName": "application_method_promotion_rule_promotion_rule_id_foreign", + "columnNames": ["promotion_rule_id"], + "localTableName": "public.application_method_promotion_rule", + "referencedColumnNames": ["id"], + "referencedTableName": "public.promotion_rule", + "deleteRule": "cascade", + "updateRule": "cascade" + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "promotion_rule_id": { + "name": "promotion_rule_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "value": { + "name": "value", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + } + }, + "name": "promotion_rule_value", + "schema": "public", + "indexes": [ + { + "columnNames": ["promotion_rule_id"], + "composite": false, + "keyName": "IDX_promotion_rule_promotion_rule_value_id", + "primary": false, + "unique": false + }, + { + "keyName": "promotion_rule_value_pkey", + "columnNames": ["id"], + "composite": false, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "promotion_rule_value_promotion_rule_id_foreign": { + "constraintName": "promotion_rule_value_promotion_rule_id_foreign", + "columnNames": ["promotion_rule_id"], + "localTableName": "public.promotion_rule_value", + "referencedColumnNames": ["id"], + "referencedTableName": "public.promotion_rule", + "deleteRule": "cascade", + "updateRule": "cascade" + } + } } ] } diff --git a/packages/promotion/src/migrations/Migration20231221104256.ts b/packages/promotion/src/migrations/Migration20231221104256.ts deleted file mode 100644 index edd403da56..0000000000 --- a/packages/promotion/src/migrations/Migration20231221104256.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Migration } from "@mikro-orm/migrations" - -export class Migration20231221104256 extends Migration { - async up(): Promise { - this.addSql( - 'create table "promotion" ("id" text not null, "code" text not null, "is_automatic" boolean not null default false, "type" text check ("type" in (\'standard\', \'buyget\')) not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_pkey" primary key ("id"));' - ) - this.addSql('create index "IDX_promotion_code" on "promotion" ("code");') - this.addSql('create index "IDX_promotion_type" on "promotion" ("type");') - this.addSql( - 'alter table "promotion" add constraint "IDX_promotion_code_unique" unique ("code");' - ) - - this.addSql( - 'create table "application_method" ("id" text not null, "value" numeric null, "max_quantity" numeric null, "type" text check ("type" in (\'fixed\', \'percentage\')) not null, "target_type" text check ("target_type" in (\'order\', \'shipping\', \'item\')) not null, "allocation" text check ("allocation" in (\'each\', \'across\')) null, "promotion_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "application_method_pkey" primary key ("id"));' - ) - this.addSql( - 'create index "IDX_application_method_type" on "application_method" ("type");' - ) - this.addSql( - 'create index "IDX_application_method_target_type" on "application_method" ("target_type");' - ) - this.addSql( - 'create index "IDX_application_method_allocation" on "application_method" ("allocation");' - ) - this.addSql( - 'alter table "application_method" add constraint "application_method_promotion_id_unique" unique ("promotion_id");' - ) - - this.addSql( - 'alter table "application_method" add constraint "application_method_promotion_id_foreign" foreign key ("promotion_id") references "promotion" ("id") on update cascade;' - ) - } -} diff --git a/packages/promotion/src/migrations/Migration20240102130345.ts b/packages/promotion/src/migrations/Migration20240102130345.ts new file mode 100644 index 0000000000..878399a637 --- /dev/null +++ b/packages/promotion/src/migrations/Migration20240102130345.ts @@ -0,0 +1,77 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20240102130345 extends Migration { + async up(): Promise { + this.addSql( + 'create table "promotion" ("id" text not null, "code" text not null, "is_automatic" boolean not null default false, "type" text check ("type" in (\'standard\', \'buyget\')) not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_pkey" primary key ("id"));' + ) + this.addSql('create index "IDX_promotion_code" on "promotion" ("code");') + this.addSql('create index "IDX_promotion_type" on "promotion" ("type");') + this.addSql( + 'alter table "promotion" add constraint "IDX_promotion_code_unique" unique ("code");' + ) + + this.addSql( + 'create table "application_method" ("id" text not null, "value" numeric null, "max_quantity" numeric null, "type" text check ("type" in (\'fixed\', \'percentage\')) not null, "target_type" text check ("target_type" in (\'order\', \'shipping\', \'item\')) not null, "allocation" text check ("allocation" in (\'each\', \'across\')) null, "promotion_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "application_method_pkey" primary key ("id"));' + ) + this.addSql( + 'create index "IDX_application_method_type" on "application_method" ("type");' + ) + this.addSql( + 'create index "IDX_application_method_target_type" on "application_method" ("target_type");' + ) + this.addSql( + 'create index "IDX_application_method_allocation" on "application_method" ("allocation");' + ) + this.addSql( + 'alter table "application_method" add constraint "application_method_promotion_id_unique" unique ("promotion_id");' + ) + + this.addSql( + 'create table "promotion_rule" ("id" text not null, "description" text null, "attribute" text not null, "operator" text check ("operator" in (\'gte\', \'lte\', \'gt\', \'lt\', \'eq\', \'ne\', \'in\')) not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "promotion_rule_pkey" primary key ("id"));' + ) + this.addSql( + 'create index "IDX_promotion_rule_attribute" on "promotion_rule" ("attribute");' + ) + this.addSql( + 'create index "IDX_promotion_rule_operator" on "promotion_rule" ("operator");' + ) + + this.addSql( + 'create table "promotion_promotion_rule" ("promotion_id" text not null, "promotion_rule_id" text not null, constraint "promotion_promotion_rule_pkey" primary key ("promotion_id", "promotion_rule_id"));' + ) + + this.addSql( + 'create table "application_method_promotion_rule" ("application_method_id" text not null, "promotion_rule_id" text not null, constraint "application_method_promotion_rule_pkey" primary key ("application_method_id", "promotion_rule_id"));' + ) + + this.addSql( + 'create table "promotion_rule_value" ("id" text not null, "promotion_rule_id" text not null, "value" text not null, constraint "promotion_rule_value_pkey" primary key ("id"));' + ) + this.addSql( + 'create index "IDX_promotion_rule_promotion_rule_value_id" on "promotion_rule_value" ("promotion_rule_id");' + ) + + this.addSql( + 'alter table "application_method" add constraint "application_method_promotion_id_foreign" foreign key ("promotion_id") references "promotion" ("id") on update cascade;' + ) + + this.addSql( + 'alter table "promotion_promotion_rule" add constraint "promotion_promotion_rule_promotion_id_foreign" foreign key ("promotion_id") references "promotion" ("id") on update cascade on delete cascade;' + ) + this.addSql( + 'alter table "promotion_promotion_rule" add constraint "promotion_promotion_rule_promotion_rule_id_foreign" foreign key ("promotion_rule_id") references "promotion_rule" ("id") on update cascade on delete cascade;' + ) + + this.addSql( + 'alter table "application_method_promotion_rule" add constraint "application_method_promotion_rule_application_method_id_foreign" foreign key ("application_method_id") references "application_method" ("id") on update cascade on delete cascade;' + ) + this.addSql( + 'alter table "application_method_promotion_rule" add constraint "application_method_promotion_rule_promotion_rule_id_foreign" foreign key ("promotion_rule_id") references "promotion_rule" ("id") on update cascade on delete cascade;' + ) + + this.addSql( + 'alter table "promotion_rule_value" add constraint "promotion_rule_value_promotion_rule_id_foreign" foreign key ("promotion_rule_id") references "promotion_rule" ("id") on update cascade on delete cascade;' + ) + } +} diff --git a/packages/promotion/src/models/application-method.ts b/packages/promotion/src/models/application-method.ts index 712c661fc2..24a0dc4b09 100644 --- a/packages/promotion/src/models/application-method.ts +++ b/packages/promotion/src/models/application-method.ts @@ -6,9 +6,11 @@ import { import { PromotionUtils, generateEntityId } from "@medusajs/utils" import { BeforeCreate, + Collection, Entity, Enum, Index, + ManyToMany, OnInit, OneToOne, OptionalProps, @@ -16,8 +18,15 @@ import { Property, } from "@mikro-orm/core" import Promotion from "./promotion" +import PromotionRule from "./promotion-rule" -type OptionalFields = "value" | "max_quantity" | "allocation" +type OptionalFields = + | "value" + | "max_quantity" + | "allocation" + | "created_at" + | "updated_at" + | "deleted_at" @Entity() export default class ApplicationMethod { [OptionalProps]?: OptionalFields @@ -26,10 +35,10 @@ export default class ApplicationMethod { id!: string @Property({ columnType: "numeric", nullable: true, serializer: Number }) - value?: number + value?: number | null @Property({ columnType: "numeric", nullable: true, serializer: Number }) - max_quantity?: number + max_quantity?: number | null @Index({ name: "IDX_application_method_type" }) @Enum(() => PromotionUtils.ApplicationMethodType) @@ -51,12 +60,19 @@ export default class ApplicationMethod { }) promotion: Promotion + @ManyToMany(() => PromotionRule, "application_methods", { + owner: true, + pivotTable: "application_method_promotion_rule", + cascade: ["soft-remove"] as any, + }) + target_rules = new Collection(this) + @Property({ onCreate: () => new Date(), columnType: "timestamptz", defaultRaw: "now()", }) - created_at?: Date + created_at: Date @Property({ onCreate: () => new Date(), @@ -64,18 +80,18 @@ export default class ApplicationMethod { columnType: "timestamptz", defaultRaw: "now()", }) - updated_at?: Date + updated_at: Date @Property({ columnType: "timestamptz", nullable: true }) - deleted_at?: Date + deleted_at: Date | null @BeforeCreate() onCreate() { - this.id = generateEntityId(this.id, "app_method") + this.id = generateEntityId(this.id, "proappmet") } @OnInit() onInit() { - this.id = generateEntityId(this.id, "promo") + this.id = generateEntityId(this.id, "proappmet") } } diff --git a/packages/promotion/src/models/index.ts b/packages/promotion/src/models/index.ts index 0ee3108f94..1f6f773839 100644 --- a/packages/promotion/src/models/index.ts +++ b/packages/promotion/src/models/index.ts @@ -1,2 +1,4 @@ export { default as ApplicationMethod } from "./application-method" export { default as Promotion } from "./promotion" +export { default as PromotionRule } from "./promotion-rule" +export { default as PromotionRuleValue } from "./promotion-rule-value" diff --git a/packages/promotion/src/models/promotion-rule-value.ts b/packages/promotion/src/models/promotion-rule-value.ts new file mode 100644 index 0000000000..64c9cb6eab --- /dev/null +++ b/packages/promotion/src/models/promotion-rule-value.ts @@ -0,0 +1,37 @@ +import { + BeforeCreate, + Entity, + ManyToOne, + OnInit, + PrimaryKey, + Property, +} from "@mikro-orm/core" + +import { generateEntityId } from "@medusajs/utils" +import PromotionRule from "./promotion-rule" + +@Entity() +export default class PromotionRuleValue { + @PrimaryKey({ columnType: "text" }) + id!: string + + @ManyToOne(() => PromotionRule, { + onDelete: "cascade", + fieldName: "promotion_rule_id", + index: "IDX_promotion_rule_promotion_rule_value_id", + }) + promotion_rule: PromotionRule + + @Property({ columnType: "text" }) + value: string + + @BeforeCreate() + onCreate() { + this.id = generateEntityId(this.id, "prorulval") + } + + @OnInit() + onInit() { + this.id = generateEntityId(this.id, "prorulval") + } +} diff --git a/packages/promotion/src/models/promotion-rule.ts b/packages/promotion/src/models/promotion-rule.ts new file mode 100644 index 0000000000..dfe7311cd2 --- /dev/null +++ b/packages/promotion/src/models/promotion-rule.ts @@ -0,0 +1,83 @@ +import { PromotionRuleOperatorValues } from "@medusajs/types" +import { PromotionUtils, generateEntityId } from "@medusajs/utils" +import { + BeforeCreate, + Cascade, + Collection, + Entity, + Enum, + Index, + ManyToMany, + OnInit, + OneToMany, + OptionalProps, + PrimaryKey, + Property, +} from "@mikro-orm/core" +import ApplicationMethod from "./application-method" +import Promotion from "./promotion" +import PromotionRuleValue from "./promotion-rule-value" + +type OptionalFields = "description" | "created_at" | "updated_at" | "deleted_at" +type OptionalRelations = "values" | "promotions" + +@Entity() +export default class PromotionRule { + [OptionalProps]?: OptionalFields | OptionalRelations + + @PrimaryKey({ columnType: "text" }) + id!: string + + @Property({ columnType: "text", nullable: true }) + description: string | null + + @Index({ name: "IDX_promotion_rule_attribute" }) + @Property({ columnType: "text" }) + attribute: string + + @Index({ name: "IDX_promotion_rule_operator" }) + @Enum(() => PromotionUtils.PromotionRuleOperator) + operator: PromotionRuleOperatorValues + + @OneToMany(() => PromotionRuleValue, (prv) => prv.promotion_rule, { + cascade: [Cascade.REMOVE], + }) + values = new Collection(this) + + @ManyToMany(() => Promotion, (promotion) => promotion.rules) + promotions = new Collection(this) + + @ManyToMany( + () => ApplicationMethod, + (applicationMethod) => applicationMethod.target_rules + ) + application_methods = new Collection(this) + + @Property({ + onCreate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + created_at: Date + + @Property({ + onCreate: () => new Date(), + onUpdate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + updated_at: Date + + @Property({ columnType: "timestamptz", nullable: true }) + deleted_at: Date | null + + @BeforeCreate() + onCreate() { + this.id = generateEntityId(this.id, "prorul") + } + + @OnInit() + onInit() { + this.id = generateEntityId(this.id, "prorul") + } +} diff --git a/packages/promotion/src/models/promotion.ts b/packages/promotion/src/models/promotion.ts index 1b395f5fe2..6e58568480 100644 --- a/packages/promotion/src/models/promotion.ts +++ b/packages/promotion/src/models/promotion.ts @@ -2,9 +2,11 @@ import { PromotionType } from "@medusajs/types" import { PromotionUtils, generateEntityId } from "@medusajs/utils" import { BeforeCreate, + Collection, Entity, Enum, Index, + ManyToMany, OnInit, OneToOne, OptionalProps, @@ -13,8 +15,13 @@ import { Unique, } from "@mikro-orm/core" import ApplicationMethod from "./application-method" +import PromotionRule from "./promotion-rule" -type OptionalFields = "is_automatic" +type OptionalFields = + | "is_automatic" + | "created_at" + | "updated_at" + | "deleted_at" type OptionalRelations = "application_method" @Entity() export default class Promotion { @@ -44,12 +51,19 @@ export default class Promotion { }) application_method: ApplicationMethod + @ManyToMany(() => PromotionRule, "promotions", { + owner: true, + pivotTable: "promotion_promotion_rule", + cascade: ["soft-remove"] as any, + }) + rules = new Collection(this) + @Property({ onCreate: () => new Date(), columnType: "timestamptz", defaultRaw: "now()", }) - created_at?: Date + created_at: Date @Property({ onCreate: () => new Date(), @@ -57,10 +71,10 @@ export default class Promotion { columnType: "timestamptz", defaultRaw: "now()", }) - updated_at?: Date + updated_at: Date @Property({ columnType: "timestamptz", nullable: true }) - deleted_at?: Date + deleted_at: Date | null @BeforeCreate() onCreate() { diff --git a/packages/promotion/src/repositories/index.ts b/packages/promotion/src/repositories/index.ts index 5b9bb3a67f..421433c704 100644 --- a/packages/promotion/src/repositories/index.ts +++ b/packages/promotion/src/repositories/index.ts @@ -1,3 +1,5 @@ export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils" export { ApplicationMethodRepository } from "./application-method" export { PromotionRepository } from "./promotion" +export { PromotionRuleRepository } from "./promotion-rule" +export { PromotionRuleValueRepository } from "./promotion-rule-value" diff --git a/packages/promotion/src/repositories/promotion-rule-value.ts b/packages/promotion/src/repositories/promotion-rule-value.ts new file mode 100644 index 0000000000..beb5e9ef92 --- /dev/null +++ b/packages/promotion/src/repositories/promotion-rule-value.ts @@ -0,0 +1,128 @@ +import { Context, DAL } from "@medusajs/types" +import { DALUtils, MedusaError, arrayDifference } from "@medusajs/utils" +import { + FilterQuery as MikroFilterQuery, + FindOptions as MikroOptions, +} from "@mikro-orm/core" + +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { PromotionRuleValue } from "@models" +import { + CreatePromotionRuleValueDTO, + UpdatePromotionRuleValueDTO, +} from "@types" + +export class PromotionRuleValueRepository extends DALUtils.MikroOrmBaseRepository { + protected readonly manager_: SqlEntityManager + + constructor({ manager }: { manager: SqlEntityManager }) { + // @ts-ignore + // eslint-disable-next-line prefer-rest-params + super(...arguments) + this.manager_ = manager + } + + async find( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise { + const manager = this.getActiveManager(context) + + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + return await manager.find( + PromotionRuleValue, + findOptions_.where as MikroFilterQuery, + findOptions_.options as MikroOptions + ) + } + + async findAndCount( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise<[PromotionRuleValue[], number]> { + const manager = this.getActiveManager(context) + + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + return await manager.findAndCount( + PromotionRuleValue, + findOptions_.where as MikroFilterQuery, + findOptions_.options as MikroOptions + ) + } + + async delete(ids: string[], context: Context = {}): Promise { + const manager = this.getActiveManager(context) + await manager.nativeDelete(PromotionRuleValue, { id: { $in: ids } }, {}) + } + + async create( + data: CreatePromotionRuleValueDTO[], + context: Context = {} + ): Promise { + const manager = this.getActiveManager(context) + + const promotionRuleValue = data.map((promotionRuleValue) => { + return manager.create(PromotionRuleValue, promotionRuleValue) + }) + + manager.persist(promotionRuleValue) + + return promotionRuleValue + } + + async update( + data: UpdatePromotionRuleValueDTO[], + context: Context = {} + ): Promise { + const manager = this.getActiveManager(context) + const promotionRuleValueIds = data.map( + (promotionRuleValue) => promotionRuleValue.id + ) + const existingPromotionRuleValues = await this.find( + { + where: { + id: { + $in: promotionRuleValueIds, + }, + }, + }, + context + ) + + const dataAndExistingIdDifference = arrayDifference( + data.map((d) => d.id), + existingPromotionRuleValues.map((plr) => plr.id) + ) + + if (dataAndExistingIdDifference.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `PromotionRuleValue with id(s) "${dataAndExistingIdDifference.join( + ", " + )}" not found` + ) + } + + const existingPromotionRuleValueMap = new Map( + existingPromotionRuleValues.map<[string, PromotionRuleValue]>( + (promotionRuleValue) => [promotionRuleValue.id, promotionRuleValue] + ) + ) + + const promotionRuleValue = data.map((promotionRuleValueData) => { + const existingPromotionRuleValue = existingPromotionRuleValueMap.get( + promotionRuleValueData.id + )! + + return manager.assign(existingPromotionRuleValue, promotionRuleValueData) + }) + + manager.persist(promotionRuleValue) + + return promotionRuleValue + } +} diff --git a/packages/promotion/src/repositories/promotion-rule.ts b/packages/promotion/src/repositories/promotion-rule.ts new file mode 100644 index 0000000000..784c6d0a23 --- /dev/null +++ b/packages/promotion/src/repositories/promotion-rule.ts @@ -0,0 +1,124 @@ +import { Context, DAL } from "@medusajs/types" +import { DALUtils, MedusaError, arrayDifference } from "@medusajs/utils" +import { + FilterQuery as MikroFilterQuery, + FindOptions as MikroOptions, +} from "@mikro-orm/core" + +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { PromotionRule } from "@models" +import { CreatePromotionRuleDTO, UpdatePromotionRuleDTO } from "@types" + +export class PromotionRuleRepository extends DALUtils.MikroOrmBaseRepository { + protected readonly manager_: SqlEntityManager + + constructor({ manager }: { manager: SqlEntityManager }) { + // @ts-ignore + // eslint-disable-next-line prefer-rest-params + super(...arguments) + this.manager_ = manager + } + + async find( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise { + const manager = this.getActiveManager(context) + + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + return await manager.find( + PromotionRule, + findOptions_.where as MikroFilterQuery, + findOptions_.options as MikroOptions + ) + } + + async findAndCount( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise<[PromotionRule[], number]> { + const manager = this.getActiveManager(context) + + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + return await manager.findAndCount( + PromotionRule, + findOptions_.where as MikroFilterQuery, + findOptions_.options as MikroOptions + ) + } + + async delete(ids: string[], context: Context = {}): Promise { + const manager = this.getActiveManager(context) + await manager.nativeDelete(PromotionRule, { id: { $in: ids } }, {}) + } + + async create( + data: CreatePromotionRuleDTO[], + context: Context = {} + ): Promise { + const manager = this.getActiveManager(context) + + const promotionRule = data.map((promotionRule) => { + return manager.create(PromotionRule, promotionRule) + }) + + manager.persist(promotionRule) + + return promotionRule + } + + async update( + data: UpdatePromotionRuleDTO[], + context: Context = {} + ): Promise { + const manager = this.getActiveManager(context) + const promotionRuleIds = data.map((promotionRule) => promotionRule.id) + const existingPromotionRules = await this.find( + { + where: { + id: { + $in: promotionRuleIds, + }, + }, + }, + context + ) + + const dataAndExistingIdDifference = arrayDifference( + data.map((d) => d.id), + existingPromotionRules.map((plr) => plr.id) + ) + + if (dataAndExistingIdDifference.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `PromotionRule with id(s) "${dataAndExistingIdDifference.join( + ", " + )}" not found` + ) + } + + const existingPromotionRuleMap = new Map( + existingPromotionRules.map<[string, PromotionRule]>((promotionRule) => [ + promotionRule.id, + promotionRule, + ]) + ) + + const promotionRule = data.map((promotionRuleData) => { + const existingPromotionRule = existingPromotionRuleMap.get( + promotionRuleData.id + )! + + return manager.assign(existingPromotionRule, promotionRuleData) + }) + + manager.persist(promotionRule) + + return promotionRule + } +} diff --git a/packages/promotion/src/services/index.ts b/packages/promotion/src/services/index.ts index 891d81673c..e18681c6a4 100644 --- a/packages/promotion/src/services/index.ts +++ b/packages/promotion/src/services/index.ts @@ -1,3 +1,5 @@ export { default as ApplicationMethodService } from "./application-method" export { default as PromotionService } from "./promotion" export { default as PromotionModuleService } from "./promotion-module" +export { default as PromotionRuleService } from "./promotion-rule" +export { default as PromotionRuleValueService } from "./promotion-rule-value" diff --git a/packages/promotion/src/services/promotion-module.ts b/packages/promotion/src/services/promotion-module.ts index aa6dadd764..dcf4b54818 100644 --- a/packages/promotion/src/services/promotion-module.ts +++ b/packages/promotion/src/services/promotion-module.ts @@ -11,16 +11,26 @@ import { InjectTransactionManager, MedusaContext, } from "@medusajs/utils" -import { Promotion } from "@models" -import { ApplicationMethodService, PromotionService } from "@services" +import { ApplicationMethod, Promotion } from "@models" +import { + ApplicationMethodService, + PromotionRuleService, + PromotionRuleValueService, + PromotionService, +} from "@services" import { joinerConfig } from "../joiner-config" import { CreateApplicationMethodDTO, CreatePromotionDTO } from "../types" -import { validateApplicationMethodAttributes } from "../utils" +import { + validateApplicationMethodAttributes, + validatePromotionRuleAttributes, +} from "../utils" type InjectedDependencies = { baseRepository: DAL.RepositoryService promotionService: PromotionService applicationMethodService: ApplicationMethodService + promotionRuleService: PromotionRuleService + promotionRuleValueService: PromotionRuleValueService } export default class PromotionModuleService< @@ -30,18 +40,24 @@ export default class PromotionModuleService< protected baseRepository_: DAL.RepositoryService protected promotionService_: PromotionService protected applicationMethodService_: ApplicationMethodService + protected promotionRuleService_: PromotionRuleService + protected promotionRuleValueService_: PromotionRuleValueService constructor( { baseRepository, promotionService, applicationMethodService, + promotionRuleService, + promotionRuleValueService, }: InjectedDependencies, protected readonly moduleDeclaration: InternalModuleDeclaration ) { this.baseRepository_ = baseRepository this.promotionService_ = promotionService this.applicationMethodService_ = applicationMethodService + this.promotionRuleService_ = promotionRuleService + this.promotionRuleValueService_ = promotionRuleValueService } __joinerConfig(): ModuleJoinerConfig { @@ -78,7 +94,7 @@ export default class PromotionModuleService< return await this.list( { id: promotions.map((p) => p!.id) }, { - relations: ["application_method"], + relations: ["application_method", "rules", "rules.values"], }, sharedContext ) @@ -89,15 +105,26 @@ export default class PromotionModuleService< data: PromotionTypes.CreatePromotionDTO[], @MedusaContext() sharedContext: Context = {} ) { + const promotionsData: CreatePromotionDTO[] = [] + const applicationMethodsData: CreateApplicationMethodDTO[] = [] + const promotionCodeApplicationMethodDataMap = new Map< string, PromotionTypes.CreateApplicationMethodDTO >() - const promotionsData: CreatePromotionDTO[] = [] - const applicationMethodsData: CreateApplicationMethodDTO[] = [] + + const promotionCodeRulesDataMap = new Map< + string, + PromotionTypes.CreatePromotionRuleDTO[] + >() + const applicationMethodRuleMap = new Map< + string, + PromotionTypes.CreatePromotionRuleDTO[] + >() for (const { application_method: applicationMethodData, + rules: rulesData, ...promotionData } of data) { if (applicationMethodData) { @@ -107,33 +134,94 @@ export default class PromotionModuleService< ) } + if (rulesData) { + promotionCodeRulesDataMap.set(promotionData.code, rulesData) + } + promotionsData.push(promotionData) } const createdPromotions = await this.promotionService_.create( - data, + promotionsData, sharedContext ) for (const promotion of createdPromotions) { - const data = promotionCodeApplicationMethodDataMap.get(promotion.code) + const applMethodData = promotionCodeApplicationMethodDataMap.get( + promotion.code + ) - if (!data) continue + if (applMethodData) { + const { + target_rules: targetRulesData = [], + ...applicationMethodWithoutRules + } = applMethodData + const applicationMethodData = { + ...applicationMethodWithoutRules, + promotion, + } - const applicationMethodData = { - ...data, - promotion, + validateApplicationMethodAttributes(applicationMethodData) + applicationMethodsData.push(applicationMethodData) + + if (targetRulesData.length) { + applicationMethodRuleMap.set(promotion.id, targetRulesData) + } } - validateApplicationMethodAttributes(applicationMethodData) - applicationMethodsData.push(applicationMethodData) + await this.createPromotionRulesAndValues( + promotionCodeRulesDataMap.get(promotion.code) || [], + "promotions", + promotion, + sharedContext + ) } - await this.applicationMethodService_.create( - applicationMethodsData, - sharedContext - ) + const createdApplicationMethods = + await this.applicationMethodService_.create( + applicationMethodsData, + sharedContext + ) + + for (const applicationMethod of createdApplicationMethods) { + await this.createPromotionRulesAndValues( + applicationMethodRuleMap.get(applicationMethod.promotion.id) || [], + "application_methods", + applicationMethod, + sharedContext + ) + } return createdPromotions } + + protected async createPromotionRulesAndValues( + rulesData: PromotionTypes.CreatePromotionRuleDTO[], + relationName: "promotions" | "application_methods", + relation: Promotion | ApplicationMethod, + sharedContext: Context + ) { + validatePromotionRuleAttributes(rulesData) + + for (const ruleData of rulesData) { + const { values, ...rest } = ruleData + const promotionRuleData = { + ...rest, + [relationName]: [relation], + } + + const [createdPromotionRule] = await this.promotionRuleService_.create( + [promotionRuleData], + sharedContext + ) + + const ruleValues = Array.isArray(values) ? values : [values] + const promotionRuleValuesData = ruleValues.map((ruleValue) => ({ + value: ruleValue, + promotion_rule: createdPromotionRule, + })) + + await this.promotionRuleValueService_.create(promotionRuleValuesData) + } + } } diff --git a/packages/promotion/src/services/promotion-rule-value.ts b/packages/promotion/src/services/promotion-rule-value.ts new file mode 100644 index 0000000000..e54227f32b --- /dev/null +++ b/packages/promotion/src/services/promotion-rule-value.ts @@ -0,0 +1,108 @@ +import { Context, DAL, FindConfig, PromotionTypes } from "@medusajs/types" +import { + InjectManager, + InjectTransactionManager, + MedusaContext, + ModulesSdkUtils, + retrieveEntity, +} from "@medusajs/utils" +import { PromotionRuleValue } from "@models" +import { PromotionRuleValueRepository } from "@repositories" +import { + CreatePromotionRuleValueDTO, + UpdatePromotionRuleValueDTO, +} from "../types" + +type InjectedDependencies = { + promotionRuleValueRepository: DAL.RepositoryService +} + +export default class PromotionRuleValueService< + TEntity extends PromotionRuleValue = PromotionRuleValue +> { + protected readonly promotionRuleValueRepository_: DAL.RepositoryService + + constructor({ promotionRuleValueRepository }: InjectedDependencies) { + this.promotionRuleValueRepository_ = promotionRuleValueRepository + } + + @InjectManager("promotionRuleValueRepository_") + async retrieve( + promotionRuleValueId: string, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await retrieveEntity< + PromotionRuleValue, + PromotionTypes.PromotionRuleValueDTO + >({ + id: promotionRuleValueId, + entityName: PromotionRuleValue.name, + repository: this.promotionRuleValueRepository_, + config, + sharedContext, + })) as TEntity + } + + @InjectManager("promotionRuleValueRepository_") + async list( + filters: PromotionTypes.FilterablePromotionRuleValueProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const queryOptions = ModulesSdkUtils.buildQuery( + filters, + config + ) + + return (await this.promotionRuleValueRepository_.find( + queryOptions, + sharedContext + )) as TEntity[] + } + + @InjectManager("promotionRuleValueRepository_") + async listAndCount( + filters: PromotionTypes.FilterablePromotionRuleValueProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise<[TEntity[], number]> { + const queryOptions = ModulesSdkUtils.buildQuery( + filters, + config + ) + + return (await this.promotionRuleValueRepository_.findAndCount( + queryOptions, + sharedContext + )) as [TEntity[], number] + } + + @InjectTransactionManager("promotionRuleValueRepository_") + async create( + data: CreatePromotionRuleValueDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await ( + this.promotionRuleValueRepository_ as PromotionRuleValueRepository + ).create(data, sharedContext)) as TEntity[] + } + + @InjectTransactionManager("promotionRuleValueRepository_") + async update( + data: UpdatePromotionRuleValueDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await ( + this.promotionRuleValueRepository_ as PromotionRuleValueRepository + ).update(data, sharedContext)) as TEntity[] + } + + @InjectTransactionManager("promotionRuleValueRepository_") + async delete( + ids: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + await this.promotionRuleValueRepository_.delete(ids, sharedContext) + } +} diff --git a/packages/promotion/src/services/promotion-rule.ts b/packages/promotion/src/services/promotion-rule.ts new file mode 100644 index 0000000000..6c8fbd5c24 --- /dev/null +++ b/packages/promotion/src/services/promotion-rule.ts @@ -0,0 +1,105 @@ +import { Context, DAL, FindConfig, PromotionTypes } from "@medusajs/types" +import { + InjectManager, + InjectTransactionManager, + MedusaContext, + ModulesSdkUtils, + retrieveEntity, +} from "@medusajs/utils" +import { PromotionRule } from "@models" +import { PromotionRuleRepository } from "@repositories" +import { CreatePromotionRuleDTO, UpdatePromotionRuleDTO } from "../types" + +type InjectedDependencies = { + promotionRuleRepository: DAL.RepositoryService +} + +export default class PromotionRuleService< + TEntity extends PromotionRule = PromotionRule +> { + protected readonly promotionRuleRepository_: DAL.RepositoryService + + constructor({ promotionRuleRepository }: InjectedDependencies) { + this.promotionRuleRepository_ = promotionRuleRepository + } + + @InjectManager("promotionRuleRepository_") + async retrieve( + promotionRuleId: string, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await retrieveEntity< + PromotionRule, + PromotionTypes.PromotionRuleDTO + >({ + id: promotionRuleId, + entityName: PromotionRule.name, + repository: this.promotionRuleRepository_, + config, + sharedContext, + })) as TEntity + } + + @InjectManager("promotionRuleRepository_") + async list( + filters: PromotionTypes.FilterablePromotionRuleProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const queryOptions = ModulesSdkUtils.buildQuery( + filters, + config + ) + + return (await this.promotionRuleRepository_.find( + queryOptions, + sharedContext + )) as TEntity[] + } + + @InjectManager("promotionRuleRepository_") + async listAndCount( + filters: PromotionTypes.FilterablePromotionRuleProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise<[TEntity[], number]> { + const queryOptions = ModulesSdkUtils.buildQuery( + filters, + config + ) + + return (await this.promotionRuleRepository_.findAndCount( + queryOptions, + sharedContext + )) as [TEntity[], number] + } + + @InjectTransactionManager("promotionRuleRepository_") + async create( + data: CreatePromotionRuleDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await ( + this.promotionRuleRepository_ as PromotionRuleRepository + ).create(data, sharedContext)) as TEntity[] + } + + @InjectTransactionManager("promotionRuleRepository_") + async update( + data: UpdatePromotionRuleDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await ( + this.promotionRuleRepository_ as PromotionRuleRepository + ).update(data, sharedContext)) as TEntity[] + } + + @InjectTransactionManager("promotionRuleRepository_") + async delete( + ids: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + await this.promotionRuleRepository_.delete(ids, sharedContext) + } +} diff --git a/packages/promotion/src/types/index.ts b/packages/promotion/src/types/index.ts index c35015eca9..3891291eeb 100644 --- a/packages/promotion/src/types/index.ts +++ b/packages/promotion/src/types/index.ts @@ -6,3 +6,5 @@ export type InitializeModuleInjectableDependencies = { export * from "./application-method" export * from "./promotion" +export * from "./promotion-rule" +export * from "./promotion-rule-value" diff --git a/packages/promotion/src/types/promotion-rule-value.ts b/packages/promotion/src/types/promotion-rule-value.ts new file mode 100644 index 0000000000..0b644e88af --- /dev/null +++ b/packages/promotion/src/types/promotion-rule-value.ts @@ -0,0 +1,12 @@ +import { PromotionRuleDTO } from "@medusajs/types" + +export interface CreatePromotionRuleValueDTO { + value: any + promotion_rule: string | PromotionRuleDTO +} + +export interface UpdatePromotionRuleValueDTO { + id: string + value: any + promotion_rule: string | PromotionRuleDTO +} diff --git a/packages/promotion/src/types/promotion-rule.ts b/packages/promotion/src/types/promotion-rule.ts new file mode 100644 index 0000000000..0a43960c84 --- /dev/null +++ b/packages/promotion/src/types/promotion-rule.ts @@ -0,0 +1,11 @@ +import { PromotionRuleOperatorValues } from "@medusajs/types" + +export interface CreatePromotionRuleDTO { + description?: string + attribute: string + operator: PromotionRuleOperatorValues +} + +export interface UpdatePromotionRuleDTO { + id: string +} diff --git a/packages/promotion/src/utils/validations/index.ts b/packages/promotion/src/utils/validations/index.ts index d5886d02f7..1853716844 100644 --- a/packages/promotion/src/utils/validations/index.ts +++ b/packages/promotion/src/utils/validations/index.ts @@ -1 +1,2 @@ export * from "./application-method" +export * from "./promotion-rule" diff --git a/packages/promotion/src/utils/validations/promotion-rule.ts b/packages/promotion/src/utils/validations/promotion-rule.ts new file mode 100644 index 0000000000..9c6a5eb843 --- /dev/null +++ b/packages/promotion/src/utils/validations/promotion-rule.ts @@ -0,0 +1,39 @@ +import { PromotionRuleOperatorValues } from "@medusajs/types" +import { MedusaError, PromotionRuleOperator, isPresent } from "@medusajs/utils" +import { CreatePromotionRuleDTO } from "../../types" + +export function validatePromotionRuleAttributes( + promotionRulesData: CreatePromotionRuleDTO[] +) { + const errors: string[] = [] + + for (const promotionRuleData of promotionRulesData) { + if (!isPresent(promotionRuleData.attribute)) { + errors.push("rules[].attribute is a required field") + } + + if (!isPresent(promotionRuleData.operator)) { + errors.push("rules[].operator is a required field") + } + + if (isPresent(promotionRuleData.operator)) { + const allowedOperators: PromotionRuleOperatorValues[] = Object.values( + PromotionRuleOperator + ) + + if (!allowedOperators.includes(promotionRuleData.operator)) { + errors.push( + `rules[].operator (${ + promotionRuleData.operator + }) is invalid. It should be one of ${allowedOperators.join(", ")}` + ) + } + } else { + errors.push("rules[].operator is a required field") + } + } + + if (!errors.length) return + + throw new MedusaError(MedusaError.Types.INVALID_DATA, errors.join(", ")) +} diff --git a/packages/promotion/tsconfig.json b/packages/promotion/tsconfig.json index bd71a38e32..f829219827 100644 --- a/packages/promotion/tsconfig.json +++ b/packages/promotion/tsconfig.json @@ -23,7 +23,9 @@ "paths": { "@models": ["./src/models"], "@services": ["./src/services"], - "@repositories": ["./src/repositories"] + "@repositories": ["./src/repositories"], + "@types": ["./src/types"], + "@utils": ["./src/utils"] } }, "include": ["src"], diff --git a/packages/types/src/promotion/common/application-method.ts b/packages/types/src/promotion/common/application-method.ts index 9020ab339c..a288e2fa6b 100644 --- a/packages/types/src/promotion/common/application-method.ts +++ b/packages/types/src/promotion/common/application-method.ts @@ -1,5 +1,6 @@ import { BaseFilterable } from "../../dal" import { PromotionDTO } from "./promotion" +import { CreatePromotionRuleDTO } from "./promotion-rule" export type ApplicationMethodType = "fixed" | "percentage" export type ApplicationMethodTargetType = "order" | "shipping" | "item" @@ -16,6 +17,7 @@ export interface CreateApplicationMethodDTO { value?: number max_quantity?: number promotion?: PromotionDTO | string + target_rules?: CreatePromotionRuleDTO[] } export interface UpdateApplicationMethodDTO { diff --git a/packages/types/src/promotion/common/index.ts b/packages/types/src/promotion/common/index.ts index 47aac75452..7bb35ddb07 100644 --- a/packages/types/src/promotion/common/index.ts +++ b/packages/types/src/promotion/common/index.ts @@ -1,2 +1,4 @@ export * from "./application-method" export * from "./promotion" +export * from "./promotion-rule" +export * from "./promotion-rule-value" diff --git a/packages/types/src/promotion/common/promotion-rule-value.ts b/packages/types/src/promotion/common/promotion-rule-value.ts new file mode 100644 index 0000000000..ffca6450fc --- /dev/null +++ b/packages/types/src/promotion/common/promotion-rule-value.ts @@ -0,0 +1,20 @@ +import { BaseFilterable } from "../../dal" +import { PromotionRuleDTO } from "./promotion-rule" + +export interface PromotionRuleValueDTO { + id: string +} + +export interface CreatePromotionRuleValueDTO { + value: any + promotion_rule: PromotionRuleDTO +} + +export interface UpdatePromotionRuleValueDTO { + id: string +} + +export interface FilterablePromotionRuleValueProps + extends BaseFilterable { + id?: string[] +} diff --git a/packages/types/src/promotion/common/promotion-rule.ts b/packages/types/src/promotion/common/promotion-rule.ts new file mode 100644 index 0000000000..4b5b5fb4bc --- /dev/null +++ b/packages/types/src/promotion/common/promotion-rule.ts @@ -0,0 +1,30 @@ +import { BaseFilterable } from "../../dal" + +export type PromotionRuleOperatorValues = + | "gt" + | "lt" + | "eq" + | "ne" + | "in" + | "lte" + | "gte" + +export interface PromotionRuleDTO { + id: string +} + +export interface CreatePromotionRuleDTO { + description?: string + attribute: string + operator: PromotionRuleOperatorValues + values: string[] | string +} + +export interface UpdatePromotionRuleDTO { + id: string +} + +export interface FilterablePromotionRuleProps + extends BaseFilterable { + id?: string[] +} diff --git a/packages/types/src/promotion/common/promotion.ts b/packages/types/src/promotion/common/promotion.ts index 4cb00464ca..e2dd6834f5 100644 --- a/packages/types/src/promotion/common/promotion.ts +++ b/packages/types/src/promotion/common/promotion.ts @@ -1,5 +1,6 @@ import { BaseFilterable } from "../../dal" import { CreateApplicationMethodDTO } from "./application-method" +import { CreatePromotionRuleDTO } from "./promotion-rule" export type PromotionType = "standard" | "buyget" @@ -12,6 +13,7 @@ export interface CreatePromotionDTO { type: PromotionType is_automatic?: boolean application_method?: CreateApplicationMethodDTO + rules?: CreatePromotionRuleDTO[] } export interface UpdatePromotionDTO { diff --git a/packages/utils/src/common/__tests__/is-present.spec.ts b/packages/utils/src/common/__tests__/is-present.spec.ts new file mode 100644 index 0000000000..516ea29a73 --- /dev/null +++ b/packages/utils/src/common/__tests__/is-present.spec.ts @@ -0,0 +1,61 @@ +import { isPresent } from "../is-present" + +describe("isPresent", function () { + it("should return true or false for different types of data", function () { + const expectations = [ + { + input: null, + output: false, + }, + { + input: undefined, + output: false, + }, + { + input: "Testing", + output: true, + }, + { + input: "", + output: false, + }, + { + input: {}, + output: false, + }, + { + input: { test: 1 }, + output: true, + }, + { + input: [], + output: false, + }, + { + input: [{ test: 1 }], + output: true, + }, + { + input: new Map([["test", "test"]]), + output: true, + }, + { + input: new Map([]), + output: false, + }, + + { + input: new Set(["test"]), + output: true, + }, + { + input: new Set([]), + output: false, + }, + ] + + expectations.forEach((expectation) => { + expect(isPresent(expectation.input)).toEqual(expectation.output) + }) + }) +}) diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts index 2baca9b213..7c5def8d4f 100644 --- a/packages/utils/src/common/index.ts +++ b/packages/utils/src/common/index.ts @@ -8,14 +8,15 @@ export * from "./deep-equal-obj" export * from "./errors" export * from "./generate-entity-id" export * from "./get-config-file" +export * from "./get-iso-string-from-date" export * from "./group-by" export * from "./handle-postgres-database-error" export * from "./is-date" export * from "./is-defined" export * from "./is-email" export * from "./is-object" +export * from "./is-present" export * from "./is-string" -export * from "./get-iso-string-from-date" export * from "./lower-case-first" export * from "./map-object-to" export * from "./medusa-container" diff --git a/packages/utils/src/common/is-present.ts b/packages/utils/src/common/is-present.ts new file mode 100644 index 0000000000..d5f43372b6 --- /dev/null +++ b/packages/utils/src/common/is-present.ts @@ -0,0 +1,23 @@ +import { isDefined } from "./is-defined" +import { isObject } from "./is-object" +import { isString } from "./is-string" + +export function isPresent(value: any): boolean { + if (!isDefined(value) || value === null) { + return false + } + + if (isString(value) || Array.isArray(value)) { + return value.length > 0 + } + + if (value instanceof Map || value instanceof Set) { + return value.size > 0 + } + + if (isObject(value)) { + return Object.keys(value).length > 0 + } + + return true +} diff --git a/packages/utils/src/promotion/index.ts b/packages/utils/src/promotion/index.ts index 4dcff9e8d2..3e298f2568 100644 --- a/packages/utils/src/promotion/index.ts +++ b/packages/utils/src/promotion/index.ts @@ -18,3 +18,13 @@ export enum ApplicationMethodAllocation { EACH = "each", ACROSS = "across", } + +export enum PromotionRuleOperator { + GTE = "gte", + LTE = "lte", + GT = "gt", + LT = "lt", + EQ = "eq", + NE = "ne", + IN = "in", +}