diff --git a/.changeset/spicy-games-unite.md b/.changeset/spicy-games-unite.md new file mode 100644 index 0000000000..22df76e84c --- /dev/null +++ b/.changeset/spicy-games-unite.md @@ -0,0 +1,7 @@ +--- +"@medusajs/medusa": patch +"@medusajs/translation": patch +"@medusajs/types": patch +--- + +chore(): Translation statistics diff --git a/integration-tests/http/__tests__/translation/admin/translation.spec.ts b/integration-tests/http/__tests__/translation/admin/translation.spec.ts index 859b38c28f..a81782128b 100644 --- a/integration-tests/http/__tests__/translation/admin/translation.spec.ts +++ b/integration-tests/http/__tests__/translation/admin/translation.spec.ts @@ -674,6 +674,249 @@ medusaIntegrationTestRunner({ }) }) }) + + describe("GET /admin/translations/statistics", () => { + it("should return statistics for entity types with no translations", async () => { + const productModule = appContainer.resolve(Modules.PRODUCT) + await productModule.createProducts([ + { title: "Product 1" }, + { title: "Product 2" }, + ]) + + const response = await api.get( + "/admin/translations/statistics?locales=en-US&locales=fr-FR&entity_types=product", + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.statistics).toBeDefined() + expect(response.data.statistics.product).toEqual({ + // 2 products × 5 translatable fields × 2 locales = 20 expected + expected: 20, + translated: 0, + missing: 20, + by_locale: { + "en-US": { expected: 10, translated: 0, missing: 10 }, + "fr-FR": { expected: 10, translated: 0, missing: 10 }, + }, + }) + }) + + it("should return statistics with partial translations", async () => { + const productModule = appContainer.resolve(Modules.PRODUCT) + const [product1, product2] = await productModule.createProducts([ + { title: "Product 1" }, + { title: "Product 2" }, + ]) + + // Create translations for product1 with partial fields + await api.post( + "/admin/translations/batch", + { + create: [ + { + reference_id: product1.id, + reference: "product", + locale_code: "fr-FR", + translations: { + title: "Produit 1", + description: "Description du produit 1", + }, + }, + { + reference_id: product2.id, + reference: "product", + locale_code: "fr-FR", + translations: { + title: "Produit 2", + }, + }, + ], + }, + adminHeaders + ) + + const response = await api.get( + "/admin/translations/statistics?locales=fr-FR&entity_types=product", + adminHeaders + ) + + expect(response.status).toEqual(200) + // 2 products × 5 fields × 1 locale = 10 expected + // product1 has 2 fields, product2 has 1 field = 3 translated + expect(response.data.statistics.product).toEqual({ + expected: 10, + translated: 3, + missing: 7, + by_locale: { + "fr-FR": { expected: 10, translated: 3, missing: 7 }, + }, + }) + }) + + it("should return statistics for multiple entity types", async () => { + const productModule = appContainer.resolve(Modules.PRODUCT) + const [product] = await productModule.createProducts([ + { + title: "Product with variant", + variants: [{ title: "Variant 1" }, { title: "Variant 2" }], + }, + ]) + + await api.post( + "/admin/translations/batch", + { + create: [ + { + reference_id: product.id, + reference: "product", + locale_code: "fr-FR", + translations: { + title: "Produit", + description: "Description", + subtitle: "Sous-titre", + status: "Actif", + material: "Matériau", + }, + }, + { + reference_id: product.variants[0].id, + reference: "product_variant", + locale_code: "fr-FR", + translations: { + title: "Variante 1", + }, + }, + ], + }, + adminHeaders + ) + + const response = await api.get( + "/admin/translations/statistics?locales=fr-FR&entity_types=product&entity_types=product_variant", + adminHeaders + ) + + expect(response.status).toEqual(200) + + // Product: 1 × 5 fields × 1 locale = 5, all translated + expect(response.data.statistics.product).toEqual({ + expected: 5, + translated: 5, + missing: 0, + by_locale: { + "fr-FR": { expected: 5, translated: 5, missing: 0 }, + }, + }) + + // Variant: 2 × 2 fields × 1 locale = 4, 1 translated + expect(response.data.statistics.product_variant).toEqual({ + expected: 4, + translated: 1, + missing: 3, + by_locale: { + "fr-FR": { expected: 4, translated: 1, missing: 3 }, + }, + }) + }) + + it("should return statistics for multiple locales", async () => { + const productModule = appContainer.resolve(Modules.PRODUCT) + const [product] = await productModule.createProducts([ + { title: "Product" }, + ]) + + await api.post( + "/admin/translations/batch", + { + create: [ + { + reference_id: product.id, + reference: "product", + locale_code: "fr-FR", + translations: { + title: "Produit", + description: "Description", + }, + }, + { + reference_id: product.id, + reference: "product", + locale_code: "de-DE", + translations: { title: "Produkt" }, + }, + ], + }, + adminHeaders + ) + + const response = await api.get( + "/admin/translations/statistics?locales=fr-FR&locales=de-DE&entity_types=product", + adminHeaders + ) + + expect(response.status).toEqual(200) + // 1 product × 5 fields × 2 locales = 10 expected + // fr-FR: 2 translated, de-DE: 1 translated = 3 total + expect(response.data.statistics.product.expected).toEqual(10) + expect(response.data.statistics.product.translated).toEqual(3) + expect(response.data.statistics.product.missing).toEqual(7) + + expect(response.data.statistics.product.by_locale["fr-FR"]).toEqual({ + expected: 5, + translated: 2, + missing: 3, + }) + expect(response.data.statistics.product.by_locale["de-DE"]).toEqual({ + expected: 5, + translated: 1, + missing: 4, + }) + }) + + it("should return zeros for unknown entity types", async () => { + const response = await api.get( + "/admin/translations/statistics?locales=fr-FR&entity_types=unknown_entity", + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.statistics.unknown_entity).toEqual({ + expected: 0, + translated: 0, + missing: 0, + by_locale: { + "fr-FR": { expected: 0, translated: 0, missing: 0 }, + }, + }) + }) + + it("should validate required fields", async () => { + // Missing locales + const response1 = await api + .get( + "/admin/translations/statistics?entity_types=product", + adminHeaders + ) + .catch((e) => e.response) + + expect(response1.status).toEqual(400) + + // Missing entity_types + const response2 = await api + .get("/admin/translations/statistics?locales=fr-FR", adminHeaders) + .catch((e) => e.response) + + expect(response2.status).toEqual(400) + + // Both missing + const response3 = await api + .get("/admin/translations/statistics", adminHeaders) + .catch((e) => e.response) + + expect(response3.status).toEqual(400) + }) + }) }) }, }) diff --git a/packages/core/types/src/http/translations/admin/queries.ts b/packages/core/types/src/http/translations/admin/queries.ts index 3c872857f2..1a3deeceb4 100644 --- a/packages/core/types/src/http/translations/admin/queries.ts +++ b/packages/core/types/src/http/translations/admin/queries.ts @@ -21,3 +21,18 @@ export interface AdminTranslationsListParams */ locale_code?: string | string[] } + +/** + * Request body for translation statistics endpoint. + */ +export interface AdminTranslationStatisticsParams { + /** + * The locales to check translations for (e.g., ["en-US", "fr-FR"]). + */ + locales: string[] + + /** + * The entity types to get statistics for (e.g., ["product", "product_variant"]). + */ + entity_types: string[] +} diff --git a/packages/core/types/src/http/translations/admin/responses.ts b/packages/core/types/src/http/translations/admin/responses.ts index 058fe553e6..ebac61820f 100644 --- a/packages/core/types/src/http/translations/admin/responses.ts +++ b/packages/core/types/src/http/translations/admin/responses.ts @@ -33,3 +33,58 @@ export interface AdminTranslationsBatchResponse { deleted: boolean } } + +/** + * Statistics for a specific locale. + */ +export interface AdminTranslationLocaleStatistics { + /** + * Expected number of translated fields. + */ + expected: number + /** + * Actual number of translated fields. + */ + translated: number + /** + * Number of missing translations. + */ + missing: number +} + +/** + * Statistics for an entity type. + */ +export interface AdminTranslationEntityStatistics + extends AdminTranslationLocaleStatistics { + /** + * Breakdown of statistics by locale. + */ + by_locale: Record +} + +/** + * Response for translation statistics endpoint. + */ +export interface AdminTranslationStatisticsResponse { + /** + * Statistics by entity type. + */ + statistics: Record +} + +/** + * Response for translation settings endpoint. + */ +export interface AdminTranslationSettingsResponse { + /** + * A mapping of entity types to their translatable field names. + * + * @example + * { + * "product": ["title", "description", "subtitle", "status"], + * "product_variant": ["title", "material"] + * } + */ + translatable_fields: Record +} diff --git a/packages/core/types/src/translation/common.ts b/packages/core/types/src/translation/common.ts index b659bed4ba..3a617a63d5 100644 --- a/packages/core/types/src/translation/common.ts +++ b/packages/core/types/src/translation/common.ts @@ -132,3 +132,55 @@ export interface FilterableTranslationProps */ locale_code?: string | string[] | OperatorMap } + +/** + * Input for getStatistics method. + */ +export interface TranslationStatisticsInput { + /** + * Locales to check translations for (e.g., ["en-US", "fr-FR"]). + */ + locales: string[] + + /** + * Entity types with their total counts. + * Key is the entity type (e.g., "product"), value contains the count of entities. + */ + entities: Record +} + +/** + * Statistics for a specific locale. + */ +export interface LocaleStatistics { + /** + * Expected number of translated fields. + */ + expected: number + + /** + * Actual number of translated fields (non-null, non-empty). + */ + translated: number + + /** + * Number of missing translations (expected - translated). + */ + missing: number +} + +/** + * Statistics for an entity type. + */ +export interface EntityTypeStatistics extends LocaleStatistics { + /** + * Breakdown of statistics by locale. + */ + by_locale: Record +} + +/** + * Output of getStatistics method. + * Maps entity types to their translation statistics. + */ +export type TranslationStatisticsOutput = Record diff --git a/packages/core/types/src/translation/service.ts b/packages/core/types/src/translation/service.ts index 08a6971087..b0855447e3 100644 --- a/packages/core/types/src/translation/service.ts +++ b/packages/core/types/src/translation/service.ts @@ -7,6 +7,8 @@ import { FilterableTranslationProps, LocaleDTO, TranslationDTO, + TranslationStatisticsInput, + TranslationStatisticsOutput, } from "./common" import { CreateLocaleDTO, @@ -291,4 +293,57 @@ export interface ITranslationModuleService extends IModuleService { config?: RestoreReturn, sharedContext?: Context ): Promise | void> + + /** + * This method retrieves translation statistics for the given entities and locales. + * It counts translated fields at a granular level, providing both aggregated + * and per-locale breakdowns. + * + * @param {TranslationStatisticsInput} input - The entities and locales to check. + * @param {Context} sharedContext + * @returns {Promise} Statistics by entity type. + * + * @example + * const stats = await translationService.getStatistics({ + * locales: ["en-US", "fr-FR"], + * entities: { + * product: { count: 2 }, + * product_variant: { count: 5 } + * } + * }) + * // Returns: + * // { + * // product: { + * // expected: 20, // 2 products × 5 fields × 2 locales + * // translated: 15, + * // missing: 5, + * // by_locale: { + * // "en-US": { expected: 10, translated: 8, missing: 2 }, + * // "fr-FR": { expected: 10, translated: 7, missing: 3 } + * // } + * // } + * // } + */ + getStatistics( + input: TranslationStatisticsInput, + sharedContext?: Context + ): Promise + + /** + * This method retrieves the translatable fields configuration. + * Returns a mapping of entity types to their translatable field names. + * + * @param {string} entityType - Optional entity type to filter by. If not provided, returns all. + * @returns {Record} A mapping of entity types to their translatable fields. + * + * @example + * // Get all translatable fields + * const allFields = translationService.getTranslatableFields() + * // Returns: { product: ["title", "description", ...], product_variant: ["title", ...] } + * + * // Get fields for a specific entity type + * const productFields = translationService.getTranslatableFields("product") + * // Returns: { product: ["title", "description", "subtitle", "status"] } + */ + getTranslatableFields(entityType?: string): Record } diff --git a/packages/medusa/src/api/admin/translations/middlewares.ts b/packages/medusa/src/api/admin/translations/middlewares.ts index d68d96a358..9aa9b000fd 100644 --- a/packages/medusa/src/api/admin/translations/middlewares.ts +++ b/packages/medusa/src/api/admin/translations/middlewares.ts @@ -6,6 +6,7 @@ import { import { AdminBatchTranslations, AdminGetTranslationsParams, + AdminTranslationStatistics, } from "./validators" import * as QueryConfig from "./query-config" import { DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT } from "../../../utils" @@ -29,4 +30,14 @@ export const adminTranslationsRoutesMiddlewares: MiddlewareRoute[] = [ }, middlewares: [validateAndTransformBody(AdminBatchTranslations)], }, + { + method: ["GET"], + matcher: "/admin/translations/statistics", + middlewares: [validateAndTransformQuery(AdminTranslationStatistics, {})], + }, + { + method: ["GET"], + matcher: "/admin/translations/settings", + middlewares: [], + }, ] diff --git a/packages/medusa/src/api/admin/translations/settings/route.ts b/packages/medusa/src/api/admin/translations/settings/route.ts new file mode 100644 index 0000000000..5f84359faa --- /dev/null +++ b/packages/medusa/src/api/admin/translations/settings/route.ts @@ -0,0 +1,29 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { HttpTypes, ITranslationModuleService } from "@medusajs/framework/types" +import { + defineFileConfig, + FeatureFlag, + Modules, +} from "@medusajs/framework/utils" +import TranslationFeatureFlag from "../../../../feature-flags/translation" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const translationService = req.scope.resolve( + Modules.TRANSLATION + ) + const translatable_fields = translationService.getTranslatableFields() + + res.json({ + translatable_fields, + }) +} + +defineFileConfig({ + isDisabled: () => !FeatureFlag.isFeatureEnabled(TranslationFeatureFlag.key), +}) diff --git a/packages/medusa/src/api/admin/translations/statistics/route.ts b/packages/medusa/src/api/admin/translations/statistics/route.ts new file mode 100644 index 0000000000..fd4de06c50 --- /dev/null +++ b/packages/medusa/src/api/admin/translations/statistics/route.ts @@ -0,0 +1,75 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { HttpTypes, ITranslationModuleService } from "@medusajs/framework/types" +import { + ContainerRegistrationKeys, + defineFileConfig, + FeatureFlag, + Modules, + promiseAll, +} from "@medusajs/framework/utils" +import TranslationFeatureFlag from "../../../../feature-flags/translation" + +export const GET = async ( + req: AuthenticatedMedusaRequest< + unknown, + HttpTypes.AdminTranslationStatisticsParams + >, + res: MedusaResponse +) => { + const translationService = req.scope.resolve( + Modules.TRANSLATION + ) + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { locales, entity_types } = req.validatedQuery + + // Fetch counts for each entity type in parallel + const entityCounts = await promiseAll( + entity_types.map(async (entityType) => { + const { metadata } = await query + .graph( + { + entity: entityType, + fields: ["id"], + pagination: { take: 1, skip: 0 }, + }, + { + throwIfKeyNotFound: false, + cache: { enable: true }, + } + ) + .catch((e) => { + const normalizedMessage = e.message.toLowerCase() + if ( + normalizedMessage.includes("service with alias") && + normalizedMessage.includes("was not found") + ) { + return { metadata: { count: 0 } } + } + throw e + }) + return { entityType, count: metadata?.count ?? 0 } + }) + ) + + const entities: Record = {} + for (const { entityType, count } of entityCounts) { + entities[entityType] = { count } + } + + const statistics = await translationService.getStatistics({ + locales, + entities, + }) + + return res.json({ + statistics, + }) +} + +defineFileConfig({ + isDisabled: () => !FeatureFlag.isFeatureEnabled(TranslationFeatureFlag.key), +}) diff --git a/packages/medusa/src/api/admin/translations/validators.ts b/packages/medusa/src/api/admin/translations/validators.ts index 2eb3396b0a..2757184336 100644 --- a/packages/medusa/src/api/admin/translations/validators.ts +++ b/packages/medusa/src/api/admin/translations/validators.ts @@ -48,3 +48,19 @@ export const AdminBatchTranslations = createBatchBody( AdminCreateTranslation, AdminUpdateTranslation ) + +export type AdminTranslationStatisticsType = z.infer< + typeof AdminTranslationStatistics +> +export const AdminTranslationStatistics = z + .object({ + locales: z.union([z.string(), z.array(z.string())]), + entity_types: z.union([z.string(), z.array(z.string())]), + }) + .transform((data) => ({ + // Normalize to arrays for consistent handling + locales: Array.isArray(data.locales) ? data.locales : [data.locales], + entity_types: Array.isArray(data.entity_types) + ? data.entity_types + : [data.entity_types], + })) diff --git a/packages/modules/translation/integration-tests/__tests__/translation-module-service.spec.ts b/packages/modules/translation/integration-tests/__tests__/translation-module-service.spec.ts index b27ec72a63..c198f3a297 100644 --- a/packages/modules/translation/integration-tests/__tests__/translation-module-service.spec.ts +++ b/packages/modules/translation/integration-tests/__tests__/translation-module-service.spec.ts @@ -646,6 +646,330 @@ moduleIntegrationTestRunner({ }) }) }) + + describe("Statistics", () => { + describe("getStatistics", () => { + it("should return statistics for a single entity type and locale", async () => { + // Create translations for 2 products with some fields filled + // Product has 4 translatable fields: title, description, subtitle, status + await service.createTranslations([ + { + reference_id: "prod_stat_1", + reference: "product", + locale_code: "en-US", + translations: { + title: "Product 1", + description: "Description 1", + // subtitle and status are missing + }, + }, + { + reference_id: "prod_stat_2", + reference: "product", + locale_code: "en-US", + translations: { + title: "Product 2", + description: "Description 2", + subtitle: "Subtitle 2", + status: "Active", + }, + }, + ]) + + const stats = await service.getStatistics({ + locales: ["en-US"], + entities: { + product: { count: 2 }, + }, + }) + + // Expected: 2 products × 5 fields × 1 locale = 10 + // Translated: prod_1 has 2, prod_2 has 4 = 6 + expect(stats.product).toEqual({ + expected: 10, + translated: 6, + missing: 4, + by_locale: { + "en-US": { + expected: 10, + translated: 6, + missing: 4, + }, + }, + }) + }) + + it("should return statistics for multiple locales", async () => { + await service.createTranslations([ + { + reference_id: "prod_multi_1", + reference: "product", + locale_code: "en-US", + translations: { + title: "Product 1 EN", + description: "Description EN", + }, + }, + { + reference_id: "prod_multi_1", + reference: "product", + locale_code: "fr-FR", + translations: { + title: "Produit 1 FR", + // only title translated for French + }, + }, + ]) + + const stats = await service.getStatistics({ + locales: ["en-US", "fr-FR"], + entities: { + product: { count: 1 }, + }, + }) + + // Expected per locale: 1 product × 5 fields = 5 + // Total expected: 5 × 2 locales = 10 + expect(stats.product.expected).toEqual(10) + expect(stats.product.translated).toEqual(3) // 2 EN + 1 FR + expect(stats.product.missing).toEqual(7) + + expect(stats.product.by_locale["en-US"]).toEqual({ + expected: 5, + translated: 2, + missing: 3, + }) + + expect(stats.product.by_locale["fr-FR"]).toEqual({ + expected: 5, + translated: 1, + missing: 4, + }) + }) + + it("should return statistics for multiple entity types", async () => { + await service.createTranslations([ + { + reference_id: "prod_type_1", + reference: "product", + locale_code: "en-US", + translations: { + title: "Product Title", + description: "Product Description", + subtitle: "Product Subtitle", + status: "Active", + material: "Product Material", + }, + }, + { + reference_id: "var_type_1", + reference: "product_variant", + locale_code: "en-US", + translations: { + title: "Variant Title", + // material missing + }, + }, + ]) + + const stats = await service.getStatistics({ + locales: ["en-US"], + entities: { + product: { count: 1 }, + product_variant: { count: 1 }, + }, + }) + + // Product: 1 × 5 fields = 5 expected, 5 translated + expect(stats.product).toEqual({ + expected: 5, + translated: 5, + missing: 0, + by_locale: { + "en-US": { expected: 5, translated: 5, missing: 0 }, + }, + }) + + // Variant: 1 × 2 fields = 2 expected, 1 translated + expect(stats.product_variant).toEqual({ + expected: 2, + translated: 1, + missing: 1, + by_locale: { + "en-US": { expected: 2, translated: 1, missing: 1 }, + }, + }) + }) + + it("should return zeros for entity types not in config", async () => { + const stats = await service.getStatistics({ + locales: ["en-US"], + entities: { + unknown_entity: { count: 10 }, + }, + }) + + expect(stats.unknown_entity).toEqual({ + expected: 0, + translated: 0, + missing: 0, + by_locale: { + "en-US": { expected: 0, translated: 0, missing: 0 }, + }, + }) + }) + + it("should return all missing when no translations exist", async () => { + const stats = await service.getStatistics({ + locales: ["en-US", "fr-FR"], + entities: { + product: { count: 5 }, + }, + }) + + // 5 products × 5 fields × 2 locales = 50 expected, 0 translated + expect(stats.product).toEqual({ + expected: 50, + translated: 0, + missing: 50, + by_locale: { + "en-US": { expected: 25, translated: 0, missing: 25 }, + "fr-FR": { expected: 25, translated: 0, missing: 25 }, + }, + }) + }) + + it("should ignore empty string and null values in translations", async () => { + await service.createTranslations([ + { + reference_id: "prod_empty_1", + reference: "product", + locale_code: "en-US", + translations: { + title: "Valid Title", + description: "", // empty string should not count + subtitle: "Valid Subtitle", + }, + }, + ]) + + const stats = await service.getStatistics({ + locales: ["en-US"], + entities: { + product: { count: 1 }, + }, + }) + + // Only title, subtitle and material should count (3), not empty description + expect(stats.product.translated).toEqual(2) + expect(stats.product.missing).toEqual(3) + }) + + it("should normalize locale codes", async () => { + await service.createTranslations([ + { + reference_id: "prod_norm_1", + reference: "product", + locale_code: "en-us", + translations: { + title: "Product Title", + }, + }, + ]) + + const stats = await service.getStatistics({ + locales: ["EN-US"], + entities: { + product: { count: 1 }, + }, + }) + + expect(stats.product.translated).toEqual(1) + }) + + it("should throw error when no locales provided", async () => { + const error = await service + .getStatistics({ + locales: [], + entities: { product: { count: 1 } }, + }) + .catch((e) => e) + + expect(error.message).toContain( + "At least one locale must be provided" + ) + }) + + it("should throw error when no entities provided", async () => { + const error = await service + .getStatistics({ + locales: ["en-US"], + entities: {}, + }) + .catch((e) => e) + + expect(error.message).toContain( + "At least one entity type must be provided" + ) + }) + + it("should handle large entity counts correctly", async () => { + // This tests that the expected calculation works with large numbers + // without actually creating that many translations + const stats = await service.getStatistics({ + locales: ["en-US", "fr-FR", "de-DE"], + entities: { + product: { count: 10000 }, + product_variant: { count: 50000 }, + }, + }) + + // Product: 10000 × 5 fields × 3 locales = 150000 + expect(stats.product.expected).toEqual(150000) + expect(stats.product.translated).toEqual(0) + expect(stats.product.missing).toEqual(150000) + + // Variant: 50000 × 2 fields × 3 locales = 300000 + expect(stats.product_variant.expected).toEqual(300000) + expect(stats.product_variant.translated).toEqual(0) + expect(stats.product_variant.missing).toEqual(300000) + }) + + it("should update statistics after translation is updated", async () => { + const created = await service.createTranslations({ + reference_id: "prod_update_stat_1", + reference: "product", + locale_code: "en-US", + translations: { + title: "Product Title", + // only 1 of 5 fields + }, + }) + + let stats = await service.getStatistics({ + locales: ["en-US"], + entities: { product: { count: 1 } }, + }) + expect(stats.product.translated).toEqual(1) + + await service.updateTranslations({ + id: created.id, + translations: { + title: "Product Title", + description: "Product Description", + subtitle: "Product Subtitle", + }, + }) + + stats = await service.getStatistics({ + locales: ["en-US"], + entities: { product: { count: 1 } }, + }) + expect(stats.product.translated).toEqual(3) + expect(stats.product.missing).toEqual(2) + }) + }) + }) }) }, }) diff --git a/packages/modules/translation/src/index.ts b/packages/modules/translation/src/index.ts index 98838ed94c..1ddb1b2843 100644 --- a/packages/modules/translation/src/index.ts +++ b/packages/modules/translation/src/index.ts @@ -1,10 +1,11 @@ import TranslationModuleService from "@services/translation-module" import loadDefaults from "./loaders/defaults" +import loadConfig from "./loaders/config" import { Module } from "@medusajs/framework/utils" export const TRANSLATION_MODULE = "translation" export default Module(TRANSLATION_MODULE, { service: TranslationModuleService, - loaders: [loadDefaults], + loaders: [loadDefaults, loadConfig], }) diff --git a/packages/modules/translation/src/loaders/config.ts b/packages/modules/translation/src/loaders/config.ts new file mode 100644 index 0000000000..acde4737a9 --- /dev/null +++ b/packages/modules/translation/src/loaders/config.ts @@ -0,0 +1,52 @@ +import { LoaderOptions } from "@medusajs/framework/types" +import { + PRODUCT_TRANSLATABLE_FIELDS, + PRODUCT_VARIANT_TRANSLATABLE_FIELDS, +} from "../utils/translatable-fields" +import { asValue } from "awilix" +import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants" + +export default async ({ + container, + options, +}: LoaderOptions<{ + expandedTranslatableFields: { [key: string]: string[] } +}>): Promise => { + const { expandedTranslatableFields } = options ?? {} + + const { product, productVariant, ...others } = + expandedTranslatableFields ?? {} + + const translatableFieldsConfig: Record = { + product: PRODUCT_TRANSLATABLE_FIELDS, + product_variant: PRODUCT_VARIANT_TRANSLATABLE_FIELDS, + } + + if (product) { + const translatableFields = new Set([ + ...PRODUCT_TRANSLATABLE_FIELDS, + ...product, + ]) + translatableFieldsConfig.product = Array.from(translatableFields) + } + + if (productVariant) { + const translatableFields = new Set([ + ...PRODUCT_VARIANT_TRANSLATABLE_FIELDS, + ...productVariant, + ]) + translatableFieldsConfig.product_variant = Array.from(translatableFields) + } + + if (others) { + Object.entries(others).forEach(([key, value]) => { + const translatableFields = new Set([...value]) + translatableFieldsConfig[key] = Array.from(translatableFields) + }) + } + + container.register( + TRANSLATABLE_FIELDS_CONFIG_KEY, + asValue(translatableFieldsConfig) + ) +} diff --git a/packages/modules/translation/src/migrations/.snapshot-medusa-translation.json b/packages/modules/translation/src/migrations/.snapshot-medusa-translation.json index 1aed639d90..36722eeeee 100644 --- a/packages/modules/translation/src/migrations/.snapshot-medusa-translation.json +++ b/packages/modules/translation/src/migrations/.snapshot-medusa-translation.json @@ -149,6 +149,16 @@ "nullable": false, "mappedType": "json" }, + "translated_field_count": { + "name": "translated_field_count", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "0", + "mappedType": "integer" + }, "created_at": { "name": "created_at", "type": "timestamptz", diff --git a/packages/modules/translation/src/migrations/Migration20251215083927.ts b/packages/modules/translation/src/migrations/Migration20251215083927.ts new file mode 100644 index 0000000000..b52f60fd24 --- /dev/null +++ b/packages/modules/translation/src/migrations/Migration20251215083927.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251215083927 extends Migration { + + override async up(): Promise { + this.addSql(`alter table if exists "translation" add column if not exists "translated_field_count" integer not null default 0;`); + } + + override async down(): Promise { + this.addSql(`alter table if exists "translation" drop column if exists "translated_field_count";`); + } + +} diff --git a/packages/modules/translation/src/models/translation.ts b/packages/modules/translation/src/models/translation.ts index 600349e271..f97cc650cc 100644 --- a/packages/modules/translation/src/models/translation.ts +++ b/packages/modules/translation/src/models/translation.ts @@ -6,7 +6,8 @@ const Translation = model 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": "..." } + translations: model.json(), + translated_field_count: model.number().default(0), // Precomputed count of translated fields for performance }) .indexes([ { diff --git a/packages/modules/translation/src/services/translation-module.ts b/packages/modules/translation/src/services/translation-module.ts index 01c5dc6141..be31045555 100644 --- a/packages/modules/translation/src/services/translation-module.ts +++ b/packages/modules/translation/src/services/translation-module.ts @@ -10,20 +10,25 @@ import { ModulesSdkTypes, TranslationTypes, } from "@medusajs/framework/types" +import { SqlEntityManager } from "@medusajs/framework/mikro-orm/postgresql" import { EmitEvents, InjectManager, MedusaContext, + MedusaError, MedusaService, normalizeLocale, } from "@medusajs/framework/utils" import Locale from "@models/locale" import Translation from "@models/translation" +import { computeTranslatedFieldCount } from "@utils/compute-translated-field-count" +import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants" type InjectedDependencies = { baseRepository: DAL.RepositoryService translationService: ModulesSdkTypes.IMedusaInternalService localeService: ModulesSdkTypes.IMedusaInternalService + [TRANSLATABLE_FIELDS_CONFIG_KEY]: Record } export default class TranslationModuleService @@ -48,15 +53,19 @@ export default class TranslationModuleService typeof Locale > + private readonly translatableFieldsConfig_: Record + constructor({ baseRepository, translationService, localeService, + translatableFieldsConfig, }: InjectedDependencies) { super(...arguments) this.baseRepository_ = baseRepository this.translationService_ = translationService this.localeService_ = localeService + this.translatableFieldsConfig_ = translatableFieldsConfig } static prepareFilters( @@ -177,6 +186,10 @@ export default class TranslationModuleService const normalizedData = dataArray.map((translation) => ({ ...translation, locale_code: normalizeLocale(translation.locale_code), + translated_field_count: computeTranslatedFieldCount( + translation.translations as Record, + this.translatableFieldsConfig_[translation.reference] + ), })) const createdTranslations = await this.translationService_.create( @@ -190,4 +203,193 @@ export default class TranslationModuleService return Array.isArray(data) ? serialized : serialized[0] } + + // @ts-expect-error + updateTranslations( + data: TranslationTypes.UpdateTranslationDTO, + sharedContext?: Context + ): Promise + // @ts-expect-error + updateTranslations( + data: TranslationTypes.UpdateTranslationDTO[], + sharedContext?: Context + ): Promise + + @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 = {} + + 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]) + ) + } + + 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, + this.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] + } + + getTranslatableFields(entityType?: string): Record { + if (entityType) { + return { [entityType]: this.translatableFieldsConfig_[entityType] } + } + return this.translatableFieldsConfig_ + } + + @InjectManager() + async getStatistics( + input: TranslationTypes.TranslationStatisticsInput, + @MedusaContext() sharedContext: Context = {} + ): Promise { + 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 result: TranslationTypes.TranslationStatisticsOutput = {} + const entityTypes: string[] = [] + + for (const entityType of Object.keys(entities)) { + const translatableFields = this.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 = this.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 + } } diff --git a/packages/modules/translation/src/utils/compute-translated-field-count.ts b/packages/modules/translation/src/utils/compute-translated-field-count.ts new file mode 100644 index 0000000000..49a99974bf --- /dev/null +++ b/packages/modules/translation/src/utils/compute-translated-field-count.ts @@ -0,0 +1,23 @@ +/** + * Computes the count of translated fields based on the translatable fields configuration. + * Only counts fields that are: + * 1. In the translatableFields array for the entity type + * 2. Have a non-null, non-empty value in the translations object + * + * @param translations - The translations JSON object from the translation record + * @param translatableFields - Array of field names that are translatable for this entity type + * @returns The count of translated fields + */ +export function computeTranslatedFieldCount( + translations: Record | undefined | null, + translatableFields: string[] | undefined | null +): number { + if (!translations || !translatableFields?.length) { + return 0 + } + + return translatableFields.filter((field) => { + const value = translations[field] + return value != null && value !== "" && value !== "null" + }).length +} diff --git a/packages/modules/translation/src/utils/constants.ts b/packages/modules/translation/src/utils/constants.ts new file mode 100644 index 0000000000..3d987a9703 --- /dev/null +++ b/packages/modules/translation/src/utils/constants.ts @@ -0,0 +1 @@ +export const TRANSLATABLE_FIELDS_CONFIG_KEY = "translatableFieldsConfig" diff --git a/packages/modules/translation/src/utils/translatable-fields.ts b/packages/modules/translation/src/utils/translatable-fields.ts new file mode 100644 index 0000000000..7276f8b26b --- /dev/null +++ b/packages/modules/translation/src/utils/translatable-fields.ts @@ -0,0 +1,9 @@ +export const PRODUCT_TRANSLATABLE_FIELDS = [ + "title", + "description", + "material", + "subtitle", + "status", +] + +export const PRODUCT_VARIANT_TRANSLATABLE_FIELDS = ["title", "material"]