feat(): Introduce translation module and preliminary application of them (#14189)

* feat(): Translation first steps

* feat(): locale middleware

* feat(): readonly links

* feat(): feature flag

* feat(): modules sdk

* feat(): translation module re export

* start adding workflows

* update typings

* update typings

* test(): Add integration tests

* test(): centralize filters preparation

* test(): centralize filters preparation

* remove unnecessary importy

* fix workflows

* Define StoreLocale inside Store Module

* Link definition to extend Store with supported_locales

* store_locale migration

* Add supported_locales handling in Store Module

* Tests

* Accept supported_locales in Store endpoints

* Add locales to js-sdk

* Include locale list and default locale in Store Detail section

* Initialize local namespace in js-sdk

* Add locales route

* Make code primary key of locale table to facilitate upserts

* Add locales routes

* Show locale code as is

* Add list translations api route

* Batch endpoint

* Types

* New batchTranslationsWorkflow and various updates to existent ones

* Edit default locale UI

* WIP

* Apply translation agnostically

* middleware

* Apply translation agnostically

* fix Apply translation agnostically

* apply translations to product list

* Add feature flag

* fetch translations by batches of 250 max

* fix apply

* improve and test util

* apply to product list

* dont manage translations if no locale

* normalize locale

* potential todo

* Protect translations routes with feature flag

* Extract normalize locale util to core/utils

* Normalize locale on write

* Normalize locale for read

* Use feature flag to guard translations UI across the board

* Avoid throwing incorrectly when locale_code not present in partial updates

* move applyTranslations util

* remove old tests

* fix util tests

* fix(): product end points

* cleanup

* update lock

* remove unused var

* cleanup

* fix apply locale

* missing new dep for test utils

* Change entity_type, entity_id to reference, reference_id

* Remove comment

* Avoid registering translations route if ff not enabled

* Prevent registering express handler for disabled route via defineFileConfig

* Add tests

* Add changeset

* Update test

* fix integration tests, module and internals

* Add locale id plus fixed

* Allow to pass array of reference_id

* fix unit tests

* fix link loading

* fix store route

* fix sales channel test

* fix tests

---------

Co-authored-by: Nicolas Gorga <nicogorga11@gmail.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2025-12-08 19:33:08 +01:00
committed by GitHub
parent fea3d4ec49
commit 6dc0b8bed8
130 changed files with 5649 additions and 112 deletions

View File

@@ -9,4 +9,6 @@ export * from "./order-customer"
export * from "./order-product"
export * from "./order-region"
export * from "./order-sales-channel"
export * from "./product-translation"
export * from "./store-currency"
export * from "./store-locale"

View File

@@ -0,0 +1,148 @@
import { ModuleJoinerConfig } from "@medusajs/framework/types"
import {
FeatureFlag,
MEDUSA_SKIP_FILE,
Modules,
} from "@medusajs/framework/utils"
export const ProductTranslation: ModuleJoinerConfig = {
[MEDUSA_SKIP_FILE]: !FeatureFlag.isFeatureEnabled("translation"),
isLink: true,
isReadOnlyLink: true,
extends: [
{
serviceName: Modules.PRODUCT,
entity: "Product",
relationship: {
serviceName: Modules.TRANSLATION,
entity: "Translation",
primaryKey: "reference_id",
foreignKey: "id",
alias: "translations",
isList: true,
args: {
methodSuffix: "Translations",
},
},
},
{
serviceName: Modules.PRODUCT,
entity: "ProductVariant",
relationship: {
serviceName: Modules.TRANSLATION,
entity: "Translation",
primaryKey: "reference_id",
foreignKey: "id",
alias: "translations",
isList: true,
args: {
methodSuffix: "Translations",
},
},
},
{
serviceName: Modules.PRODUCT,
entity: "ProductCategory",
relationship: {
serviceName: Modules.TRANSLATION,
entity: "Translation",
primaryKey: "reference_id",
foreignKey: "id",
alias: "translations",
isList: true,
args: {
methodSuffix: "Translations",
},
},
},
{
serviceName: Modules.PRODUCT,
entity: "ProductCollection",
relationship: {
serviceName: Modules.TRANSLATION,
entity: "Translation",
primaryKey: "reference_id",
foreignKey: "id",
alias: "translations",
isList: true,
args: {
methodSuffix: "Translations",
},
},
},
{
serviceName: Modules.PRODUCT,
entity: "ProductTag",
relationship: {
serviceName: Modules.TRANSLATION,
entity: "Translation",
primaryKey: "reference_id",
foreignKey: "id",
alias: "translations",
isList: true,
args: {
methodSuffix: "Translations",
},
},
},
{
serviceName: Modules.PRODUCT,
entity: "ProductType",
relationship: {
serviceName: Modules.TRANSLATION,
entity: "Translation",
primaryKey: "reference_id",
foreignKey: "id",
alias: "translations",
isList: true,
args: {
methodSuffix: "Translations",
},
},
},
{
serviceName: Modules.PRODUCT,
entity: "ProductOption",
relationship: {
serviceName: Modules.TRANSLATION,
entity: "Translation",
primaryKey: "reference_id",
foreignKey: "id",
alias: "translations",
isList: true,
args: {
methodSuffix: "Translations",
},
},
},
{
serviceName: Modules.PRODUCT,
entity: "ProductOptionValue",
relationship: {
serviceName: Modules.TRANSLATION,
entity: "Translation",
primaryKey: "reference_id",
foreignKey: "id",
alias: "translations",
isList: true,
args: {
methodSuffix: "Translations",
},
},
},
{
serviceName: Modules.TRANSLATION,
entity: "Translation",
relationship: {
serviceName: Modules.PRODUCT,
entity: "Product",
primaryKey: "id",
foreignKey: "reference_id",
alias: "product",
args: {
methodSuffix: "Products",
},
},
},
],
} as ModuleJoinerConfig

View File

@@ -0,0 +1,28 @@
import { ModuleJoinerConfig } from "@medusajs/framework/types"
import {
FeatureFlag,
MEDUSA_SKIP_FILE,
Modules,
} from "@medusajs/framework/utils"
export const StoreLocales: ModuleJoinerConfig = {
[MEDUSA_SKIP_FILE]: !FeatureFlag.isFeatureEnabled("translation"),
isLink: true,
isReadOnlyLink: true,
extends: [
{
serviceName: Modules.STORE,
entity: "Store",
relationship: {
serviceName: Modules.TRANSLATION,
entity: "Locale",
primaryKey: "code",
foreignKey: "supported_locales.locale_code",
alias: "locale",
args: {
methodSuffix: "Locales",
},
},
},
],
} as ModuleJoinerConfig

View File

@@ -14,6 +14,7 @@ import {
composeLinkName,
composeTableName,
ContainerRegistrationKeys,
isFileSkipped,
Modules,
promiseAll,
simpleHash,
@@ -40,9 +41,9 @@ export const initialize = async (
(mod) => Object.keys(mod)[0]
)
const allLinksToLoad = Object.values(linkDefinitions).concat(
pluginLinksDefinitions ?? []
)
const allLinksToLoad = Object.values(linkDefinitions)
.concat(pluginLinksDefinitions ?? [])
.filter((linkDefinition) => !isFileSkipped(linkDefinition))
await promiseAll(
allLinksToLoad.map(async (linkDefinition) => {

View File

@@ -6,6 +6,10 @@ export const createStoreFixture: StoreTypes.CreateStoreDTO = {
{ currency_code: "usd" },
{ currency_code: "eur", is_default: true },
],
supported_locales: [
{ locale_code: "fr-FR" },
{ locale_code: "en-US", is_default: true },
],
default_sales_channel_id: "test-sales-channel",
default_region_id: "test-region",
metadata: {

View File

@@ -15,7 +15,11 @@ moduleIntegrationTestRunner<IStoreModuleService>({
service: StoreModuleService,
}).linkable
expect(Object.keys(linkable)).toEqual(["store", "storeCurrency"])
expect(Object.keys(linkable)).toEqual([
"store",
"storeCurrency",
"storeLocale",
])
Object.keys(linkable).forEach((key) => {
delete linkable[key].toJSON
@@ -40,6 +44,15 @@ moduleIntegrationTestRunner<IStoreModuleService>({
field: "storeCurrency",
},
},
storeLocale: {
id: {
linkable: "store_locale_id",
entity: "StoreLocale",
primaryKey: "id",
serviceName: "store",
field: "storeLocale",
},
},
})
})
@@ -54,6 +67,10 @@ moduleIntegrationTestRunner<IStoreModuleService>({
expect.objectContaining({ currency_code: "eur" }),
expect.objectContaining({ currency_code: "usd" }),
]),
supported_locales: expect.arrayContaining([
expect.objectContaining({ locale_code: "fr-FR" }),
expect.objectContaining({ locale_code: "en-US" }),
]),
default_sales_channel_id: "test-sales-channel",
default_region_id: "test-region",
metadata: {
@@ -75,6 +92,19 @@ moduleIntegrationTestRunner<IStoreModuleService>({
"There should be a default currency set for the store"
)
})
it("should fail to get created if there is no default locale", async function () {
const err = await service
.createStores({
...createStoreFixture,
supported_locales: [{ locale_code: "en-US" }],
})
.catch((err) => err.message)
expect(err).toEqual(
"There should be a default locale set for the store"
)
})
})
describe("upserting a store", () => {
@@ -130,6 +160,19 @@ moduleIntegrationTestRunner<IStoreModuleService>({
)
})
it("should fail updating locales without a default one", async function () {
const createdStore = await service.createStores(createStoreFixture)
const updateErr = await service
.updateStores(createdStore.id, {
supported_locales: [{ locale_code: "en-US" }],
})
.catch((err) => err.message)
expect(updateErr).toEqual(
"There should be a default locale set for the store"
)
})
it("should fail updating currencies where a duplicate currency code exists", async function () {
const createdStore = await service.createStores(createStoreFixture)
const updateErr = await service
@@ -144,6 +187,20 @@ moduleIntegrationTestRunner<IStoreModuleService>({
expect(updateErr).toEqual("Duplicate currency codes: usd")
})
it("should fail updating locales where a duplicate locale code exists", async function () {
const createdStore = await service.createStores(createStoreFixture)
const updateErr = await service
.updateStores(createdStore.id, {
supported_locales: [
{ locale_code: "en-US" },
{ locale_code: "en-US" },
],
})
.catch((err) => err.message)
expect(updateErr).toEqual("Duplicate locale codes: en-US")
})
it("should fail updating currencies where there is more than 1 default currency", async function () {
const createdStore = await service.createStores(createStoreFixture)
const updateErr = await service
@@ -157,6 +214,20 @@ moduleIntegrationTestRunner<IStoreModuleService>({
expect(updateErr).toEqual("Only one default currency is allowed")
})
it("should fail updating locales where there is more than 1 default locale", async function () {
const createdStore = await service.createStores(createStoreFixture)
const updateErr = await service
.updateStores(createdStore.id, {
supported_locales: [
{ locale_code: "en-US", is_default: true },
{ locale_code: "fr-FR", is_default: true },
],
})
.catch((err) => err.message)
expect(updateErr).toEqual("Only one default locale is allowed")
})
})
describe("deleting a store", () => {

View File

@@ -101,9 +101,10 @@
"keyName": "IDX_store_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_deleted_at\" ON \"store\" (deleted_at) WHERE deleted_at IS NULL"
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_deleted_at\" ON \"store\" (\"deleted_at\") WHERE deleted_at IS NULL"
},
{
"keyName": "store_pkey",
@@ -111,12 +112,14 @@
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
"foreignKeys": {},
"nativeEnums": {}
},
{
"columns": {
@@ -197,17 +200,19 @@
"keyName": "IDX_store_currency_store_id",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_currency_store_id\" ON \"store_currency\" (store_id) WHERE deleted_at IS NULL"
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_currency_store_id\" ON \"store_currency\" (\"store_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_store_currency_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_currency_deleted_at\" ON \"store_currency\" (deleted_at) WHERE deleted_at IS NULL"
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_currency_deleted_at\" ON \"store_currency\" (\"deleted_at\") WHERE deleted_at IS NULL"
},
{
"keyName": "store_currency_pkey",
@@ -215,6 +220,7 @@
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
@@ -234,7 +240,131 @@
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
},
"nativeEnums": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"locale_code": {
"name": "locale_code",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"is_default": {
"name": "is_default",
"type": "boolean",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "false",
"mappedType": "boolean"
},
"store_id": {
"name": "store_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"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": "store_locale",
"schema": "public",
"indexes": [
{
"keyName": "IDX_store_locale_store_id",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_locale_store_id\" ON \"store_locale\" (\"store_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_store_locale_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_locale_deleted_at\" ON \"store_locale\" (\"deleted_at\") WHERE deleted_at IS NULL"
},
{
"keyName": "store_locale_pkey",
"columnNames": [
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"store_locale_store_id_foreign": {
"constraintName": "store_locale_store_id_foreign",
"columnNames": [
"store_id"
],
"localTableName": "public.store_locale",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.store",
"deleteRule": "cascade",
"updateRule": "cascade"
}
},
"nativeEnums": {}
}
]
],
"nativeEnums": {}
}

View File

@@ -0,0 +1,17 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20251202184737 extends Migration {
override async up(): Promise<void> {
this.addSql(`create table if not exists "store_locale" ("id" text not null, "locale_code" text not null, "is_default" boolean not null default false, "store_id" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "store_locale_pkey" primary key ("id"));`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_store_locale_store_id" ON "store_locale" ("store_id") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_store_locale_deleted_at" ON "store_locale" ("deleted_at") WHERE deleted_at IS NULL;`);
this.addSql(`alter table if exists "store_locale" add constraint "store_locale_store_id_foreign" foreign key ("store_id") references "store" ("id") on update cascade on delete cascade;`);
}
override async down(): Promise<void> {
this.addSql(`drop table if exists "store_locale" cascade;`);
}
}

View File

@@ -1,2 +1,3 @@
export { default as Store } from "./store"
export { default as StoreCurrency } from "./currency"
export { default as StoreLocale } from "./locale"

View File

@@ -0,0 +1,15 @@
import { model } from "@medusajs/framework/utils"
import Store from "./store"
const StoreLocale = model.define("StoreLocale", {
id: model.id({ prefix: "stloc" }).primaryKey(),
locale_code: model.text().searchable(),
is_default: model.boolean().default(false),
store: model
.belongsTo(() => Store, {
mappedBy: "supported_locales",
})
.nullable(),
})
export default StoreLocale

View File

@@ -1,5 +1,6 @@
import { model } from "@medusajs/framework/utils"
import StoreCurrency from "./currency"
import StoreLocale from "./locale"
const Store = model
.define("Store", {
@@ -12,9 +13,12 @@ const Store = model
supported_currencies: model.hasMany(() => StoreCurrency, {
mappedBy: "store",
}),
supported_locales: model.hasMany(() => StoreLocale, {
mappedBy: "store",
}),
})
.cascades({
delete: ["supported_currencies"],
delete: ["supported_currencies", "supported_locales"],
})
export default Store

View File

@@ -20,7 +20,7 @@ import {
removeUndefined,
} from "@medusajs/framework/utils"
import { Store, StoreCurrency } from "@models"
import { Store, StoreCurrency, StoreLocale } from "@models"
import { UpdateStoreInput } from "@types"
type InjectedDependencies = {
@@ -32,7 +32,8 @@ export default class StoreModuleService
extends MedusaService<{
Store: { dto: StoreTypes.StoreDTO }
StoreCurrency: { dto: StoreTypes.StoreCurrencyDTO }
}>({ Store, StoreCurrency })
StoreLocale: { dto: StoreTypes.StoreLocaleDTO }
}>({ Store, StoreCurrency, StoreLocale })
implements IStoreModuleService
{
protected baseRepository_: DAL.RepositoryService
@@ -88,7 +89,7 @@ export default class StoreModuleService
return (
await this.storeService_.upsertWithReplace(
normalizedInput,
{ relations: ["supported_currencies"] },
{ relations: ["supported_currencies", "supported_locales"] },
sharedContext
)
).entities
@@ -200,7 +201,7 @@ export default class StoreModuleService
return (
await this.storeService_.upsertWithReplace(
normalizedInput,
{ relations: ["supported_currencies"] },
{ relations: ["supported_currencies", "supported_locales"] },
sharedContext
)
).entities
@@ -226,37 +227,56 @@ export default class StoreModuleService
) {
for (const store of stores) {
if (store.supported_currencies?.length) {
const duplicates = getDuplicates(
store.supported_currencies?.map((c) => c.currency_code)
StoreModuleService.validateSupportedItems(
store.supported_currencies,
(c) => c.currency_code,
"currency"
)
if (duplicates.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Duplicate currency codes: ${duplicates.join(", ")}`
)
}
let seenDefault = false
store.supported_currencies?.forEach((c) => {
if (c.is_default) {
if (seenDefault) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Only one default currency is allowed`
)
}
seenDefault = true
}
})
if (!seenDefault) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`There should be a default currency set for the store`
)
}
}
// TODO: If we are protecting this module behind a feature flag, we should check if the feature flag is enabled before validating the locales.
if (store.supported_locales?.length) {
StoreModuleService.validateSupportedItems(
store.supported_locales,
(l) => l.locale_code,
"locale"
)
}
}
}
private static validateSupportedItems<T extends { is_default?: boolean }>(
items: T[],
getCode: (item: T) => string,
typeName: string
) {
const duplicates = getDuplicates(items.map(getCode))
if (duplicates.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Duplicate ${typeName} codes: ${duplicates.join(", ")}`
)
}
let seenDefault = false
items.forEach((item) => {
if (item.is_default) {
if (seenDefault) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Only one default ${typeName} is allowed`
)
}
seenDefault = true
}
})
if (!seenDefault) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`There should be a default ${typeName} set for the store`
)
}
}

View File

@@ -0,0 +1,6 @@
/dist
node_modules
.DS_store
.env*
.env
*.sql

View File

@@ -0,0 +1,16 @@
import { TranslationTypes } from "@medusajs/framework/types"
export const createLocaleFixture: TranslationTypes.CreateLocaleDTO = {
code: "test-LC",
name: "Test Locale",
}
export const createTranslationFixture: TranslationTypes.CreateTranslationDTO = {
reference_id: "prod_123",
reference: "product",
locale_code: "fr-FR",
translations: {
title: "Titre du produit",
description: "Description du produit en français",
},
}

View File

@@ -0,0 +1,651 @@
import { ITranslationModuleService } from "@medusajs/framework/types"
import { Module, Modules } from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import TranslationModuleService from "@services/translation-module"
import { createLocaleFixture, createTranslationFixture } from "../__fixtures__"
jest.setTimeout(100000)
moduleIntegrationTestRunner<ITranslationModuleService>({
moduleName: Modules.TRANSLATION,
testSuite: ({ service }) => {
describe("Translation Module Service", () => {
it(`should export the appropriate linkable configuration`, () => {
const linkable = Module(Modules.TRANSLATION, {
service: TranslationModuleService,
}).linkable
expect(Object.keys(linkable)).toEqual(["locale", "translation"])
Object.keys(linkable).forEach((key) => {
delete linkable[key].toJSON
})
expect(linkable).toEqual({
locale: {
id: {
linkable: "locale_id",
entity: "Locale",
primaryKey: "id",
serviceName: "translation",
field: "locale",
},
},
translation: {
id: {
linkable: "translation_id",
entity: "Translation",
primaryKey: "id",
serviceName: "translation",
field: "translation",
},
},
})
})
describe("Locale", () => {
describe("creating a locale", () => {
it("should create a locale successfully", async () => {
const locale = await service.createLocales(createLocaleFixture)
expect(locale).toEqual(
expect.objectContaining({
code: "test-LC",
name: "Test Locale",
created_at: expect.any(Date),
updated_at: expect.any(Date),
})
)
})
it("should create multiple locales successfully", async () => {
const locales = await service.createLocales([
createLocaleFixture,
{ code: "test-LC2", name: "Test Locale 2" },
])
expect(locales).toHaveLength(2)
expect(locales[0].code).toEqual("test-LC")
expect(locales[1].code).toEqual("test-LC2")
})
})
describe("retrieving a locale", () => {
it("should retrieve a locale by id", async () => {
const created = await service.createLocales(createLocaleFixture)
const retrieved = await service.retrieveLocale(created.id)
expect(retrieved).toEqual(
expect.objectContaining({
id: created.id,
code: created.code,
name: "Test Locale",
})
)
})
it("should throw when retrieving non-existent locale", async () => {
const error = await service
.retrieveLocale("non-existent-id")
.catch((e) => e)
expect(error.message).toContain("Locale with id: non-existent-id")
})
})
describe("listing locales", () => {
it("should list all locales including defaults", async () => {
const locales = await service.listLocales()
expect(locales.length).toBeGreaterThanOrEqual(45)
})
it("should filter locales by code", async () => {
await service.createLocales(createLocaleFixture)
const locales = await service.listLocales({ code: "test-LC" })
expect(locales).toHaveLength(1)
expect(locales[0].code).toEqual("test-LC")
})
it("should filter locales by name", async () => {
const locales = await service.listLocales({
name: "English (United States)",
})
expect(locales).toHaveLength(1)
expect(locales[0].code).toEqual("en-US")
})
it("should support pagination", async () => {
const paginatedLocales = await service.listLocales(
{},
{ take: 5, skip: 0 }
)
expect(paginatedLocales).toHaveLength(5)
})
})
describe("listing and counting locales", () => {
it("should list and count locales", async () => {
const [locales, count] = await service.listAndCountLocales()
expect(count).toBeGreaterThanOrEqual(45)
expect(locales.length).toEqual(count)
})
it("should filter and count correctly", async () => {
await service.createLocales([
{ code: "custom-A", name: "Custom A" },
{ code: "custom-B", name: "Custom B" },
])
const [locales, count] = await service.listAndCountLocales({
code: ["custom-A", "custom-B"],
})
expect(count).toEqual(2)
expect(locales).toHaveLength(2)
})
})
describe("updating a locale", () => {
it("should update a locale successfully", async () => {
const created = await service.createLocales(createLocaleFixture)
const updated = await service.updateLocales({
id: created.id,
code: created.code,
name: "Updated Locale Name",
})
expect(updated.name).toEqual("Updated Locale Name")
expect(updated.code).toEqual("test-LC")
})
it("should update multiple locales", async () => {
const created = await service.createLocales([
{ code: "upd-1", name: "Update 1" },
{ code: "upd-2", name: "Update 2" },
])
const updated = await service.updateLocales([
{ id: created[0].id, code: created[0].code, name: "Updated 1" },
{ id: created[1].id, code: created[1].code, name: "Updated 2" },
])
expect(updated).toHaveLength(2)
const updatedById = updated.reduce(
(acc, l) => ({ ...acc, [l.code]: l }),
{} as Record<string, any>
)
expect(updatedById[created[0].code].name).toEqual("Updated 1")
expect(updatedById[created[1].code].name).toEqual("Updated 2")
})
})
describe("deleting a locale", () => {
it("should delete a locale successfully", async () => {
const created = await service.createLocales(createLocaleFixture)
await service.deleteLocales(created.id)
const error = await service
.retrieveLocale(created.id)
.catch((e) => e)
expect(error.message).toContain("Locale with id")
})
it("should delete multiple locales", async () => {
const created = await service.createLocales([
{ code: "del-1", name: "Delete 1" },
{ code: "del-2", name: "Delete 2" },
])
await service.deleteLocales([created[0].id, created[1].id])
const locales = await service.listLocales({
code: ["del-1", "del-2"],
})
expect(locales).toHaveLength(0)
})
})
describe("soft deleting a locale", () => {
it("should soft delete a locale", async () => {
const created = await service.createLocales(createLocaleFixture)
await service.softDeleteLocales(created.id)
const locales = await service.listLocales({ code: created.code })
expect(locales).toHaveLength(0)
})
})
describe("restoring a locale", () => {
it("should restore a soft deleted locale", async () => {
const created = await service.createLocales(createLocaleFixture)
await service.softDeleteLocales(created.id)
await service.restoreLocales(created.id)
const restored = await service.retrieveLocale(created.id)
expect(restored.code).toEqual(created.code)
})
})
})
describe("Translation", () => {
describe("creating a translation", () => {
it("should create a translation successfully", async () => {
const translation = await service.createTranslations(
createTranslationFixture
)
expect(translation).toEqual(
expect.objectContaining({
id: expect.stringMatching(/^trans_/),
reference_id: "prod_123",
reference: "product",
locale_code: "fr-FR",
translations: {
title: "Titre du produit",
description: "Description du produit en français",
},
created_at: expect.any(Date),
updated_at: expect.any(Date),
})
)
})
it("should create multiple translations successfully", async () => {
const translations = await service.createTranslations([
createTranslationFixture,
{
reference_id: "prod_123",
reference: "product",
locale_code: "de-DE",
translations: {
title: "Produkttitel",
description: "Produktbeschreibung auf Deutsch",
},
},
])
expect(translations).toHaveLength(2)
expect(translations[0].locale_code).toEqual("fr-FR")
expect(translations[1].locale_code).toEqual("de-DE")
})
it("should fail when creating duplicate translation for same entity/type/locale", async () => {
await service.createTranslations(createTranslationFixture)
const error = await service
.createTranslations(createTranslationFixture)
.catch((e) => e)
expect(error.message).toMatch(
/unique|duplicate|constraint|already exists/i
)
})
})
describe("retrieving a translation", () => {
it("should retrieve a translation by id", async () => {
const created = await service.createTranslations(
createTranslationFixture
)
const retrieved = await service.retrieveTranslation(created.id)
expect(retrieved).toEqual(
expect.objectContaining({
id: created.id,
reference_id: "prod_123",
reference: "product",
locale_code: "fr-FR",
})
)
})
it("should throw when retrieving non-existent translation", async () => {
const error = await service
.retrieveTranslation("non-existent-id")
.catch((e) => e)
expect(error.message).toContain(
"Translation with id: non-existent-id"
)
})
})
describe("listing translations", () => {
beforeEach(async () => {
await service.createTranslations([
{
reference_id: "prod_1",
reference: "product",
locale_code: "fr-FR",
translations: { title: "Produit Un" },
},
{
reference_id: "prod_1",
reference: "product",
locale_code: "de-DE",
translations: { title: "Produkt Eins" },
},
{
reference_id: "prod_2",
reference: "product",
locale_code: "fr-FR",
translations: { title: "Produit Deux" },
},
{
reference_id: "cat_1",
reference: "product_category",
locale_code: "fr-FR",
translations: { name: "Catégorie" },
},
])
})
it("should list all translations", async () => {
const translations = await service.listTranslations()
expect(translations.length).toBeGreaterThanOrEqual(4)
})
it("should filter by reference_id", async () => {
const translations = await service.listTranslations({
reference_id: "prod_1",
})
expect(translations).toHaveLength(2)
})
it("should filter by reference", async () => {
const translations = await service.listTranslations({
reference: "product_category",
})
expect(translations).toHaveLength(1)
expect(translations[0].reference_id).toEqual("cat_1")
})
it("should filter by locale_code", async () => {
const translations = await service.listTranslations({
locale_code: "de-DE",
})
expect(translations).toHaveLength(1)
expect(translations[0].reference_id).toEqual("prod_1")
})
it("should filter by multiple criteria", async () => {
const translations = await service.listTranslations({
reference_id: "prod_1",
locale_code: "fr-FR",
})
expect(translations).toHaveLength(1)
expect(translations[0].translations).toEqual({
title: "Produit Un",
})
})
it("should support pagination", async () => {
const translations = await service.listTranslations(
{},
{ take: 2, skip: 0 }
)
expect(translations).toHaveLength(2)
})
})
describe("listing translations with q filter (JSONB search)", () => {
beforeEach(async () => {
await service.createTranslations([
{
reference_id: "prod_search_1",
reference: "product",
locale_code: "fr-FR",
translations: {
title: "Chaussures de sport",
description: "Des chaussures confortables pour le running",
},
},
{
reference_id: "prod_search_2",
reference: "product",
locale_code: "fr-FR",
translations: {
title: "T-shirt de sport",
description: "Un t-shirt léger et respirant",
},
},
{
reference_id: "prod_search_3",
reference: "product",
locale_code: "de-DE",
translations: {
title: "Sportschuhe",
description: "Bequeme Schuhe zum Laufen",
},
},
])
})
it("should search within JSONB translations field", async () => {
const translations = await service.listTranslations({
q: "chaussures",
})
expect(translations).toHaveLength(1)
expect(translations[0].reference_id).toEqual("prod_search_1")
})
it("should search case-insensitively", async () => {
const translations = await service.listTranslations({
q: "CHAUSSURES",
})
expect(translations).toHaveLength(1)
})
it("should search across all JSONB values", async () => {
const translations = await service.listTranslations({
q: "running",
})
expect(translations).toHaveLength(1)
expect(translations[0].reference_id).toEqual("prod_search_1")
})
it("should combine q filter with other filters", async () => {
const translations = await service.listTranslations({
q: "sport",
locale_code: "fr-FR",
})
expect(translations).toHaveLength(2)
})
it("should return empty array when q matches nothing", async () => {
const translations = await service.listTranslations({
q: "nonexistent-term-xyz",
})
expect(translations).toHaveLength(0)
})
})
describe("listing and counting translations", () => {
beforeEach(async () => {
await service.createTranslations([
{
reference_id: "cnt_1",
reference: "product",
locale_code: "fr-FR",
translations: { title: "Un" },
},
{
reference_id: "cnt_2",
reference: "product",
locale_code: "fr-FR",
translations: { title: "Deux" },
},
{
reference_id: "cnt_3",
reference: "product",
locale_code: "fr-FR",
translations: { title: "Trois" },
},
])
})
it("should list and count translations", async () => {
const [translations, count] =
await service.listAndCountTranslations({
reference: "product",
locale_code: "fr-FR",
})
expect(count).toEqual(3)
expect(translations).toHaveLength(3)
})
it("should list and count with q filter", async () => {
const [translations, count] =
await service.listAndCountTranslations({
q: "Deux",
})
expect(count).toEqual(1)
expect(translations).toHaveLength(1)
expect(translations[0].reference_id).toEqual("cnt_2")
})
})
describe("updating a translation", () => {
it("should update a translation successfully", async () => {
const created = await service.createTranslations(
createTranslationFixture
)
const updated = await service.updateTranslations({
id: created.id,
translations: {
title: "Nouveau titre",
description: "Nouvelle description",
},
})
expect(updated.translations).toEqual({
title: "Nouveau titre",
description: "Nouvelle description",
})
})
it("should update multiple translations", async () => {
const created = await service.createTranslations([
{
reference_id: "upd_1",
reference: "product",
locale_code: "fr-FR",
translations: { title: "Original 1" },
},
{
reference_id: "upd_2",
reference: "product",
locale_code: "fr-FR",
translations: { title: "Original 2" },
},
])
const updated = await service.updateTranslations([
{ id: created[0].id, translations: { title: "Updated 1" } },
{ id: created[1].id, translations: { title: "Updated 2" } },
])
expect(updated).toHaveLength(2)
const updatedById = updated.reduce(
(acc, t) => ({ ...acc, [t.id]: t }),
{} as Record<string, any>
)
expect(updatedById[created[0].id].translations).toEqual({
title: "Updated 1",
})
expect(updatedById[created[1].id].translations).toEqual({
title: "Updated 2",
})
})
})
describe("deleting a translation", () => {
it("should delete a translation successfully", async () => {
const created = await service.createTranslations(
createTranslationFixture
)
await service.deleteTranslations(created.id)
const error = await service
.retrieveTranslation(created.id)
.catch((e) => e)
expect(error.message).toContain("Translation with id")
})
it("should delete multiple translations", async () => {
const created = await service.createTranslations([
{
reference_id: "del_1",
reference: "product",
locale_code: "fr-FR",
translations: { title: "Delete 1" },
},
{
reference_id: "del_2",
reference: "product",
locale_code: "fr-FR",
translations: { title: "Delete 2" },
},
])
await service.deleteTranslations([created[0].id, created[1].id])
const translations = await service.listTranslations({
reference_id: ["del_1", "del_2"],
})
expect(translations).toHaveLength(0)
})
})
describe("soft deleting a translation", () => {
it("should soft delete a translation", async () => {
const created = await service.createTranslations(
createTranslationFixture
)
await service.softDeleteTranslations(created.id)
const translations = await service.listTranslations({
id: created.id,
})
expect(translations).toHaveLength(0)
})
})
describe("restoring a translation", () => {
it("should restore a soft deleted translation", async () => {
const created = await service.createTranslations(
createTranslationFixture
)
await service.softDeleteTranslations(created.id)
await service.restoreTranslations(created.id)
const restored = await service.retrieveTranslation(created.id)
expect(restored.id).toEqual(created.id)
})
})
})
})
},
})

View File

@@ -0,0 +1,15 @@
const defineJestConfig = require("../../../define_jest_config")
module.exports = defineJestConfig({
moduleNameMapper: {
"^@models$": "<rootDir>/src/models",
"^@models/(.*)$": "<rootDir>/src/models/$1",
"^@services$": "<rootDir>/src/services",
"^@services/(.*)$": "<rootDir>/src/services/$1",
"^@repositories$": "<rootDir>/src/repositories",
"^@repositories/(.*)$": "<rootDir>/src/repositories/$1",
"^@types$": "<rootDir>/src/types",
"^@types/(.*)$": "<rootDir>/src/types/$1",
"^@utils$": "<rootDir>/src/utils",
"^@utils/(.*)$": "<rootDir>/src/utils/$1",
},
})

View File

@@ -0,0 +1,7 @@
import { defineMikroOrmCliConfig } from "@medusajs/framework/utils"
import Locale from "./src/models/locale"
import Translation from "./src/models/translation"
export default defineMikroOrmCliConfig("translation", {
entities: [Locale, Translation],
})

View File

@@ -0,0 +1,45 @@
{
"name": "@medusajs/translation",
"version": "2.12.1",
"description": "Medusa Translation module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"!dist/**/__tests__",
"!dist/**/__mocks__",
"!dist/**/__fixtures__"
],
"engines": {
"node": ">=20"
},
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/modules/translation"
},
"publishConfig": {
"access": "public"
},
"author": "Medusa",
"license": "MIT",
"scripts": {
"watch": "yarn run -T tsc --build --watch",
"watch:test": "yarn run -T tsc --build tsconfig.spec.json --watch",
"resolve:aliases": "yarn run -T tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && yarn run -T tsc-alias -p tsconfig.resolved.json && yarn run -T rimraf tsconfig.resolved.json",
"build": "yarn run -T rimraf dist && yarn run -T tsc --build && npm run resolve:aliases",
"test": "../../../node_modules/.bin/jest --passWithNoTests --bail --forceExit --testPathPattern=src",
"test:integration": "../../../node_modules/.bin/jest --passWithNoTests --forceExit --testPathPattern=\"integration-tests/__tests__/.*\\.ts\"",
"migration:initial": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:create --initial",
"migration:create": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:create",
"migration:up": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:up",
"orm:cache:clear": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm cache:clear"
},
"devDependencies": {
"@medusajs/framework": "2.12.1",
"@medusajs/test-utils": "2.12.1"
},
"peerDependencies": {
"@medusajs/framework": "2.12.1"
}
}

View File

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

View File

@@ -0,0 +1,80 @@
import {
LoaderOptions,
Logger,
ModulesSdkTypes,
} from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import Locale from "@models/locale"
/**
* BCP 47 Language Tags
* Common language-region codes following the IETF BCP 47 standard.
* Format: language[-script][-region]
* Examples: "en-US" (English, United States), "zh-Hans-CN" (Chinese Simplified, China)
*/
const defaultLocales = [
{ code: "en-US", name: "English (United States)" },
{ code: "en-GB", name: "English (United Kingdom)" },
{ code: "en-AU", name: "English (Australia)" },
{ code: "en-CA", name: "English (Canada)" },
{ code: "es-ES", name: "Spanish (Spain)" },
{ code: "es-MX", name: "Spanish (Mexico)" },
{ code: "es-AR", name: "Spanish (Argentina)" },
{ code: "fr-FR", name: "French (France)" },
{ code: "fr-CA", name: "French (Canada)" },
{ code: "fr-BE", name: "French (Belgium)" },
{ code: "de-DE", name: "German (Germany)" },
{ code: "de-AT", name: "German (Austria)" },
{ code: "de-CH", name: "German (Switzerland)" },
{ code: "it-IT", name: "Italian (Italy)" },
{ code: "pt-BR", name: "Portuguese (Brazil)" },
{ code: "pt-PT", name: "Portuguese (Portugal)" },
{ code: "nl-NL", name: "Dutch (Netherlands)" },
{ code: "nl-BE", name: "Dutch (Belgium)" },
{ code: "da-DK", name: "Danish (Denmark)" },
{ code: "sv-SE", name: "Swedish (Sweden)" },
{ code: "nb-NO", name: "Norwegian Bokmål (Norway)" },
{ code: "fi-FI", name: "Finnish (Finland)" },
{ code: "pl-PL", name: "Polish (Poland)" },
{ code: "cs-CZ", name: "Czech (Czech Republic)" },
{ code: "sk-SK", name: "Slovak (Slovakia)" },
{ code: "hu-HU", name: "Hungarian (Hungary)" },
{ code: "ro-RO", name: "Romanian (Romania)" },
{ code: "bg-BG", name: "Bulgarian (Bulgaria)" },
{ code: "el-GR", name: "Greek (Greece)" },
{ code: "tr-TR", name: "Turkish (Turkey)" },
{ code: "ru-RU", name: "Russian (Russia)" },
{ code: "uk-UA", name: "Ukrainian (Ukraine)" },
{ code: "ar-SA", name: "Arabic (Saudi Arabia)" },
{ code: "ar-AE", name: "Arabic (United Arab Emirates)" },
{ code: "ar-EG", name: "Arabic (Egypt)" },
{ code: "he-IL", name: "Hebrew (Israel)" },
{ code: "hi-IN", name: "Hindi (India)" },
{ code: "bn-BD", name: "Bengali (Bangladesh)" },
{ code: "th-TH", name: "Thai (Thailand)" },
{ code: "vi-VN", name: "Vietnamese (Vietnam)" },
{ code: "id-ID", name: "Indonesian (Indonesia)" },
{ code: "ms-MY", name: "Malay (Malaysia)" },
{ code: "tl-PH", name: "Tagalog (Philippines)" },
{ code: "zh-CN", name: "Chinese Simplified (China)" },
{ code: "zh-TW", name: "Chinese Traditional (Taiwan)" },
{ code: "zh-HK", name: "Chinese Traditional (Hong Kong)" },
{ code: "ja-JP", name: "Japanese (Japan)" },
{ code: "ko-KR", name: "Korean (South Korea)" },
]
export default async ({ container }: LoaderOptions): Promise<void> => {
const logger =
container.resolve<Logger>(ContainerRegistrationKeys.LOGGER) ?? console
const localeService_: ModulesSdkTypes.IMedusaInternalService<typeof Locale> =
container.resolve("localeService")
try {
const resp = await localeService_.upsert(defaultLocales)
logger.debug(`Loaded ${resp.length} locales`)
} catch (error) {
logger.warn(
`Failed to load locales, skipping loader. Original error: ${error.message}`
)
}
}

View File

@@ -0,0 +1,259 @@
{
"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"
},
"name": {
"name": "name",
"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": "locale",
"schema": "public",
"indexes": [
{
"keyName": "IDX_locale_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_locale_deleted_at\" ON \"locale\" (\"deleted_at\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_locale_code_unique",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_locale_code_unique\" ON \"locale\" (\"code\") WHERE deleted_at IS NULL"
},
{
"keyName": "locale_pkey",
"columnNames": [
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {},
"nativeEnums": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"reference_id": {
"name": "reference_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"reference": {
"name": "reference",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"locale_code": {
"name": "locale_code",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"translations": {
"name": "translations",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "json"
},
"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": "translation",
"schema": "public",
"indexes": [
{
"keyName": "IDX_translation_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_translation_deleted_at\" ON \"translation\" (\"deleted_at\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_translation_reference_id_locale_code_unique",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_translation_reference_id_locale_code_unique\" ON \"translation\" (\"reference_id\", \"locale_code\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_translation_reference_id_reference_locale_code",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_translation_reference_id_reference_locale_code\" ON \"translation\" (\"reference_id\", \"reference\", \"locale_code\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_translation_reference_locale_code",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_translation_reference_locale_code\" ON \"translation\" (\"reference\", \"locale_code\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_translation_reference_id_reference",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_translation_reference_id_reference\" ON \"translation\" (\"reference_id\", \"reference\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_translation_locale_code",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_translation_locale_code\" ON \"translation\" (\"locale_code\") WHERE deleted_at IS NULL"
},
{
"keyName": "translation_pkey",
"columnNames": [
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {},
"nativeEnums": {}
}
],
"nativeEnums": {}
}

View File

@@ -0,0 +1,49 @@
import { Migration } from "@medusajs/framework/mikro-orm/migrations"
export class Migration20251208124155 extends Migration {
override async up(): Promise<void> {
this.addSql(
`alter table if exists "translation" drop constraint if exists "translation_reference_id_locale_code_unique";`
)
this.addSql(
`alter table if exists "locale" drop constraint if exists "locale_code_unique";`
)
this.addSql(
`create table if not exists "locale" ("id" text not null, "code" text not null, "name" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "locale_pkey" primary key ("id"));`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_locale_deleted_at" ON "locale" ("deleted_at") WHERE deleted_at IS NULL;`
)
this.addSql(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_locale_code_unique" ON "locale" ("code") WHERE deleted_at IS NULL;`
)
this.addSql(
`create table if not exists "translation" ("id" text not null, "reference_id" text not null, "reference" text not null, "locale_code" text not null, "translations" jsonb not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "translation_pkey" primary key ("id"));`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_translation_deleted_at" ON "translation" ("deleted_at") WHERE deleted_at IS NULL;`
)
this.addSql(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_translation_reference_id_locale_code_unique" ON "translation" ("reference_id", "locale_code") WHERE deleted_at IS NULL;`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_translation_reference_id_reference_locale_code" ON "translation" ("reference_id", "reference", "locale_code") WHERE deleted_at IS NULL;`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_translation_reference_locale_code" ON "translation" ("reference", "locale_code") WHERE deleted_at IS NULL;`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_translation_reference_id_reference" ON "translation" ("reference_id", "reference") WHERE deleted_at IS NULL;`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_translation_locale_code" ON "translation" ("locale_code") WHERE deleted_at IS NULL;`
)
}
override async down(): Promise<void> {
this.addSql(`drop table if exists "locale" cascade;`)
this.addSql(`drop table if exists "translation" cascade;`)
}
}

View File

@@ -0,0 +1,16 @@
import { model } from "@medusajs/framework/utils"
const Locale = model
.define("locale", {
id: model.id({ prefix: "loc" }).primaryKey(),
code: model.text().searchable(), // BCP 47 language tag, e.g., "en-US", "da-DK"
name: model.text().searchable(), // Human-readable name, e.g., "English (US)", "Danish"
})
.indexes([
{
on: ["code"],
unique: true,
},
])
export default Locale

View File

@@ -0,0 +1,30 @@
import { model } from "@medusajs/framework/utils"
const Translation = model
.define("translation", {
id: model.id({ prefix: "trans" }).primaryKey(),
reference_id: model.text().searchable(),
reference: model.text().searchable(), // e.g., "product", "product_variant", "product_category"
locale_code: model.text().searchable(), // BCP 47 language tag, e.g., "en-US", "da-DK"
translations: model.json(), // JSON object containing translated fields, e.g., { "title": "...", "description": "..." }
})
.indexes([
{
on: ["reference_id", "locale_code"],
unique: true,
},
{
on: ["reference_id", "reference", "locale_code"],
},
{
on: ["reference", "locale_code"],
},
{
on: ["reference_id", "reference"],
},
{
on: ["locale_code"],
},
])
export default Translation

View File

@@ -0,0 +1,193 @@
import { raw } from "@medusajs/framework/mikro-orm/core"
import {
Context,
CreateTranslationDTO,
DAL,
FilterableTranslationProps,
FindConfig,
ITranslationModuleService,
LocaleDTO,
ModulesSdkTypes,
TranslationTypes,
} from "@medusajs/framework/types"
import {
EmitEvents,
InjectManager,
MedusaContext,
MedusaService,
normalizeLocale,
} from "@medusajs/framework/utils"
import Locale from "@models/locale"
import Translation from "@models/translation"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
translationService: ModulesSdkTypes.IMedusaInternalService<typeof Translation>
localeService: ModulesSdkTypes.IMedusaInternalService<typeof Locale>
}
export default class TranslationModuleService
extends MedusaService<{
Locale: {
dto: TranslationTypes.LocaleDTO
}
Translation: {
dto: TranslationTypes.TranslationDTO
}
}>({
Locale,
Translation,
})
implements ITranslationModuleService
{
protected baseRepository_: DAL.RepositoryService
protected translationService_: ModulesSdkTypes.IMedusaInternalService<
typeof Translation
>
protected localeService_: ModulesSdkTypes.IMedusaInternalService<
typeof Locale
>
constructor({
baseRepository,
translationService,
localeService,
}: InjectedDependencies) {
super(...arguments)
this.baseRepository_ = baseRepository
this.translationService_ = translationService
this.localeService_ = localeService
}
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 listTranslations(
filters: FilterableTranslationProps = {},
config: FindConfig<TranslationTypes.TranslationDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TranslationTypes.TranslationDTO[]> {
const preparedFilters = TranslationModuleService.prepareFilters(filters)
const results = await this.translationService_.list(
preparedFilters,
config,
sharedContext
)
return await this.baseRepository_.serialize<
TranslationTypes.TranslationDTO[]
>(results)
}
@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 [results, count] = await this.translationService_.listAndCount(
preparedFilters,
config,
sharedContext
)
return [
await this.baseRepository_.serialize<TranslationTypes.TranslationDTO[]>(
results
),
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 normalizedData = dataArray.map((translation) => ({
...translation,
locale_code: normalizeLocale(translation.locale_code),
}))
const createdTranslations = await this.translationService_.create(
normalizedData,
sharedContext
)
const serialized = await this.baseRepository_.serialize<
TranslationTypes.TranslationDTO[]
>(createdTranslations)
return Array.isArray(data) ? serialized : serialized[0]
}
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../../_tsconfig.base.json",
"compilerOptions": {
"paths": {
"@models/*": ["./src/models/*"],
"@services/*": ["./src/services/*"],
"@repositories/*": ["./src/repositories/*"],
"@types/*": ["./src/types/*"],
"@utils/*": ["./src/utils/*"]
}
}
}