Files
medusa-store/packages/modules/region/src/services/region-module.ts
Adrien de Peretti e8822f3e69 chore(): Module Internal Events (#13296)
* chore(): Ensure the product module emits all necessary events

* chore(): Ensure the product module emits all necessary events

* Update events tests

* more events and fixes

* more tests and category fixes

* more tests and category fixes

* Add todo

* update updateProduct_ event emitting and adjust test

* Adjust update products implementation to rely on already computed events

* rm unnecessary update variants events

* Fix formatting in changeset for product events

* refactor: Manage event emitting automatically (WIP)

* refactor: Manage event emitting automatically (WIP)

* chore(api-key): Add missing emit events and refactoring

* chore(cart): Add missing emit events and refactoring

* chore(customer): Add missing emit events and refactoring

* chore(fufillment, utils): Add missing emit events and refactoring

* chore(fufillment, utils): Add missing emit events and refactoring

* chore(inventory): Add missing emit events and refactoring

* chore(notification): Add missing emit events and refactoring

* chore(utils): Remove medusa service event handling legacy

* chore(product): Add missing emit events and refactoring

* chore(order): Add missing emit events and refactoring

* chore(payment): Add missing emit events and refactoring

* chore(pricing, util): Add missing emit events and refactoring, fix internal service upsertWithReplace event dispatching

* chore(promotions): Add missing emit events and refactoring

* chore(region): Add missing emit events and refactoring

* chore(sales-channel): Add missing emit events and refactoring

* chore(settings): Add missing emit events and refactoring

* chore(stock-location): Add missing emit events and refactoring

* chore(store): Add missing emit events and refactoring

* chore(taxes): Add missing emit events and refactoring

* chore(user): Add missing emit events and refactoring

* fix unit tests

* rm changeset for regeneration

* Create changeset for Medusa.js patch updates

Add a changeset for patch updates to multiple Medusa.js modules.

* rm unused product event builders

* address feedback

* remove old changeset

* fix event action for token generated

* fix user module events

* fix import

* fix promotion events

* add new module integration tests shard

* fix medusa service

* revert shard

* fix event action

* fix pipeline

* fix pipeline

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
2025-09-10 14:37:38 +02:00

379 lines
10 KiB
TypeScript

import {
Context,
CreateRegionDTO,
DAL,
FilterableRegionProps,
InferEntityType,
InternalModuleDeclaration,
IRegionModuleService,
ModulesSdkTypes,
RegionCountryDTO,
RegionDTO,
SoftDeleteReturn,
UpdateRegionDTO,
UpsertRegionDTO,
} from "@medusajs/framework/types"
import {
arrayDifference,
EmitEvents,
getDuplicates,
InjectManager,
InjectTransactionManager,
isString,
MedusaContext,
MedusaError,
MedusaService,
promiseAll,
removeUndefined,
} from "@medusajs/framework/utils"
import { Country, Region } from "@models"
import { UpdateRegionInput } from "@types"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
regionService: ModulesSdkTypes.IMedusaInternalService<any>
countryService: ModulesSdkTypes.IMedusaInternalService<any>
}
export default class RegionModuleService
extends MedusaService<{
Region: {
dto: RegionDTO
model: typeof Region
}
Country: {
dto: RegionCountryDTO
model: typeof Country
}
}>({ Region, Country })
implements IRegionModuleService
{
protected baseRepository_: DAL.RepositoryService
protected readonly regionService_: ModulesSdkTypes.IMedusaInternalService<
typeof Region
>
protected readonly countryService_: ModulesSdkTypes.IMedusaInternalService<
typeof Country
>
constructor(
{ baseRepository, regionService, countryService }: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
// @ts-ignore
super(...arguments)
this.baseRepository_ = baseRepository
this.regionService_ = regionService
this.countryService_ = countryService
}
// @ts-expect-error
async createRegions(
data: CreateRegionDTO[],
sharedContext?: Context
): Promise<RegionDTO[]>
// @ts-expect-error
async createRegions(
data: CreateRegionDTO,
sharedContext?: Context
): Promise<RegionDTO>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async createRegions(
data: CreateRegionDTO | CreateRegionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<RegionDTO | RegionDTO[]> {
const input = Array.isArray(data) ? data : [data]
const result = await this.createRegions_(input, sharedContext)
return await this.baseRepository_.serialize<RegionDTO[]>(
Array.isArray(data) ? result : result[0]
)
}
@InjectTransactionManager()
async createRegions_(
data: CreateRegionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof Region>[]> {
let normalizedInput = RegionModuleService.normalizeInput(data)
let normalizedDbRegions = normalizedInput.map((region) =>
removeUndefined({
...region,
countries: undefined,
})
)
const result = await this.regionService_.create(
normalizedDbRegions,
sharedContext
)
if (data.some((input) => input.countries?.length)) {
await this.validateCountries(
normalizedInput.map((r) => r.countries ?? []).flat(),
sharedContext
)
await this.countryService_.update(
normalizedInput.map((region, i) => ({
selector: { iso_2: region.countries },
data: {
region_id: result[i].id,
},
})),
sharedContext
)
}
return result
}
@InjectManager()
@EmitEvents()
// @ts-expect-error
async softDeleteRegions(
ids: string | object | string[] | object[],
config?: SoftDeleteReturn<string>,
@MedusaContext() sharedContext: Context = {}
): Promise<Record<string, string[]> | void> {
const result = await super.softDeleteRegions(ids, config, sharedContext)
// Note: You cannot revert the state of a region by simply restoring it. The association with countries is lost.
await super.updateCountries(
{
selector: { region_id: ids },
data: { region_id: null } as any,
},
sharedContext
)
return result
}
async upsertRegions(
data: UpsertRegionDTO[],
sharedContext?: Context
): Promise<RegionDTO[]>
async upsertRegions(
data: UpsertRegionDTO,
sharedContext?: Context
): Promise<RegionDTO>
@InjectManager()
@EmitEvents()
async upsertRegions(
data: UpsertRegionDTO | UpsertRegionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<RegionDTO | RegionDTO[]> {
const result = await this.upsertRegions_(data, sharedContext)
return await this.baseRepository_.serialize<RegionDTO[] | RegionDTO>(
Array.isArray(data) ? result : result[0]
)
}
@InjectTransactionManager()
protected async upsertRegions_(
data: UpsertRegionDTO | UpsertRegionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof Region>[]> {
const input = Array.isArray(data) ? data : [data]
const forUpdate = input.filter(
(region): region is UpdateRegionInput => !!region.id
)
const forCreate = input.filter(
(region): region is CreateRegionDTO => !region.id
)
const operations: Promise<InferEntityType<typeof Region>[]>[] = []
if (forCreate.length) {
operations.push(this.createRegions_(forCreate, sharedContext))
}
if (forUpdate.length) {
operations.push(this.updateRegions_(forUpdate, sharedContext))
}
const result = (await promiseAll(operations)).flat()
return result
}
// @ts-expect-error
async updateRegions(
id: string,
data: UpdateRegionDTO,
sharedContext?: Context
): Promise<RegionDTO>
// @ts-expect-error
async updateRegions(
selector: FilterableRegionProps,
data: UpdateRegionDTO,
sharedContext?: Context
): Promise<RegionDTO[]>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async updateRegions(
idOrSelector: string | FilterableRegionProps,
data: UpdateRegionDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<RegionDTO | RegionDTO[]> {
let normalizedInput: UpdateRegionInput[] = []
if (isString(idOrSelector)) {
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const regions = await this.regionService_.list(
idOrSelector,
{},
sharedContext
)
normalizedInput = regions.map((region) => ({
id: region.id,
...data,
}))
}
const updateResult = await this.updateRegions_(
normalizedInput,
sharedContext
)
const regions = await this.baseRepository_.serialize<
RegionDTO[] | RegionDTO
>(updateResult)
return isString(idOrSelector) ? regions[0] : regions
}
@InjectTransactionManager()
protected async updateRegions_(
data: UpdateRegionInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof Region>[]> {
const normalizedInput = RegionModuleService.normalizeInput(data)
// If countries are being updated for a region, first make previously set countries' region to null to get to a clean slate.
// Somewhat less efficient, but region operations will be very rare, so it is better to go with a simple solution
const regionsWithCountryUpdate = normalizedInput
.filter((region) => !!region.countries)
.map((region) => region.id)
.flat()
let normalizedDbRegions = normalizedInput.map((region) =>
removeUndefined({
...region,
countries: undefined, // -> delete countries if passed because we want to do update "manually"
})
)
if (regionsWithCountryUpdate.length) {
await this.countryService_.update(
{
selector: {
region_id: regionsWithCountryUpdate,
},
data: { region_id: null },
},
sharedContext
)
await this.validateCountries(
normalizedInput.map((d) => d.countries ?? []).flat(),
sharedContext
)
await this.countryService_.update(
normalizedInput.map((region) => ({
selector: { iso_2: region.countries },
data: {
region_id: region.id,
},
})),
sharedContext
)
}
return await this.regionService_.update(normalizedDbRegions, sharedContext)
}
private static normalizeInput<T extends UpdateRegionDTO>(regions: T[]): T[] {
return regions.map((region) =>
removeUndefined({
...region,
currency_code: region.currency_code?.toLowerCase(),
name: region.name?.trim(),
countries: region.countries?.map((country) => country.toLowerCase()),
})
)
}
/**
* Validate that countries can be assigned to a region.
*
* NOTE: this method relies on countries of the regions that we are assigning to need to be unassigned first.
* @param countries
* @param sharedContext
* @private
*/
private async validateCountries(
countries: string[] | undefined,
sharedContext: Context
): Promise<InferEntityType<typeof Country>[]> {
if (!countries?.length) {
return []
}
// The new regions being created have a country conflict
const uniqueCountries = Array.from(new Set(countries))
if (uniqueCountries.length !== countries.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Countries with codes: "${getDuplicates(countries).join(
", "
)}" are already assigned to a region`
)
}
const countriesInDb = await this.countryService_.list(
{ iso_2: uniqueCountries },
{ select: ["iso_2", "region_id"] },
sharedContext
)
const countryCodesInDb = countriesInDb.map((c) => c.iso_2.toLowerCase())
// Countries missing in the database
if (countriesInDb.length !== uniqueCountries.length) {
const missingCountries = arrayDifference(
uniqueCountries,
countryCodesInDb
)
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Countries with codes: "${missingCountries.join(", ")}" do not exist`
)
}
// Countries that already have a region already assigned to them
// @ts-ignore
const countriesWithRegion = countriesInDb.filter((c) => !!c.region_id)
if (countriesWithRegion.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Countries with codes: "${countriesWithRegion
.map((c) => c.iso_2)
.join(", ")}" are already assigned to a region`
)
}
return countriesInDb
}
}