Files
medusa-store/packages/modules/translation/src/services/translation-module.ts
T
Nicolas Gorga d60ea7268a feat(translation,fulfillment,customer,product,region,tax,core-flows,medusa,types): Implement dynamic translation settings management (#14536)
* Add is_active field to translation_settings model

* Types

* Workflows

* Api layer

* Tests

* Add changeset

* Add comment

* Hook to create or deactivate translatable entities on startup

* Cleanup old code

* Configure translatable option for core entities

* Validation step and snake case correction

* Cleanup

* Tests

* Comment in PR

* Update changeset

* Mock DmlEntity.getTranslatableEntities

* Move validation to module service layer

* Remove validation from remaining workflow

* Return object directly

* Type improvements

* Remove .only from tests

* Apply snakeCase

* Fix tests

* Fix tests

* Remove unnecessary map and use set instead

* Fix tests

* Comments

* Include translatable product properties

* Avoid race condition in translations tests

* Update test
2026-01-14 07:09:49 -03:00

661 lines
19 KiB
TypeScript

import { raw } from "@medusajs/framework/mikro-orm/core"
import {
Context,
CreateTranslationDTO,
CreateTranslationSettingsDTO,
DAL,
FilterableTranslationProps,
FindConfig,
ITranslationModuleService,
LocaleDTO,
ModulesSdkTypes,
TranslationTypes,
UpdateTranslationSettingsDTO,
} from "@medusajs/framework/types"
import { SqlEntityManager } from "@medusajs/framework/mikro-orm/postgresql"
import {
arrayDifference,
DmlEntity,
EmitEvents,
InjectManager,
MedusaContext,
MedusaError,
MedusaErrorTypes,
MedusaService,
normalizeLocale,
toSnakeCase,
} from "@medusajs/framework/utils"
import Locale from "@models/locale"
import Translation from "@models/translation"
import Settings from "@models/settings"
import { computeTranslatedFieldCount } from "@utils/compute-translated-field-count"
import { filterTranslationFields } from "@utils/filter-translation-fields"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
translationService: ModulesSdkTypes.IMedusaInternalService<typeof Translation>
localeService: ModulesSdkTypes.IMedusaInternalService<typeof Locale>
translationSettingsService: ModulesSdkTypes.IMedusaInternalService<
typeof Settings
>
}
export default class TranslationModuleService
extends MedusaService<{
Locale: {
dto: TranslationTypes.LocaleDTO
}
Translation: {
dto: TranslationTypes.TranslationDTO
}
TranslationSettings: {
dto: TranslationTypes.TranslationSettingsDTO
}
}>({
Locale,
Translation,
TranslationSettings: Settings,
})
implements ITranslationModuleService
{
protected baseRepository_: DAL.RepositoryService
protected translationService_: ModulesSdkTypes.IMedusaInternalService<
typeof Translation
>
protected localeService_: ModulesSdkTypes.IMedusaInternalService<
typeof Locale
>
protected settingsService_: ModulesSdkTypes.IMedusaInternalService<
typeof Settings
>
constructor({
baseRepository,
translationService,
localeService,
translationSettingsService,
}: InjectedDependencies) {
super(...arguments)
this.baseRepository_ = baseRepository
this.translationService_ = translationService
this.localeService_ = localeService
this.settingsService_ = translationSettingsService
}
__hooks = {
onApplicationStart: async () => {
return this.onApplicationStart_()
},
}
protected async onApplicationStart_() {
const translatableEntities = DmlEntity.getTranslatableEntities()
const translatableEntitiesSet = new Set(
translatableEntities.map((entity) => toSnakeCase(entity.entity))
)
const currentTranslationSettings = await this.settingsService_.list()
const currentTranslationSettingsSet = new Set(
currentTranslationSettings.map((setting) => setting.entity_type)
)
const settingsToUpsert: (
| CreateTranslationSettingsDTO
| UpdateTranslationSettingsDTO
)[] = []
for (const setting of currentTranslationSettings) {
if (
!translatableEntitiesSet.has(setting.entity_type) &&
setting.is_active
) {
settingsToUpsert.push({
id: setting.id,
is_active: false,
})
}
}
for (const entity of translatableEntities) {
const snakeCaseEntityType = toSnakeCase(entity.entity)
const hasCurrentSettings =
currentTranslationSettingsSet.has(snakeCaseEntityType)
if (!hasCurrentSettings) {
settingsToUpsert.push({
entity_type: snakeCaseEntityType,
fields: entity.fields,
})
}
}
await this.settingsService_.upsert(settingsToUpsert)
}
@InjectManager()
async getTranslatableFields(
entityType?: string,
@MedusaContext() sharedContext: Context = {}
): Promise<Record<string, string[]>> {
const filters = entityType ? { entity_type: entityType } : {}
const settings = await this.settingsService_.list(
filters,
{},
sharedContext
)
return settings.reduce((acc, setting) => {
acc[toSnakeCase(setting.entity_type)] =
setting.fields as unknown as string[]
return acc
}, {} as Record<string, string[]>)
}
static prepareFilters(
filters: FilterableTranslationProps
): FilterableTranslationProps {
let { q, ...restFilters } = filters
if (q) {
restFilters = {
...restFilters,
[raw(`translations::text ILIKE ?`, [`%${q}%`])]: [],
}
}
return restFilters
}
@InjectManager()
// @ts-expect-error
async retrieveTranslation(
id: string,
config: FindConfig<TranslationTypes.TranslationDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TranslationTypes.TranslationDTO> {
const configWithReference =
TranslationModuleService.ensureReferenceFieldInConfig(config)
const result = await this.translationService_.retrieve(
id,
configWithReference,
sharedContext
)
const serialized =
await this.baseRepository_.serialize<TranslationTypes.TranslationDTO>(
result
)
const translatableFieldsConfig = await this.getTranslatableFields(
undefined,
sharedContext
)
return filterTranslationFields([serialized], translatableFieldsConfig)[0]
}
/**
* Ensures the 'reference' field is included in the select config.
* This is needed for filtering translations by translatable fields.
*/
static ensureReferenceFieldInConfig(
config: FindConfig<TranslationTypes.TranslationDTO>
): FindConfig<TranslationTypes.TranslationDTO> {
if (!config?.select?.length) {
return config
}
const select = config.select as string[]
if (!select.includes("reference")) {
return { ...config, select: [...select, "reference"] }
}
return config
}
@InjectManager()
// @ts-expect-error
async listTranslations(
filters: FilterableTranslationProps = {},
config: FindConfig<TranslationTypes.TranslationDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TranslationTypes.TranslationDTO[]> {
const preparedFilters = TranslationModuleService.prepareFilters(filters)
const configWithReference =
TranslationModuleService.ensureReferenceFieldInConfig(config)
const results = await this.translationService_.list(
preparedFilters,
configWithReference,
sharedContext
)
const serialized = await this.baseRepository_.serialize<
TranslationTypes.TranslationDTO[]
>(results)
const translatableFieldsConfig = await this.getTranslatableFields(
undefined,
sharedContext
)
return filterTranslationFields(serialized, translatableFieldsConfig)
}
@InjectManager()
// @ts-expect-error
async listAndCountTranslations(
filters: FilterableTranslationProps = {},
config: FindConfig<TranslationTypes.TranslationDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[TranslationTypes.TranslationDTO[], number]> {
const preparedFilters = TranslationModuleService.prepareFilters(filters)
const configWithReference =
TranslationModuleService.ensureReferenceFieldInConfig(config)
const [results, count] = await this.translationService_.listAndCount(
preparedFilters,
configWithReference,
sharedContext
)
const serialized = await this.baseRepository_.serialize<
TranslationTypes.TranslationDTO[]
>(results)
const translatableFieldsConfig = await this.getTranslatableFields(
undefined,
sharedContext
)
return [
filterTranslationFields(serialized, translatableFieldsConfig),
count,
]
}
// @ts-expect-error
createLocales(
data: TranslationTypes.CreateLocaleDTO[],
sharedContext?: Context
): Promise<TranslationTypes.LocaleDTO[]>
// @ts-expect-error
createLocales(
data: TranslationTypes.CreateLocaleDTO,
sharedContext?: Context
): Promise<TranslationTypes.LocaleDTO>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async createLocales(
data: TranslationTypes.CreateLocaleDTO | TranslationTypes.CreateLocaleDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TranslationTypes.LocaleDTO | TranslationTypes.LocaleDTO[]> {
const dataArray = Array.isArray(data) ? data : [data]
const normalizedData = dataArray.map((locale) => ({
...locale,
code: normalizeLocale(locale.code),
}))
const createdLocales = await this.localeService_.create(
normalizedData,
sharedContext
)
const serialized = await this.baseRepository_.serialize<LocaleDTO[]>(
createdLocales
)
return Array.isArray(data) ? serialized : serialized[0]
}
// @ts-expect-error
createTranslations(
data: CreateTranslationDTO,
sharedContext?: Context
): Promise<TranslationTypes.TranslationDTO>
// @ts-expect-error
createTranslations(
data: CreateTranslationDTO[],
sharedContext?: Context
): Promise<TranslationTypes.TranslationDTO[]>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async createTranslations(
data: CreateTranslationDTO | CreateTranslationDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<
TranslationTypes.TranslationDTO | TranslationTypes.TranslationDTO[]
> {
const dataArray = Array.isArray(data) ? data : [data]
const translatableFieldsConfig = await this.getTranslatableFields(
undefined,
sharedContext
)
const normalizedData = dataArray.map((translation) => ({
...translation,
locale_code: normalizeLocale(translation.locale_code),
translated_field_count: computeTranslatedFieldCount(
translation.translations as Record<string, unknown>,
translatableFieldsConfig[translation.reference]
),
}))
const createdTranslations = await this.translationService_.create(
normalizedData,
sharedContext
)
const serialized = await this.baseRepository_.serialize<
TranslationTypes.TranslationDTO[]
>(createdTranslations)
return Array.isArray(data) ? serialized : serialized[0]
}
// @ts-expect-error
updateTranslations(
data: TranslationTypes.UpdateTranslationDTO,
sharedContext?: Context
): Promise<TranslationTypes.TranslationDTO>
// @ts-expect-error
updateTranslations(
data: TranslationTypes.UpdateTranslationDTO[],
sharedContext?: Context
): Promise<TranslationTypes.TranslationDTO[]>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async updateTranslations(
data:
| TranslationTypes.UpdateTranslationDTO
| TranslationTypes.UpdateTranslationDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<
TranslationTypes.TranslationDTO | TranslationTypes.TranslationDTO[]
> {
const dataArray = Array.isArray(data) ? data : [data]
const updatesWithTranslations = dataArray.filter((d) => d.translations)
if (updatesWithTranslations.length) {
const idsNeedingReference = updatesWithTranslations
.filter((d) => !d.reference)
.map((d) => d.id)
let referenceMap: Record<string, string> = {}
if (idsNeedingReference.length) {
const existingTranslations = await this.translationService_.list(
{ id: idsNeedingReference },
{ select: ["id", "reference"] },
sharedContext
)
referenceMap = Object.fromEntries(
existingTranslations.map((t) => [t.id, t.reference])
)
}
const translatableFieldsConfig = await this.getTranslatableFields(
undefined,
sharedContext
)
for (const update of dataArray) {
if (update.translations) {
const reference = update.reference || referenceMap[update.id]
;(
update as TranslationTypes.UpdateTranslationDTO & {
translated_field_count: number
}
).translated_field_count = computeTranslatedFieldCount(
update.translations as Record<string, unknown>,
translatableFieldsConfig[reference] || []
)
}
}
}
const updatedTranslations = await this.translationService_.update(
dataArray,
sharedContext
)
const serialized = await this.baseRepository_.serialize<
TranslationTypes.TranslationDTO[]
>(updatedTranslations)
return Array.isArray(data) ? serialized : serialized[0]
}
@InjectManager()
@EmitEvents()
// @ts-expect-error
async createTranslationSettings(
data: CreateTranslationSettingsDTO[] | CreateTranslationSettingsDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
| TranslationTypes.TranslationSettingsDTO
| TranslationTypes.TranslationSettingsDTO[]
> {
const dataArray = Array.isArray(data) ? data : [data]
await this.validateSettings_(dataArray, sharedContext)
// @ts-expect-error TS can't match union type to overloads
return await super.createTranslationSettings(data, sharedContext)
}
@InjectManager()
@EmitEvents()
// @ts-expect-error
async updateTranslationSettings(
data: UpdateTranslationSettingsDTO | UpdateTranslationSettingsDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<
| TranslationTypes.TranslationSettingsDTO[]
| TranslationTypes.TranslationSettingsDTO
> {
const dataArray = Array.isArray(data) ? data : [data]
await this.validateSettings_(dataArray, sharedContext)
// @ts-expect-error TS can't match union type to overloads
return await super.updateTranslationSettings(data, sharedContext)
}
@InjectManager()
async getStatistics(
input: TranslationTypes.TranslationStatisticsInput,
@MedusaContext() sharedContext: Context = {}
): Promise<TranslationTypes.TranslationStatisticsOutput> {
const { locales, entities } = input
if (!locales || !locales.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"At least one locale must be provided"
)
}
if (!entities || !Object.keys(entities).length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"At least one entity type must be provided"
)
}
const normalizedLocales = locales.map(normalizeLocale)
const manager = (sharedContext.transactionManager ??
sharedContext.manager) as SqlEntityManager
const knex = manager.getKnex()
const translatableFieldsConfig = await this.getTranslatableFields(
undefined,
sharedContext
)
const result: TranslationTypes.TranslationStatisticsOutput = {}
const entityTypes: string[] = []
for (const entityType of Object.keys(entities)) {
const translatableFields = translatableFieldsConfig[entityType]
if (!translatableFields || translatableFields.length === 0) {
result[entityType] = {
expected: 0,
translated: 0,
missing: 0,
by_locale: Object.fromEntries(
normalizedLocales.map((locale) => [
locale,
{ expected: 0, translated: 0, missing: 0 },
])
),
}
} else {
entityTypes.push(entityType)
}
}
if (!entityTypes.length) {
return result
}
const { rows } = await knex.raw(
`
SELECT
reference,
locale_code,
COALESCE(SUM(translated_field_count), 0)::int AS translated_field_count
FROM translation
WHERE reference = ANY(?)
AND locale_code = ANY(?)
AND deleted_at IS NULL
GROUP BY reference, locale_code
`,
[entityTypes, normalizedLocales]
)
for (const entityType of entityTypes) {
const translatableFields = translatableFieldsConfig[entityType]
const fieldsPerEntity = translatableFields.length
const entityCount = entities[entityType].count
const expectedPerLocale = entityCount * fieldsPerEntity
result[entityType] = {
expected: expectedPerLocale * normalizedLocales.length,
translated: 0,
missing: expectedPerLocale * normalizedLocales.length,
by_locale: Object.fromEntries(
normalizedLocales.map((locale) => [
locale,
{
expected: expectedPerLocale,
translated: 0,
missing: expectedPerLocale,
},
])
),
}
}
for (const row of rows) {
const entityType = row.reference
const localeCode = row.locale_code
const translatedCount = parseInt(row.translated_field_count, 10) || 0
result[entityType].by_locale[localeCode].translated = translatedCount
result[entityType].by_locale[localeCode].missing =
result[entityType].by_locale[localeCode].expected - translatedCount
result[entityType].translated += translatedCount
}
for (const entityType of entityTypes) {
result[entityType].missing =
result[entityType].expected - result[entityType].translated
}
return result
}
/**
* Validates the translation settings to create or update against the translatable entities and their translatable fields configuration.
* @param dataToValidate - The data to validate.
* @param sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
*/
@InjectManager()
protected async validateSettings_(
dataToValidate: (
| CreateTranslationSettingsDTO
| UpdateTranslationSettingsDTO
)[],
@MedusaContext() sharedContext: Context = {}
) {
const translatableEntities = DmlEntity.getTranslatableEntities()
const translatableEntitiesMap = new Map(
translatableEntities.map((entity) => [toSnakeCase(entity.entity), entity])
)
const invalidSettings: {
entity_type: string
is_invalid_entity: boolean
invalidFields?: string[]
}[] = []
for (const item of dataToValidate) {
let itemEntityType = item.entity_type
if (!itemEntityType) {
const translationSetting = await this.retrieveTranslationSettings(
//@ts-expect-error - if no entity_type, we are on an update
item.id,
{ select: ["entity_type"] },
sharedContext
)
itemEntityType = translationSetting.entity_type
}
const entity = translatableEntitiesMap.get(itemEntityType)
if (!entity) {
invalidSettings.push({
entity_type: itemEntityType,
is_invalid_entity: true,
})
} else {
const invalidFields = arrayDifference(item.fields ?? [], entity.fields)
if (invalidFields.length) {
invalidSettings.push({
entity_type: itemEntityType,
is_invalid_entity: false,
invalidFields,
})
}
}
}
if (invalidSettings.length) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
"Invalid translation settings:\n" +
invalidSettings
.map(
(setting) =>
`- ${setting.entity_type} ${
setting.is_invalid_entity
? "is not a translatable entity"
: `doesn't have the following fields set as translatable: ${setting.invalidFields?.join(
", "
)}`
}`
)
.join("\n")
)
}
}
}