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

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

FIXES CORE-1743
FIXES CORE-1764
This commit is contained in:
Adrien de Peretti authored and GitHub committed 2024-02-23 11:14:33 +00:00
1 parent 78b6d46584
commit 788c4a1e36
19 files changed
+1767 -55

No files matched your search

@@ -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": {
@@ -1,6 +1,6 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20240219115644 extends Migration {
export class Migration20240221164918_InitialSetupMigration extends Migration {
async up(): Promise<void> {
this.addSql('create table if not exists "fulfillment_address" ("id" text not null, "fulfillment_id" text null, "company" text null, "first_name" text null, "last_name" text null, "address_1" text null, "address_2" text null, "city" text null, "country_code" text null, "province" text null, "postal_code" text null, "phone" text null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "fulfillment_address_pkey" primary key ("id"));');
@@ -26,8 +26,7 @@ export class Migration20240219115644 extends Migration {
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_geo_zone_service_zone_id" ON "geo_zone" (service_zone_id) WHERE deleted_at IS NULL;');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_geo_zone_deleted_at" ON "geo_zone" (deleted_at) WHERE deleted_at IS NOT NULL;');
this.addSql('create table if not exists "shipping_option_type" ("id" text not null, "label" text not null, "description" text null, "code" text not null, "shipping_option_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "shipping_option_type_pkey" primary key ("id"));');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_shipping_option_type_shipping_option_id" ON "shipping_option_type" (shipping_option_id) WHERE deleted_at IS NULL;');
this.addSql('create table if not exists "shipping_option_type" ("id" text not null, "label" text not null, "description" text null, "code" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "shipping_option_type_pkey" primary key ("id"));');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_shipping_option_type_deleted_at" ON "shipping_option_type" (deleted_at) WHERE deleted_at IS NOT NULL;');
this.addSql('create table if not exists "shipping_profile" ("id" text not null, "name" text not null, "type" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "shipping_profile_pkey" primary key ("id"));');
@@ -42,7 +41,7 @@ export class Migration20240219115644 extends Migration {
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_shipping_option_shipping_option_type_id" ON "shipping_option" (shipping_option_type_id) WHERE deleted_at IS NULL;');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_shipping_option_deleted_at" ON "shipping_option" (deleted_at) WHERE deleted_at IS NOT NULL;');
this.addSql('create table if not exists "shipping_option_rule" ("id" text not null, "attribute" text not null, "operator" text not null, "value" jsonb null, "shipping_option_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "shipping_option_rule_pkey" primary key ("id"));');
this.addSql('create table if not exists "shipping_option_rule" ("id" text not null, "attribute" text not null, "operator" text check ("operator" in (\'in\', \'eq\', \'ne\', \'gt\', \'gte\', \'lt\', \'lte\', \'nin\')) not null, "value" jsonb null, "shipping_option_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "shipping_option_rule_pkey" primary key ("id"));');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_shipping_option_rule_shipping_option_id" ON "shipping_option_rule" (shipping_option_id) WHERE deleted_at IS NULL;');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_shipping_option_rule_deleted_at" ON "shipping_option_rule" (deleted_at) WHERE deleted_at IS NOT NULL;');
@@ -62,10 +61,17 @@ export class Migration20240219115644 extends Migration {
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_fulfillment_item_fulfillment_id" ON "fulfillment_item" (fulfillment_id) WHERE deleted_at IS NULL;');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_fulfillment_item_deleted_at" ON "fulfillment_item" (deleted_at) WHERE deleted_at IS NOT NULL;');
this.addSql('alter table if exists "service_zone" add constraint "service_zone_fulfillment_set_id_foreign" foreign key ("fulfillment_set_id") references "fulfillment_set" ("id") on update cascade;');
this.addSql('alter table if exists "geo_zone" add constraint "geo_zone_service_zone_id_foreign" foreign key ("service_zone_id") references "service_zone" ("id") on update cascade;');
this.addSql('alter table if exists "shipping_option" add constraint "shipping_option_service_zone_id_foreign" foreign key ("service_zone_id") references "service_zone" ("id") on update cascade;');
this.addSql('alter table if exists "shipping_option" add constraint "shipping_option_shipping_profile_id_foreign" foreign key ("shipping_profile_id") references "shipping_profile" ("id") on update cascade on delete set null;');
this.addSql('alter table if exists "shipping_option" add constraint "shipping_option_service_provider_id_foreign" foreign key ("service_provider_id") references "service_provider" ("id") on update cascade on delete set null;');
this.addSql('alter table if exists "shipping_option" add constraint "shipping_option_shipping_option_type_id_foreign" foreign key ("shipping_option_type_id") references "shipping_option_type" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "shipping_option_rule" add constraint "shipping_option_rule_shipping_option_id_foreign" foreign key ("shipping_option_id") references "shipping_option" ("id") on update cascade;');
this.addSql('alter table if exists "fulfillment" add constraint "fulfillment_shipping_option_id_foreign" foreign key ("shipping_option_id") references "shipping_option" ("id") on update cascade on delete set null;');
this.addSql('alter table if exists "fulfillment" add constraint "fulfillment_provider_id_foreign" foreign key ("provider_id") references "service_provider" ("id") on update cascade;');
this.addSql('alter table if exists "fulfillment" add constraint "fulfillment_delivery_address_id_foreign" foreign key ("delivery_address_id") references "fulfillment_address" ("id") on update cascade;');
+5 -1
View File
@@ -74,7 +74,11 @@ export default class GeoZone {
@Property({ columnType: "text", nullable: true })
city: string | null = null
@Property({ columnType: "text" })
@ManyToOne(() => ServiceZone, {
type: "text",
mapToPk: true,
fieldName: "service_zone_id",
})
@ServiceZoneIdIndex.MikroORMIndex()
service_zone_id: string
@@ -61,7 +61,11 @@ export default class ServiceZone {
@Property({ columnType: "jsonb", nullable: true })
metadata: Record<string, unknown> | null = null
@Property({ columnType: "text" })
@ManyToOne(() => FulfillmentSet, {
type: "text",
mapToPk: true,
fieldName: "fulfillment_set_id",
})
@FulfillmentSetIdIndex.MikroORMIndex()
fulfillment_set_id: string
@@ -8,6 +8,7 @@ import { DAL } from "@medusajs/types"
import {
BeforeCreate,
Entity,
Enum,
Filter,
ManyToOne,
OnInit,
@@ -16,6 +17,7 @@ import {
Property,
} from "@mikro-orm/core"
import ShippingOption from "./shipping-option"
import { RuleOperator } from "@utils"
type ShippingOptionRuleOptionalProps = DAL.SoftDeletableEntityDateColumns
@@ -42,17 +44,26 @@ export default class ShippingOptionRule {
@Property({ columnType: "text" })
attribute: string
@Property({ columnType: "text" })
operator: string
@Enum({
items: () => Object.values(RuleOperator),
columnType: "text",
})
operator: Lowercase<keyof typeof RuleOperator>
@Property({ columnType: "jsonb", nullable: true })
value: { value: string | string[] } | null = null
value: string | string[] | null = null
@Property({ columnType: "text" })
@ManyToOne(() => ShippingOption, {
type: "text",
mapToPk: true,
fieldName: "shipping_option_id",
})
@ShippingOptionIdIndex.MikroORMIndex()
shipping_option_id: string
@ManyToOne(() => ShippingOption, { persist: false })
@ManyToOne(() => ShippingOption, {
persist: false,
})
shipping_option: ShippingOption
@Property({
@@ -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
}
}
@@ -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
@@ -9,6 +9,7 @@ import {
UpdateFulfillmentSetDTO,
} from "@medusajs/types"
import {
arrayDifference,
getSetDifference,
InjectManager,
InjectTransactionManager,
@@ -24,14 +25,19 @@ import {
GeoZone,
ServiceZone,
ShippingOption,
ShippingOptionRule,
ShippingOptionType,
ShippingProfile,
} from "@models"
import { validateRules } from "@utils"
const generateMethodForModels = [
ServiceZone,
ShippingOption,
GeoZone,
ShippingProfile,
ShippingOptionRule,
ShippingOptionType,
]
type InjectedDependencies = {
@@ -41,6 +47,8 @@ type InjectedDependencies = {
geoZoneService: ModulesSdkTypes.InternalModuleService<any>
shippingProfileService: ModulesSdkTypes.InternalModuleService<any>
shippingOptionService: ModulesSdkTypes.InternalModuleService<any>
shippingOptionRuleService: ModulesSdkTypes.InternalModuleService<any>
shippingOptionTypeService: ModulesSdkTypes.InternalModuleService<any>
}
export default class FulfillmentModuleService<
@@ -48,7 +56,9 @@ export default class FulfillmentModuleService<
TServiceZoneEntity extends ServiceZone = ServiceZone,
TGeoZoneEntity extends GeoZone = GeoZone,
TShippingProfileEntity extends ShippingProfile = ShippingProfile,
TShippingOptionEntity extends ShippingOption = ShippingOption
TShippingOptionEntity extends ShippingOption = ShippingOption,
TShippingOptionRuleEntity extends ShippingOptionRule = ShippingOptionRule,
TSippingOptionTypeEntity extends ShippingOptionType = ShippingOptionType
>
extends ModulesSdkUtils.abstractModuleServiceFactory<
InjectedDependencies,
@@ -59,6 +69,8 @@ export default class FulfillmentModuleService<
ShippingOption: { dto: FulfillmentTypes.ShippingOptionDTO }
GeoZone: { dto: FulfillmentTypes.GeoZoneDTO }
ShippingProfile: { dto: FulfillmentTypes.ShippingProfileDTO }
ShippingOptionRule: { dto: FulfillmentTypes.ShippingOptionRuleDTO }
ShippingOptionType: { dto: FulfillmentTypes.ShippingOptionTypeDTO }
}
>(FulfillmentSet, generateMethodForModels, entityNameToLinkableKeysMap)
implements IFulfillmentModuleService
@@ -69,6 +81,8 @@ export default class FulfillmentModuleService<
protected readonly geoZoneService_: ModulesSdkTypes.InternalModuleService<TGeoZoneEntity>
protected readonly shippingProfileService_: ModulesSdkTypes.InternalModuleService<TShippingProfileEntity>
protected readonly shippingOptionService_: ModulesSdkTypes.InternalModuleService<TShippingOptionEntity>
protected readonly shippingOptionRuleService_: ModulesSdkTypes.InternalModuleService<TShippingOptionRuleEntity>
protected readonly shippingOptionTypeService_: ModulesSdkTypes.InternalModuleService<TSippingOptionTypeEntity>
constructor(
{
@@ -78,6 +92,8 @@ export default class FulfillmentModuleService<
geoZoneService,
shippingProfileService,
shippingOptionService,
shippingOptionRuleService,
shippingOptionTypeService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
@@ -89,6 +105,8 @@ export default class FulfillmentModuleService<
this.geoZoneService_ = geoZoneService
this.shippingProfileService_ = shippingProfileService
this.shippingOptionService_ = shippingOptionService
this.shippingOptionRuleService_ = shippingOptionRuleService
this.shippingOptionTypeService_ = shippingOptionTypeService
}
__joinerConfig(): ModuleJoinerConfig {
@@ -235,6 +253,11 @@ export default class FulfillmentModuleService<
return []
}
const rules = data_.flatMap((d) => d.rules)
if (rules.length) {
validateRules(rules as Record<string, unknown>[])
}
const createdShippingOptions = await this.shippingOptionService_.create(
data_,
sharedContext
@@ -328,6 +351,61 @@ export default class FulfillmentModuleService<
)
}
async createShippingOptionRules(
data: FulfillmentTypes.CreateShippingOptionRuleDTO[],
sharedContext?: Context
): Promise<FulfillmentTypes.ShippingOptionRuleDTO[]>
async createShippingOptionRules(
data: FulfillmentTypes.CreateShippingOptionRuleDTO,
sharedContext?: Context
): Promise<FulfillmentTypes.ShippingOptionRuleDTO>
@InjectManager("baseRepository_")
async createShippingOptionRules(
data:
| FulfillmentTypes.CreateShippingOptionRuleDTO[]
| FulfillmentTypes.CreateShippingOptionRuleDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
| FulfillmentTypes.ShippingOptionRuleDTO
| FulfillmentTypes.ShippingOptionRuleDTO[]
> {
const createdShippingOptionRules = await this.createShippingOptionRules_(
data,
sharedContext
)
return await this.baseRepository_.serialize<
| FulfillmentTypes.ShippingOptionRuleDTO
| FulfillmentTypes.ShippingOptionRuleDTO[]
>(createdShippingOptionRules, {
populate: true,
})
}
@InjectTransactionManager("baseRepository_")
async createShippingOptionRules_(
data:
| FulfillmentTypes.CreateShippingOptionRuleDTO[]
| FulfillmentTypes.CreateShippingOptionRuleDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<TShippingOptionRuleEntity | TShippingOptionRuleEntity[]> {
const data_ = Array.isArray(data) ? data : [data]
if (!data_.length) {
return []
}
validateRules(data_ as unknown as Record<string, unknown>[])
const createdShippingOptionRules =
await this.shippingOptionRuleService_.create(data_, sharedContext)
return Array.isArray(data)
? createdShippingOptionRules
: createdShippingOptionRules[0]
}
update(
data: FulfillmentTypes.UpdateFulfillmentSetDTO[],
sharedContext?: Context
@@ -375,6 +453,7 @@ export default class FulfillmentModuleService<
},
{
relations: ["service_zones", "service_zones.geo_zones"],
take: fulfillmentSetIds.length,
},
sharedContext
)
@@ -557,6 +636,7 @@ export default class FulfillmentModuleService<
},
{
relations: ["geo_zones"],
take: serviceZoneIds.length,
},
sharedContext
)
@@ -662,7 +742,7 @@ export default class FulfillmentModuleService<
sharedContext?: Context
): Promise<FulfillmentTypes.ShippingOptionDTO>
@InjectTransactionManager("baseRepository_")
@InjectManager("baseRepository_")
async updateShippingOptions(
data:
| FulfillmentTypes.UpdateShippingOptionDTO[]
@@ -671,7 +751,123 @@ export default class FulfillmentModuleService<
): Promise<
FulfillmentTypes.ShippingOptionDTO[] | FulfillmentTypes.ShippingOptionDTO
> {
return []
const updatedShippingOptions = await this.updateShippingOptions_(
data,
sharedContext
)
return await this.baseRepository_.serialize<
FulfillmentTypes.ShippingOptionDTO | FulfillmentTypes.ShippingOptionDTO[]
>(updatedShippingOptions, {
populate: true,
})
}
@InjectTransactionManager("baseRepository_")
async updateShippingOptions_(
data:
| FulfillmentTypes.UpdateShippingOptionDTO[]
| FulfillmentTypes.UpdateShippingOptionDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<TShippingOptionEntity | TShippingOptionEntity[]> {
const dataArray = Array.isArray(data) ? data : [data]
if (!dataArray.length) {
return []
}
const shippingOptionIds = dataArray.map((s) => s.id)
if (!shippingOptionIds.length) {
return []
}
const shippingOptions = await this.shippingOptionService_.list(
{
id: shippingOptionIds,
},
{
relations: ["rules"],
take: shippingOptionIds.length,
},
sharedContext
)
const existingShippingOptions = new Map(
shippingOptions.map((s) => [s.id, s])
)
FulfillmentModuleService.validateMissingShippingOptions_(
shippingOptions,
dataArray
)
const ruleIdsToDelete: string[] = []
dataArray.forEach((shippingOption) => {
if (!shippingOption.rules) {
return
}
const existingShippingOption = existingShippingOptions.get(
shippingOption.id
)! // Garuantueed to exist since the validation above have been performed
const existingRules = existingShippingOption.rules
FulfillmentModuleService.validateMissingShippingOptionRules(
existingShippingOption,
shippingOption
)
const existingRulesMap = new Map(
existingRules.map((rule) => [rule.id, rule])
)
const updatedRules = shippingOption.rules
const updatedRuleIds = updatedRules
.map((r) => "id" in r && r.id)
.filter((id): id is string => !!id)
const toDeleteRuleIds = arrayDifference(
updatedRuleIds,
Array.from(existingRulesMap.keys())
) as string[]
if (toDeleteRuleIds.length) {
ruleIdsToDelete.push(...toDeleteRuleIds)
}
const newRules = updatedRules
.map((rule) => {
if (!("id" in rule)) {
return rule
}
return
})
.filter(Boolean)
validateRules(newRules as Record<string, unknown>[])
shippingOption.rules = shippingOption.rules.map((rule) => {
if (!("id" in rule)) {
return rule
}
return existingRulesMap.get(rule.id)!
})
})
if (ruleIdsToDelete.length) {
await this.shippingOptionRuleService_.delete(
ruleIdsToDelete,
sharedContext
)
}
const updatedShippingOptions = await this.shippingOptionService_.update(
dataArray,
sharedContext
)
return Array.isArray(data)
? updatedShippingOptions
: updatedShippingOptions[0]
}
updateShippingProfiles(
@@ -724,4 +920,107 @@ export default class FulfillmentModuleService<
return Array.isArray(data) ? serialized : serialized[0]
}
updateShippingOptionRules(
data: FulfillmentTypes.UpdateShippingOptionRuleDTO[],
sharedContext?: Context
): Promise<FulfillmentTypes.ShippingOptionRuleDTO[]>
updateShippingOptionRules(
data: FulfillmentTypes.UpdateShippingOptionRuleDTO,
sharedContext?: Context
): Promise<FulfillmentTypes.ShippingOptionRuleDTO>
@InjectManager("baseRepository_")
async updateShippingOptionRules(
data:
| FulfillmentTypes.UpdateShippingOptionRuleDTO[]
| FulfillmentTypes.UpdateShippingOptionRuleDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
| FulfillmentTypes.ShippingOptionRuleDTO[]
| FulfillmentTypes.ShippingOptionRuleDTO
> {
const updatedShippingOptionRules = await this.updateShippingOptionRules_(
data,
sharedContext
)
return await this.baseRepository_.serialize<
| FulfillmentTypes.ShippingOptionRuleDTO
| FulfillmentTypes.ShippingOptionRuleDTO[]
>(updatedShippingOptionRules, {
populate: true,
})
}
@InjectTransactionManager("baseRepository_")
async updateShippingOptionRules_(
data:
| FulfillmentTypes.UpdateShippingOptionRuleDTO[]
| FulfillmentTypes.UpdateShippingOptionRuleDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<TShippingOptionRuleEntity | TShippingOptionRuleEntity[]> {
const data_ = Array.isArray(data) ? data : [data]
if (!data_.length) {
return []
}
validateRules(data_ as unknown as Record<string, unknown>[])
const updatedShippingOptionRules =
await this.shippingOptionRuleService_.update(data_, sharedContext)
return Array.isArray(data)
? updatedShippingOptionRules
: updatedShippingOptionRules[0]
}
protected static validateMissingShippingOptions_(
shippingOptions: ShippingOption[],
shippingOptionsData: FulfillmentTypes.UpdateShippingOptionDTO[]
) {
const missingShippingOptionIds = arrayDifference(
shippingOptionsData.map((s) => s.id),
shippingOptions.map((s) => s.id)
)
if (missingShippingOptionIds.length) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`The following shipping options do not exist: ${Array.from(
missingShippingOptionIds
).join(", ")}`
)
}
}
protected static validateMissingShippingOptionRules(
shippingOption: ShippingOption,
shippingOptionUpdateData: FulfillmentTypes.UpdateShippingOptionDTO
) {
if (!shippingOptionUpdateData.rules) {
return
}
const existingRules = shippingOption.rules
const rulesSet = new Set(existingRules.map((r) => r.id))
// Only validate the rules that have an id to validate that they really exists in the shipping option
const expectedRuleSet = new Set(
shippingOptionUpdateData.rules
.map((r) => "id" in r && r.id)
.filter((id): id is string => !!id)
)
const nonAlreadyExistingRules = getSetDifference(expectedRuleSet, rulesSet)
if (nonAlreadyExistingRules.size) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`The following rules does not exists: ${Array.from(
nonAlreadyExistingRules
).join(", ")} on shipping option ${shippingOptionUpdateData.id}`
)
}
}
}
@@ -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)
})
})
+1
View File
@@ -0,0 +1 @@
export * from './utils'
+152
View File
@@ -0,0 +1,152 @@
import { isString, MedusaError, pickValueFromObject } from "@medusajs/utils"
/**
* The rule engine here is kept inside the module as of now, but it could be moved
* to the utils package and be used across the different modules that provides context
* based rule filtering.
*
* TODO: discussion around that should happen at some point
*/
export type Rule = {
attribute: string
operator: RuleOperator
value: string | string[]
}
export enum RuleOperator {
IN = "in",
EQ = "eq",
NE = "ne",
GT = "gt",
GTE = "gte",
LT = "lt",
LTE = "lte",
NIN = "nin",
}
export const availableOperators = Object.values(RuleOperator)
const isDate = (str: string) => {
return !isNaN(Date.parse(str))
}
const operatorsPredicate = {
in: (contextValue: string, ruleValue: string[]) =>
ruleValue.includes(contextValue),
nin: (contextValue: string, ruleValue: string[]) =>
!ruleValue.includes(contextValue),
eq: (contextValue: string, ruleValue: string) => contextValue === ruleValue,
ne: (contextValue: string, ruleValue: string) => contextValue !== ruleValue,
gt: (contextValue: string, ruleValue: string) => {
if (isDate(contextValue) && isDate(ruleValue)) {
return new Date(contextValue) > new Date(ruleValue)
}
return Number(contextValue) > Number(ruleValue)
},
gte: (contextValue: string, ruleValue: string) => {
if (isDate(contextValue) && isDate(ruleValue)) {
return new Date(contextValue) >= new Date(ruleValue)
}
return Number(contextValue) >= Number(ruleValue)
},
lt: (contextValue: string, ruleValue: string) => {
if (isDate(contextValue) && isDate(ruleValue)) {
return new Date(contextValue) < new Date(ruleValue)
}
return Number(contextValue) < Number(ruleValue)
},
lte: (contextValue: string, ruleValue: string) => {
if (isDate(contextValue) && isDate(ruleValue)) {
return new Date(contextValue) <= new Date(ruleValue)
}
return Number(contextValue) <= Number(ruleValue)
},
}
/**
* Validate contextValue context object from contextValue set of rules.
* By default, all rules must be valid to return true unless the option atLeastOneValidRule is set to true.
* @param context
* @param rules
* @param options
*/
export function isContextValidForRules(
context: Record<string, any>,
rules: Rule[],
options: {
atLeastOneValidRule: boolean
} = {
atLeastOneValidRule: false,
}
) {
const { atLeastOneValidRule } = options
const loopComparator = atLeastOneValidRule ? rules.some : rules.every
const predicate = (rule) => {
const { attribute, operator, value } = rule
const contextValue = pickValueFromObject(attribute, context)
return operatorsPredicate[operator](
contextValue,
value as string & string[]
)
}
return loopComparator.apply(rules, [predicate])
}
/**
* Validate contextValue rule object
* @param rule
*/
export function validateRule(rule: Record<string, unknown>): boolean {
if (!rule.attribute || !rule.operator || !rule.value) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Rule must have an attribute, an operator and contextValue value"
)
}
if (!isString(rule.attribute)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Rule attribute must be contextValue string"
)
}
if (!isString(rule.operator)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Rule operator must be contextValue string"
)
}
if (!availableOperators.includes(rule.operator as RuleOperator)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Rule operator ${
rule.operator
} is not supported. Must be one of ${availableOperators.join(", ")}`
)
}
if (rule.operator === RuleOperator.IN || rule.operator === RuleOperator.NIN) {
if (!Array.isArray(rule.value)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Rule value must be an array for in/nin operators"
)
}
}
return true
}
/**
* Validate contextValue set of rules
* @param rules
*/
export function validateRules(rules: Record<string, unknown>[]): boolean {
rules.forEach(validateRule)
return true
}