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
This commit is contained in:
Nicolas Gorga
2026-01-14 07:09:49 -03:00
committed by GitHub
parent 42235825ee
commit d60ea7268a
50 changed files with 1397 additions and 199 deletions

View File

@@ -1,12 +1,11 @@
import "./types"
import { Module } from "@medusajs/framework/utils"
import TranslationModuleService from "@services/translation-module"
import loadConfig from "./loaders/config"
import loadDefaults from "./loaders/defaults"
export const TRANSLATION_MODULE = "translation"
export default Module(TRANSLATION_MODULE, {
service: TranslationModuleService,
loaders: [loadDefaults, loadConfig],
loaders: [loadDefaults],
})

View File

@@ -1,60 +0,0 @@
import {
LoaderOptions,
Logger,
ModulesSdkTypes,
} from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
import { asValue } from "awilix"
import { translatableFieldsConfig } from "../utils/translatable-fields"
import Settings from "@models/settings"
import type { TranslationModuleOptions } from "../types"
export default async ({
container,
options,
}: LoaderOptions<TranslationModuleOptions>): Promise<void> => {
const logger =
container.resolve<Logger>(ContainerRegistrationKeys.LOGGER) ?? console
const settingsService: ModulesSdkTypes.IMedusaInternalService<
typeof Settings
> = container.resolve("translationSettingsService")
const mergedConfig: Record<string, string[]> = translatableFieldsConfig
const userProvidedFields = options?.entities ?? []
for (const field of userProvidedFields) {
mergedConfig[field.type] ??= []
mergedConfig[field.type] = Array.from(
new Set([...(mergedConfig[field.type] ?? []), ...field.fields])
)
}
try {
const existingSettings = await settingsService.list(
{},
{ select: ["id", "entity_type"] }
)
const existingByEntityType = new Map(
existingSettings.map((s) => [s.entity_type, s.id])
)
const settingsToUpsert = Object.entries(mergedConfig).map(
([entityType, fields]) => {
const existingId = existingByEntityType.get(entityType)
return existingId
? { id: existingId, entity_type: entityType, fields }
: { entity_type: entityType, fields }
}
)
const resp = await settingsService.upsert(settingsToUpsert)
logger.debug(`Loaded ${resp.length} translation settings`)
} catch (error) {
logger.warn(
`Failed to load translation settings, skipping loader. Original error: ${error.message}`
)
}
container.register(TRANSLATABLE_FIELDS_CONFIG_KEY, asValue(mergedConfig))
}

View File

@@ -293,6 +293,16 @@
"nullable": false,
"mappedType": "json"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "true",
"mappedType": "boolean"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",

View File

@@ -0,0 +1,13 @@
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
export class Migration20260108122757 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table if exists "translation_settings" add column if not exists "is_active" boolean not null default true;`);
}
override async down(): Promise<void> {
this.addSql(`alter table if exists "translation_settings" drop column if exists "is_active";`);
}
}

View File

@@ -18,6 +18,10 @@ const Settings = model
* ["title", "description", "material"]
*/
fields: model.json(),
/**
* Wether the entity translatable status is enabled.
*/
is_active: model.boolean().default(true),
})
.indexes([
{

View File

@@ -2,6 +2,7 @@ import { raw } from "@medusajs/framework/mikro-orm/core"
import {
Context,
CreateTranslationDTO,
CreateTranslationSettingsDTO,
DAL,
FilterableTranslationProps,
FindConfig,
@@ -9,21 +10,25 @@ import {
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 { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
import { filterTranslationFields } from "@utils/filter-translation-fields"
type InjectedDependencies = {
@@ -33,7 +38,6 @@ type InjectedDependencies = {
translationSettingsService: ModulesSdkTypes.IMedusaInternalService<
typeof Settings
>
[TRANSLATABLE_FIELDS_CONFIG_KEY]: Record<string, string[]>
}
export default class TranslationModuleService
@@ -78,6 +82,55 @@ export default class TranslationModuleService
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,
@@ -90,7 +143,8 @@ export default class TranslationModuleService
sharedContext
)
return settings.reduce((acc, setting) => {
acc[setting.entity_type] = setting.fields as unknown as string[]
acc[toSnakeCase(setting.entity_type)] =
setting.fields as unknown as string[]
return acc
}, {} as Record<string, string[]>)
}
@@ -377,6 +431,42 @@ export default class TranslationModuleService
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,
@@ -492,4 +582,79 @@ export default class TranslationModuleService
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")
)
}
}
}

View File

@@ -1 +0,0 @@
export const TRANSLATABLE_FIELDS_CONFIG_KEY = "translatableFieldsConfig"

View File

@@ -1,41 +0,0 @@
export const PRODUCT_TRANSLATABLE_FIELDS = [
"title",
"description",
"material",
"subtitle",
]
export const PRODUCT_VARIANT_TRANSLATABLE_FIELDS = ["title", "material"]
export const PRODUCT_TYPE_TRANSLATABLE_FIELDS = ["value"]
export const PRODUCT_COLLECTION_TRANSLATABLE_FIELDS = ["title"]
export const PRODUCT_CATEGORY_TRANSLATABLE_FIELDS = ["name", "description"]
export const PRODUCT_TAG_TRANSLATABLE_FIELDS = ["value"]
export const PRODUCT_OPTION_TRANSLATABLE_FIELDS = ["title"]
export const PRODUCT_OPTION_VALUE_TRANSLATABLE_FIELDS = ["value"]
export const REGION_TRANSLATABLE_FIELDS = ["name"]
export const CUSTOMER_GROUP_TRANSLATABLE_FIELDS = ["name"]
export const SHIPPING_OPTION_TRANSLATABLE_FIELDS = ["name"]
export const SHIPPING_OPTION_TYPE_TRANSLATABLE_FIELDS = ["label", "description"]
export const TAX_RATE_TRANSLATABLE_FIELDS = ["name"]
// export const RETURN_REASON_TRANSLATABLE_FIELDS = [
// "value",
// "label",
// "description",
// ]
export const translatableFieldsConfig = {
product: PRODUCT_TRANSLATABLE_FIELDS,
product_variant: PRODUCT_VARIANT_TRANSLATABLE_FIELDS,
product_type: PRODUCT_TYPE_TRANSLATABLE_FIELDS,
product_collection: PRODUCT_COLLECTION_TRANSLATABLE_FIELDS,
product_category: PRODUCT_CATEGORY_TRANSLATABLE_FIELDS,
product_tag: PRODUCT_TAG_TRANSLATABLE_FIELDS,
product_option: PRODUCT_OPTION_TRANSLATABLE_FIELDS,
product_option_value: PRODUCT_OPTION_VALUE_TRANSLATABLE_FIELDS,
region: REGION_TRANSLATABLE_FIELDS,
customer_group: CUSTOMER_GROUP_TRANSLATABLE_FIELDS,
shipping_option: SHIPPING_OPTION_TRANSLATABLE_FIELDS,
shipping_option_type: SHIPPING_OPTION_TYPE_TRANSLATABLE_FIELDS,
tax_rate: TAX_RATE_TRANSLATABLE_FIELDS,
// return_reason: RETURN_REASON_TRANSLATABLE_FIELDS,
}