feat(types,utils): add promotions create with application method (#5945)

What:

- Promotions can be created with its bare attributes
- Promotions one to one relationship with ApplicationMethod can be created with its attributes + validation

RESOLVES CORE-1592
RESOLVES CORE-1595
This commit is contained in:
Riqwan Thamir
2023-12-21 12:04:50 +01:00
committed by GitHub
parent c41f3002f3
commit 890e76a5c5
38 changed files with 1497 additions and 55 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/types": patch
"@medusajs/utils": patch
---
feat(types,utils): add promotions create with application method

View File

@@ -0,0 +1,14 @@
import { PromotionType } from "@medusajs/utils"
export const defaultPromotionsData = [
{
id: "promotion-id-1",
code: "PROMOTION_1",
type: PromotionType.STANDARD,
},
{
id: "promotion-id-2",
code: "PROMOTION_2",
type: PromotionType.STANDARD,
},
]

View File

@@ -0,0 +1,22 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { Promotion } from "@models"
import { defaultPromotionsData } from "./data"
export * from "./data"
export async function createPromotions(
manager: SqlEntityManager,
promotionsData = defaultPromotionsData
): Promise<Promotion[]> {
const promotion: Promotion[] = []
for (let promotionData of promotionsData) {
let promotion = manager.create(Promotion, promotionData)
manager.persist(promotion)
await manager.flush()
}
return promotion
}

View File

@@ -0,0 +1,132 @@
import { IPromotionModuleService } from "@medusajs/types"
import { PromotionType } from "@medusajs/utils"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { initialize } from "../../../../src"
import { DB_URL, MikroOrmWrapper } from "../../../utils"
jest.setTimeout(30000)
describe("Promotion Service", () => {
let service: IPromotionModuleService
let repositoryManager: SqlEntityManager
beforeEach(async () => {
await MikroOrmWrapper.setupDatabase()
repositoryManager = MikroOrmWrapper.forkManager()
service = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PROMOTION_DB_SCHEMA,
},
})
})
afterEach(async () => {
await MikroOrmWrapper.clearDatabase()
})
describe("create", () => {
it("should throw an error when required params are not passed", async () => {
const error = await service
.create([
{
type: PromotionType.STANDARD,
} as any,
])
.catch((e) => e)
expect(error.message).toContain(
"Value for Promotion.code is required, 'undefined' found"
)
})
it("should create a basic promotion successfully", async () => {
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
},
])
const [promotion] = await service.list({
id: [createdPromotion.id],
})
expect(promotion).toEqual(
expect.objectContaining({
code: "PROMOTION_TEST",
is_automatic: false,
type: "standard",
})
)
})
it("should create a promotion with order application method successfully", async () => {
const [createdPromotion] = await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
application_method: {
type: "fixed",
target_type: "order",
value: 100,
},
},
])
const [promotion] = await service.list({
id: [createdPromotion.id],
})
expect(promotion).toEqual(
expect.objectContaining({
code: "PROMOTION_TEST",
is_automatic: false,
type: "standard",
})
)
})
it("should throw error when creating an item application method without allocation", async () => {
const error = await service
.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
application_method: {
type: "fixed",
target_type: "item",
value: 100,
},
},
])
.catch((e) => e)
expect(error.message).toContain(
"application_method.allocation should be either 'across OR each' when application_method.target_type is either 'shipping OR item'"
)
})
it("should throw error when creating an item application, each allocation, without max quanity", async () => {
const error = await service
.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
application_method: {
type: "fixed",
allocation: "each",
target_type: "shipping",
value: 100,
},
},
])
.catch((e) => e)
expect(error.message).toContain(
"application_method.max_quantity is required when application_method.allocation is 'each'"
)
})
})
})

View File

@@ -0,0 +1,71 @@
import { PromotionType } from "@medusajs/utils"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { PromotionRepository } from "@repositories"
import { PromotionService } from "@services"
import { createPromotions } from "../../../__fixtures__/promotion"
import { MikroOrmWrapper } from "../../../utils"
jest.setTimeout(30000)
describe("Promotion Service", () => {
let service: PromotionService
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
beforeEach(async () => {
await MikroOrmWrapper.setupDatabase()
repositoryManager = await MikroOrmWrapper.forkManager()
testManager = await MikroOrmWrapper.forkManager()
const promotionRepository = new PromotionRepository({
manager: repositoryManager,
})
service = new PromotionService({
promotionRepository: promotionRepository,
})
await createPromotions(testManager)
})
afterEach(async () => {
await MikroOrmWrapper.clearDatabase()
})
describe("create", () => {
it("should throw an error when required params are not passed", async () => {
const error = await service
.create([
{
type: PromotionType.STANDARD,
} as any,
])
.catch((e) => e)
expect(error.message).toContain(
"Value for Promotion.code is required, 'undefined' found"
)
})
it("should create a promotion successfully", async () => {
await service.create([
{
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
},
])
const [promotion] = await service.list({
code: ["PROMOTION_TEST"],
})
expect(promotion).toEqual(
expect.objectContaining({
code: "PROMOTION_TEST",
is_automatic: false,
type: "standard",
})
)
})
})
})

View File

@@ -10,7 +10,11 @@ import { moduleDefinition } from "../module-definition"
import { InitializeModuleInjectableDependencies } from "../types"
export const initialize = async (
options?: ModulesSdkTypes.ModuleBootstrapDeclaration,
options?:
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
| ExternalModuleDeclaration
| InternalModuleDeclaration,
injectedDependencies?: InitializeModuleInjectableDependencies
): Promise<IPromotionModuleService> => {
const loaded = await MedusaModule.bootstrap<IPromotionModuleService>({

View File

@@ -1,4 +1,8 @@
import { InternalModuleDeclaration, LoaderOptions, Modules } from "@medusajs/modules-sdk"
import {
InternalModuleDeclaration,
LoaderOptions,
Modules,
} from "@medusajs/modules-sdk"
import { ModulesSdkTypes } from "@medusajs/types"
import { ModulesSdkUtils } from "@medusajs/utils"
import { EntitySchema } from "@mikro-orm/core"

View File

@@ -3,6 +3,7 @@ import * as defaultRepositories from "@repositories"
import { LoaderOptions } from "@medusajs/modules-sdk"
import { ModulesSdkTypes } from "@medusajs/types"
import { loadCustomRepositories } from "@medusajs/utils"
import * as defaultServices from "@services"
import { asClass } from "awilix"
export default async ({
@@ -17,7 +18,10 @@ export default async ({
)?.repositories
container.register({
// promotionService: asClass(defaultServices.PromotionService).singleton(),
promotionService: asClass(defaultServices.PromotionService).singleton(),
applicationMethodService: asClass(
defaultServices.ApplicationMethodService
).singleton(),
})
if (customRepositories) {
@@ -34,5 +38,11 @@ export default async ({
function loadDefaultRepositories({ container }) {
container.register({
baseRepository: asClass(defaultRepositories.BaseRepository).singleton(),
applicationMethodRepository: asClass(
defaultRepositories.ApplicationMethodRepository
).singleton(),
promotionRepository: asClass(
defaultRepositories.PromotionRepository
).singleton(),
})
}

View File

@@ -0,0 +1,303 @@
{
"namespaces": [
"public"
],
"name": "public",
"tables": [
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"code": {
"name": "code",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"is_automatic": {
"name": "is_automatic",
"type": "boolean",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "false",
"mappedType": "boolean"
},
"type": {
"name": "type",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": [
"standard",
"buyget"
],
"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",
"schema": "public",
"indexes": [
{
"columnNames": [
"code"
],
"composite": false,
"keyName": "IDX_promotion_code",
"primary": false,
"unique": false
},
{
"columnNames": [
"type"
],
"composite": false,
"keyName": "IDX_promotion_type",
"primary": false,
"unique": false
},
{
"keyName": "IDX_promotion_code_unique",
"columnNames": [
"code"
],
"composite": false,
"primary": false,
"unique": true
},
{
"keyName": "promotion_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"value": {
"name": "value",
"type": "numeric",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "decimal"
},
"max_quantity": {
"name": "max_quantity",
"type": "numeric",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "decimal"
},
"type": {
"name": "type",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": [
"fixed",
"percentage"
],
"mappedType": "enum"
},
"target_type": {
"name": "target_type",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": [
"order",
"shipping",
"item"
],
"mappedType": "enum"
},
"allocation": {
"name": "allocation",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"enumItems": [
"each",
"across"
],
"mappedType": "enum"
},
"promotion_id": {
"name": "promotion_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"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": "application_method",
"schema": "public",
"indexes": [
{
"columnNames": [
"type"
],
"composite": false,
"keyName": "IDX_application_method_type",
"primary": false,
"unique": false
},
{
"columnNames": [
"target_type"
],
"composite": false,
"keyName": "IDX_application_method_target_type",
"primary": false,
"unique": false
},
{
"columnNames": [
"allocation"
],
"composite": false,
"keyName": "IDX_application_method_allocation",
"primary": false,
"unique": false
},
{
"columnNames": [
"promotion_id"
],
"composite": false,
"keyName": "application_method_promotion_id_unique",
"primary": false,
"unique": true
},
{
"keyName": "application_method_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"application_method_promotion_id_foreign": {
"constraintName": "application_method_promotion_id_foreign",
"columnNames": [
"promotion_id"
],
"localTableName": "public.application_method",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.promotion",
"updateRule": "cascade"
}
}
}
]
}

View File

@@ -1,36 +0,0 @@
{
"namespaces": [
"public"
],
"name": "public",
"tables": [
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
}
},
"name": "promotion",
"schema": "public",
"indexes": [
{
"keyName": "promotion_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
}
]
}

View File

@@ -1,9 +0,0 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20231218142613 extends Migration {
async up(): Promise<void> {
this.addSql('create table "promotion" ("id" text not null, constraint "promotion_pkey" primary key ("id"));');
}
}

View File

@@ -0,0 +1,34 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20231221104256 extends Migration {
async up(): Promise<void> {
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;'
)
}
}

View File

@@ -0,0 +1,81 @@
import {
ApplicationMethodAllocation,
ApplicationMethodTargetType,
ApplicationMethodType,
} from "@medusajs/types"
import { PromotionUtils, generateEntityId } from "@medusajs/utils"
import {
BeforeCreate,
Entity,
Enum,
Index,
OnInit,
OneToOne,
OptionalProps,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import Promotion from "./promotion"
type OptionalFields = "value" | "max_quantity" | "allocation"
@Entity()
export default class ApplicationMethod {
[OptionalProps]?: OptionalFields
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "numeric", nullable: true, serializer: Number })
value?: number
@Property({ columnType: "numeric", nullable: true, serializer: Number })
max_quantity?: number
@Index({ name: "IDX_application_method_type" })
@Enum(() => PromotionUtils.ApplicationMethodType)
type: ApplicationMethodType
@Index({ name: "IDX_application_method_target_type" })
@Enum(() => PromotionUtils.ApplicationMethodTargetType)
target_type: ApplicationMethodTargetType
@Index({ name: "IDX_application_method_allocation" })
@Enum({
items: () => PromotionUtils.ApplicationMethodAllocation,
nullable: true,
})
allocation?: ApplicationMethodAllocation
@OneToOne({
entity: () => Promotion,
})
promotion: Promotion
@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
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "app_method")
}
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "promo")
}
}

View File

@@ -1 +1,2 @@
export { default as ApplicationMethod } from "./application-method"
export { default as Promotion } from "./promotion"

View File

@@ -1,16 +1,72 @@
import { generateEntityId } from "@medusajs/utils"
import { BeforeCreate, Entity, PrimaryKey, OnInit } from "@mikro-orm/core"
import { PromotionType } from "@medusajs/types"
import { PromotionUtils, generateEntityId } from "@medusajs/utils"
import {
BeforeCreate,
Entity,
Enum,
Index,
OnInit,
OneToOne,
OptionalProps,
PrimaryKey,
Property,
Unique,
} from "@mikro-orm/core"
import ApplicationMethod from "./application-method"
type OptionalFields = "is_automatic"
type OptionalRelations = "application_method"
@Entity()
export default class Promotion {
[OptionalProps]?: OptionalFields | OptionalRelations
@PrimaryKey({ columnType: "text" })
id!: string
@Property({ columnType: "text" })
@Index({ name: "IDX_promotion_code" })
@Unique({
name: "IDX_promotion_code_unique",
properties: ["code"],
})
code: string
@Property({ columnType: "boolean", default: false })
is_automatic?: boolean = false
@Index({ name: "IDX_promotion_type" })
@Enum(() => PromotionUtils.PromotionType)
type: PromotionType
@OneToOne({
entity: () => ApplicationMethod,
mappedBy: (am) => am.promotion,
})
application_method: ApplicationMethod
@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
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "promo")
}
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "promo")

View File

@@ -0,0 +1,121 @@
import { Context, DAL } from "@medusajs/types"
import { DALUtils, MedusaError } from "@medusajs/utils"
import {
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
} from "@mikro-orm/core"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { ApplicationMethod } from "@models"
import {
CreateApplicationMethodDTO,
UpdateApplicationMethodDTO,
} from "../types"
export class ApplicationMethodRepository 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<ApplicationMethod> = { where: {} },
context: Context = {}
): Promise<ApplicationMethod[]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
return await manager.find(
ApplicationMethod,
findOptions_.where as MikroFilterQuery<ApplicationMethod>,
findOptions_.options as MikroOptions<ApplicationMethod>
)
}
async findAndCount(
findOptions: DAL.FindOptions<ApplicationMethod> = { where: {} },
context: Context = {}
): Promise<[ApplicationMethod[], number]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
return await manager.findAndCount(
ApplicationMethod,
findOptions_.where as MikroFilterQuery<ApplicationMethod>,
findOptions_.options as MikroOptions<ApplicationMethod>
)
}
async delete(ids: string[], context: Context = {}): Promise<void> {
const manager = this.getActiveManager<SqlEntityManager>(context)
await manager.nativeDelete(ApplicationMethod, { id: { $in: ids } }, {})
}
async create(
data: CreateApplicationMethodDTO[],
context: Context = {}
): Promise<ApplicationMethod[]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const applicationMethods = data.map((applicationMethodData) => {
return manager.create(ApplicationMethod, applicationMethodData)
})
manager.persist(applicationMethods)
return applicationMethods
}
async update(
data: UpdateApplicationMethodDTO[],
context: Context = {}
): Promise<ApplicationMethod[]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const applicationMethodIds = data.map(
(applicationMethodData) => applicationMethodData.id
)
const existingApplicationMethods = await this.find(
{
where: {
id: {
$in: applicationMethodIds,
},
},
},
context
)
const existingApplicationMethodMap = new Map(
existingApplicationMethods.map<[string, ApplicationMethod]>(
(applicationMethod) => [applicationMethod.id, applicationMethod]
)
)
const applicationMethods = data.map((applicationMethodData) => {
const existingApplicationMethod = existingApplicationMethodMap.get(
applicationMethodData.id
)
if (!existingApplicationMethod) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`ApplicationMethod with id "${applicationMethodData.id}" not found`
)
}
return manager.assign(existingApplicationMethod, applicationMethodData)
})
manager.persist(applicationMethods)
return applicationMethods
}
}

View File

@@ -1 +1,3 @@
export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils"
export { ApplicationMethodRepository } from "./application-method"
export { PromotionRepository } from "./promotion"

View File

@@ -0,0 +1,124 @@
import { Context, DAL } from "@medusajs/types"
import { DALUtils, MedusaError } from "@medusajs/utils"
import {
LoadStrategy,
FilterQuery as MikroFilterQuery,
FindOptions as MikroOptions,
} from "@mikro-orm/core"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { Promotion } from "@models"
import { CreatePromotionDTO, UpdatePromotionDTO } from "../types"
export class PromotionRepository 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<Promotion> = { where: {} },
context: Context = {}
): Promise<Promotion[]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return await manager.find(
Promotion,
findOptions_.where as MikroFilterQuery<Promotion>,
findOptions_.options as MikroOptions<Promotion>
)
}
async findAndCount(
findOptions: DAL.FindOptions<Promotion> = { where: {} },
context: Context = {}
): Promise<[Promotion[], number]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const findOptions_ = { ...findOptions }
findOptions_.options ??= {}
Object.assign(findOptions_.options, {
strategy: LoadStrategy.SELECT_IN,
})
return await manager.findAndCount(
Promotion,
findOptions_.where as MikroFilterQuery<Promotion>,
findOptions_.options as MikroOptions<Promotion>
)
}
async delete(ids: string[], context: Context = {}): Promise<void> {
const manager = this.getActiveManager<SqlEntityManager>(context)
await manager.nativeDelete(Promotion, { id: { $in: ids } }, {})
}
async create(
data: CreatePromotionDTO[],
context: Context = {}
): Promise<Promotion[]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const promotions = data.map((promotionData) => {
return manager.create(Promotion, promotionData)
})
manager.persist(promotions)
return promotions
}
async update(
data: UpdatePromotionDTO[],
context: Context = {}
): Promise<Promotion[]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const promotionIds = data.map((promotionData) => promotionData.id)
const existingPromotions = await this.find(
{
where: {
id: {
$in: promotionIds,
},
},
},
context
)
const existingPromotionMap = new Map(
existingPromotions.map<[string, Promotion]>((promotion) => [
promotion.id,
promotion,
])
)
const promotions = data.map((promotionData) => {
const existingPromotion = existingPromotionMap.get(promotionData.id)
if (!existingPromotion) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Promotion with id "${promotionData.id}" not found`
)
}
return manager.assign(existingPromotion, promotionData)
})
manager.persist(promotions)
return promotions
}
}

View File

@@ -0,0 +1,108 @@
import { Context, DAL, FindConfig, PromotionTypes } from "@medusajs/types"
import {
InjectManager,
InjectTransactionManager,
MedusaContext,
ModulesSdkUtils,
retrieveEntity,
} from "@medusajs/utils"
import { ApplicationMethod } from "@models"
import { ApplicationMethodRepository } from "@repositories"
import {
CreateApplicationMethodDTO,
UpdateApplicationMethodDTO,
} from "../types"
type InjectedDependencies = {
applicationMethodRepository: DAL.RepositoryService
}
export default class ApplicationMethodService<
TEntity extends ApplicationMethod = ApplicationMethod
> {
protected readonly applicationMethodRepository_: DAL.RepositoryService
constructor({ applicationMethodRepository }: InjectedDependencies) {
this.applicationMethodRepository_ = applicationMethodRepository
}
@InjectManager("applicationMethodRepository_")
async retrieve(
applicationMethodId: string,
config: FindConfig<PromotionTypes.ApplicationMethodDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity> {
return (await retrieveEntity<
ApplicationMethod,
PromotionTypes.ApplicationMethodDTO
>({
id: applicationMethodId,
entityName: ApplicationMethod.name,
repository: this.applicationMethodRepository_,
config,
sharedContext,
})) as TEntity
}
@InjectManager("applicationMethodRepository_")
async list(
filters: PromotionTypes.FilterableApplicationMethodProps = {},
config: FindConfig<PromotionTypes.ApplicationMethodDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
const queryOptions = ModulesSdkUtils.buildQuery<ApplicationMethod>(
filters,
config
)
return (await this.applicationMethodRepository_.find(
queryOptions,
sharedContext
)) as TEntity[]
}
@InjectManager("applicationMethodRepository_")
async listAndCount(
filters: PromotionTypes.FilterableApplicationMethodProps = {},
config: FindConfig<PromotionTypes.ApplicationMethodDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], number]> {
const queryOptions = ModulesSdkUtils.buildQuery<ApplicationMethod>(
filters,
config
)
return (await this.applicationMethodRepository_.findAndCount(
queryOptions,
sharedContext
)) as [TEntity[], number]
}
@InjectTransactionManager("applicationMethodRepository_")
async create(
data: CreateApplicationMethodDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return (await (
this.applicationMethodRepository_ as ApplicationMethodRepository
).create(data, sharedContext)) as TEntity[]
}
@InjectTransactionManager("applicationMethodRepository_")
async update(
data: UpdateApplicationMethodDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return (await (
this.applicationMethodRepository_ as ApplicationMethodRepository
).update(data, sharedContext)) as TEntity[]
}
@InjectTransactionManager("applicationMethodRepository_")
async delete(
ids: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this.applicationMethodRepository_.delete(ids, sharedContext)
}
}

View File

@@ -1 +1,3 @@
export { default as ApplicationMethodService } from "./application-method"
export { default as PromotionService } from "./promotion"
export { default as PromotionModuleService } from "./promotion-module"

View File

@@ -1,16 +1,26 @@
import {
Context,
DAL,
FindConfig,
InternalModuleDeclaration,
ModuleJoinerConfig,
PromotionTypes,
} from "@medusajs/types"
import {
InjectManager,
InjectTransactionManager,
MedusaContext,
} from "@medusajs/utils"
import { Promotion } from "@models"
import { ApplicationMethodService, PromotionService } from "@services"
import { joinerConfig } from "../joiner-config"
import { CreateApplicationMethodDTO, CreatePromotionDTO } from "../types"
import { validateApplicationMethodAttributes } from "../utils"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
promotionService: PromotionService
applicationMethodService: ApplicationMethodService
}
export default class PromotionModuleService<
@@ -18,15 +28,112 @@ export default class PromotionModuleService<
> implements PromotionTypes.IPromotionModuleService
{
protected baseRepository_: DAL.RepositoryService
protected promotionService_: PromotionService
protected applicationMethodService_: ApplicationMethodService
constructor(
{ baseRepository }: InjectedDependencies,
{
baseRepository,
promotionService,
applicationMethodService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
this.baseRepository_ = baseRepository
this.promotionService_ = promotionService
this.applicationMethodService_ = applicationMethodService
}
__joinerConfig(): ModuleJoinerConfig {
return joinerConfig
}
@InjectManager("baseRepository_")
async list(
filters: PromotionTypes.FilterablePromotionProps = {},
config: FindConfig<PromotionTypes.PromotionDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.PromotionDTO[]> {
const promotions = await this.promotionService_.list(
filters,
config,
sharedContext
)
return this.baseRepository_.serialize<PromotionTypes.PromotionDTO[]>(
promotions,
{
populate: true,
}
)
}
@InjectManager("baseRepository_")
async create(
data: PromotionTypes.CreatePromotionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.PromotionDTO[]> {
const promotions = await this.create_(data, sharedContext)
return await this.list(
{ id: promotions.map((p) => p!.id) },
{
relations: ["application_method"],
},
sharedContext
)
}
@InjectTransactionManager("baseRepository_")
protected async create_(
data: PromotionTypes.CreatePromotionDTO[],
@MedusaContext() sharedContext: Context = {}
) {
const promotionCodeApplicationMethodDataMap = new Map<
string,
PromotionTypes.CreateApplicationMethodDTO
>()
const promotionsData: CreatePromotionDTO[] = []
const applicationMethodsData: CreateApplicationMethodDTO[] = []
for (const {
application_method: applicationMethodData,
...promotionData
} of data) {
if (applicationMethodData) {
promotionCodeApplicationMethodDataMap.set(
promotionData.code,
applicationMethodData
)
}
promotionsData.push(promotionData)
}
const createdPromotions = await this.promotionService_.create(
data,
sharedContext
)
for (const promotion of createdPromotions) {
const data = promotionCodeApplicationMethodDataMap.get(promotion.code)
if (!data) continue
const applicationMethodData = {
...data,
promotion,
}
validateApplicationMethodAttributes(applicationMethodData)
applicationMethodsData.push(applicationMethodData)
}
await this.applicationMethodService_.create(
applicationMethodsData,
sharedContext
)
return createdPromotions
}
}

View File

@@ -0,0 +1,96 @@
import { Context, DAL, FindConfig, PromotionTypes } from "@medusajs/types"
import {
InjectManager,
InjectTransactionManager,
MedusaContext,
ModulesSdkUtils,
retrieveEntity,
} from "@medusajs/utils"
import { Promotion } from "@models"
import { PromotionRepository } from "@repositories"
import { CreatePromotionDTO, UpdatePromotionDTO } from "../types"
type InjectedDependencies = {
promotionRepository: DAL.RepositoryService
}
export default class PromotionService<TEntity extends Promotion = Promotion> {
protected readonly promotionRepository_: DAL.RepositoryService
constructor({ promotionRepository }: InjectedDependencies) {
this.promotionRepository_ = promotionRepository
}
@InjectManager("promotionRepository_")
async retrieve(
promotionId: string,
config: FindConfig<PromotionTypes.PromotionDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity> {
return (await retrieveEntity<Promotion, PromotionTypes.PromotionDTO>({
id: promotionId,
entityName: Promotion.name,
repository: this.promotionRepository_,
config,
sharedContext,
})) as TEntity
}
@InjectManager("promotionRepository_")
async list(
filters: PromotionTypes.FilterablePromotionProps = {},
config: FindConfig<PromotionTypes.PromotionDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
const queryOptions = ModulesSdkUtils.buildQuery<Promotion>(filters, config)
return (await this.promotionRepository_.find(
queryOptions,
sharedContext
)) as TEntity[]
}
@InjectManager("promotionRepository_")
async listAndCount(
filters: PromotionTypes.FilterablePromotionProps = {},
config: FindConfig<PromotionTypes.PromotionDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], number]> {
const queryOptions = ModulesSdkUtils.buildQuery<Promotion>(filters, config)
return (await this.promotionRepository_.findAndCount(
queryOptions,
sharedContext
)) as [TEntity[], number]
}
@InjectTransactionManager("promotionRepository_")
async create(
data: CreatePromotionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return (await (this.promotionRepository_ as PromotionRepository).create(
data,
sharedContext
)) as TEntity[]
}
@InjectTransactionManager("promotionRepository_")
async update(
data: UpdatePromotionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return (await (this.promotionRepository_ as PromotionRepository).update(
data,
sharedContext
)) as TEntity[]
}
@InjectTransactionManager("promotionRepository_")
async delete(
ids: string[],
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this.promotionRepository_.delete(ids, sharedContext)
}
}

View File

@@ -0,0 +1,19 @@
import {
ApplicationMethodAllocation,
ApplicationMethodTargetType,
ApplicationMethodType,
PromotionDTO,
} from "@medusajs/types"
export interface CreateApplicationMethodDTO {
type: ApplicationMethodType
target_type: ApplicationMethodTargetType
allocation?: ApplicationMethodAllocation
value?: number
promotion: PromotionDTO | string
max_quantity?: number
}
export interface UpdateApplicationMethodDTO {
id: string
}

View File

@@ -3,3 +3,6 @@ import { Logger } from "@medusajs/types"
export type InitializeModuleInjectableDependencies = {
logger?: Logger
}
export * from "./application-method"
export * from "./promotion"

View File

@@ -0,0 +1,11 @@
import { PromotionType } from "@medusajs/types"
export interface CreatePromotionDTO {
code: string
type: PromotionType
is_automatic?: boolean
}
export interface UpdatePromotionDTO {
id: string
}

View File

@@ -0,0 +1 @@
export * from "./validations"

View File

@@ -0,0 +1,51 @@
import {
ApplicationMethodAllocation,
ApplicationMethodTargetType,
MedusaError,
isDefined,
} from "@medusajs/utils"
import { CreateApplicationMethodDTO } from "../../types"
const allowedTargetTypes: string[] = [
ApplicationMethodTargetType.SHIPPING,
ApplicationMethodTargetType.ITEM,
]
const allowedAllocationTypes: string[] = [
ApplicationMethodAllocation.ACROSS,
ApplicationMethodAllocation.EACH,
]
const allowedAllocationForQuantity: string[] = [
ApplicationMethodAllocation.EACH,
]
export function validateApplicationMethodAttributes(
data: CreateApplicationMethodDTO
) {
if (
allowedTargetTypes.includes(data.target_type) &&
!allowedAllocationTypes.includes(data.allocation || "")
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method.allocation should be either '${allowedAllocationTypes.join(
" OR "
)}' when application_method.target_type is either '${allowedTargetTypes.join(
" OR "
)}'`
)
}
if (
allowedAllocationForQuantity.includes(data.allocation || "") &&
!isDefined(data.max_quantity)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`application_method.max_quantity is required when application_method.allocation is '${allowedAllocationForQuantity.join(
" OR "
)}'`
)
}
}

View File

@@ -0,0 +1 @@
export * from "./application-method"

View File

@@ -4,6 +4,7 @@
"target": "es2020",
"outDir": "./dist",
"esModuleInterop": true,
"declarationMap": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",

View File

@@ -0,0 +1,31 @@
import { BaseFilterable } from "../../dal"
import { PromotionDTO } from "./promotion"
export type ApplicationMethodType = "fixed" | "percentage"
export type ApplicationMethodTargetType = "order" | "shipping" | "item"
export type ApplicationMethodAllocation = "each" | "across"
export interface ApplicationMethodDTO {
id: string
}
export interface CreateApplicationMethodDTO {
type: ApplicationMethodType
target_type: ApplicationMethodTargetType
allocation?: ApplicationMethodAllocation
value?: number
max_quantity?: number
promotion?: PromotionDTO | string
}
export interface UpdateApplicationMethodDTO {
id: string
}
export interface FilterableApplicationMethodProps
extends BaseFilterable<FilterableApplicationMethodProps> {
id?: string[]
type?: ApplicationMethodType[]
target_type?: ApplicationMethodTargetType[]
allocation?: ApplicationMethodAllocation[]
}

View File

@@ -0,0 +1,2 @@
export * from "./application-method"
export * from "./promotion"

View File

@@ -0,0 +1,27 @@
import { BaseFilterable } from "../../dal"
import { CreateApplicationMethodDTO } from "./application-method"
export type PromotionType = "standard" | "buyget"
export interface PromotionDTO {
id: string
}
export interface CreatePromotionDTO {
code: string
type: PromotionType
is_automatic?: boolean
application_method?: CreateApplicationMethodDTO
}
export interface UpdatePromotionDTO {
id: string
}
export interface FilterablePromotionProps
extends BaseFilterable<FilterablePromotionProps> {
id?: string[]
code?: string[]
is_automatic?: boolean
type?: PromotionType[]
}

View File

@@ -1 +1,2 @@
export * from "./common"
export * from "./service"

View File

@@ -1,3 +1,21 @@
import { FindConfig } from "../common"
import { IModuleService } from "../modules-sdk"
import { Context } from "../shared-context"
import {
CreatePromotionDTO,
FilterablePromotionProps,
PromotionDTO,
} from "./common"
export interface IPromotionModuleService extends IModuleService {}
export interface IPromotionModuleService extends IModuleService {
create(
data: CreatePromotionDTO[],
sharedContext?: Context
): Promise<PromotionDTO[]>
list(
filters?: FilterablePromotionProps,
config?: FindConfig<PromotionDTO>,
sharedContext?: Context
): Promise<PromotionDTO[]>
}

View File

@@ -4,6 +4,7 @@
"target": "es2020",
"outDir": "./dist",
"esModuleInterop": true,
"declarationMap": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",

View File

@@ -4,6 +4,7 @@ export * as EventBusUtils from "./event-bus"
export * as FeatureFlagUtils from "./feature-flags"
export * as ModulesSdkUtils from "./modules-sdk"
export * as ProductUtils from "./product"
export * as PromotionUtils from "./promotion"
export * as SearchUtils from "./search"
export * as ShippingProfileUtils from "./shipping"
export * as OrchestrationUtils from "./orchestration"

View File

@@ -7,6 +7,7 @@ export * from "./feature-flags"
export * from "./modules-sdk"
export * from "./pricing"
export * from "./product"
export * from "./promotion"
export * from "./search"
export * from "./shipping"
export * from "./orchestration"

View File

@@ -0,0 +1,20 @@
export enum PromotionType {
STANDARD = "standard",
BUYGET = "buyget",
}
export enum ApplicationMethodType {
FIXED = "fixed",
PERCENTAGE = "percentage",
}
export enum ApplicationMethodTargetType {
ORDER = "order",
SHIPPING = "shipping",
ITEM = "item",
}
export enum ApplicationMethodAllocation {
EACH = "each",
ACROSS = "across",
}