From 243f70b00ce9a1b91d5b6988e28b2dfe36f21ab1 Mon Sep 17 00:00:00 2001 From: HushLuxe Date: Sat, 21 Feb 2026 16:55:47 +0100 Subject: [PATCH] feat: implement internationalization (i18n) system (Closes #158) --- package-lock.json | 93 +++++++++++++++---- package.json | 2 + src/app.module.ts | 4 +- .../i18n/entities/translation.entity.ts | 37 ++++++++ src/common/i18n/locales/en/common.json | 6 ++ src/common/i18n/locales/es/common.json | 6 ++ src/common/i18n/localization.module.ts | 36 +++++++ src/common/i18n/localization.service.spec.ts | 93 +++++++++++++++++++ src/common/i18n/localization.service.ts | 66 +++++++++++++ src/common/i18n/translations.controller.ts | 61 ++++++++++++ src/common/i18n/user-preference.resolver.ts | 16 ++++ src/database/entities.ts | 1 + .../1740156000000-CreateTranslationTable.ts | 79 ++++++++++++++++ src/puzzles/puzzles.module.ts | 4 +- src/puzzles/puzzles.service.ts | 39 +++++--- src/users/entities/user.entity.ts | 1 + 16 files changed, 515 insertions(+), 29 deletions(-) create mode 100644 src/common/i18n/entities/translation.entity.ts create mode 100644 src/common/i18n/locales/en/common.json create mode 100644 src/common/i18n/locales/es/common.json create mode 100644 src/common/i18n/localization.module.ts create mode 100644 src/common/i18n/localization.service.spec.ts create mode 100644 src/common/i18n/localization.service.ts create mode 100644 src/common/i18n/translations.controller.ts create mode 100644 src/common/i18n/user-preference.resolver.ts create mode 100644 src/migrations/1740156000000-CreateTranslationTable.ts diff --git a/package-lock.json b/package-lock.json index 6dfd917..760b9fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,8 +46,10 @@ "class-validator": "^0.14.0", "csv-parser": "^3.2.0", "helmet": "^7.0.0", + "i18next": "^25.8.13", "ioredis": "^5.9.3", "nest-winston": "^1.9.0", + "nestjs-i18n": "^10.6.0", "nodemailer": "^8.0.1", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", @@ -1679,6 +1681,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -5577,6 +5588,12 @@ "license": "ISC", "optional": true }, + "node_modules/accept-language-parser": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz", + "integrity": "sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -5856,7 +5873,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -5870,7 +5886,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -6321,7 +6336,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6471,7 +6485,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6869,7 +6882,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -8534,7 +8546,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -8817,7 +8828,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8975,7 +8985,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -9304,6 +9313,37 @@ "ms": "^2.0.0" } }, + "node_modules/i18next": { + "version": "25.8.13", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.13.tgz", + "integrity": "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -9547,7 +9587,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -9588,7 +9627,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9630,7 +9668,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -9688,7 +9725,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -11549,6 +11585,29 @@ "winston": "^3.0.0" } }, + "node_modules/nestjs-i18n": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/nestjs-i18n/-/nestjs-i18n-10.6.0.tgz", + "integrity": "sha512-fPOgwrnb8u8UO6YXNlnamF7GDhNLOHQE8hD/pT/L7oUQibvL7PBZYZgquwppOFRaOPnH0uING2BzQGq7uQNNmQ==", + "license": "MIT", + "dependencies": { + "accept-language-parser": "^1.5.0", + "chokidar": "^3.6.0", + "cookie": "^0.7.0", + "iterare": "^1.2.1", + "js-yaml": "^4.1.0", + "string-format": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@nestjs/common": "*", + "@nestjs/core": "*", + "class-validator": "*", + "rxjs": "*" + } + }, "node_modules/next-line": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/next-line/-/next-line-1.1.0.tgz", @@ -11714,7 +11773,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12732,7 +12790,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -12745,7 +12802,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -13860,6 +13916,12 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz", + "integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==", + "license": "WTFPL OR MIT" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -14417,7 +14479,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" diff --git a/package.json b/package.json index fb05640..be113d8 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,10 @@ "class-validator": "^0.14.0", "csv-parser": "^3.2.0", "helmet": "^7.0.0", + "i18next": "^25.8.13", "ioredis": "^5.9.3", "nest-winston": "^1.9.0", + "nestjs-i18n": "^10.6.0", "nodemailer": "^8.0.1", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index 87c4c6b..3df4496 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -38,6 +38,7 @@ import { AntiCheatModule } from './anti-cheat/anti-cheat.module'; import { QuestsModule } from './quests/quests.module'; import { BlockchainTransactionModule } from './blockchain-transaction/blockchain-transaction.module'; import { PrivacyModule } from './privacy/privacy.module'; +import { LocalizationModule } from './common/i18n/localization.module'; @Module({ imports: [ @@ -117,6 +118,7 @@ import { PrivacyModule } from './privacy/privacy.module'; QuestsModule, BlockchainTransactionModule, PrivacyModule, + LocalizationModule, ], controllers: [AppController], providers: [ @@ -127,4 +129,4 @@ import { PrivacyModule } from './privacy/privacy.module'; }, ], }) -export class AppModule {} \ No newline at end of file +export class AppModule { } \ No newline at end of file diff --git a/src/common/i18n/entities/translation.entity.ts b/src/common/i18n/entities/translation.entity.ts new file mode 100644 index 0000000..978be58 --- /dev/null +++ b/src/common/i18n/entities/translation.entity.ts @@ -0,0 +1,37 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity('translations') +@Index(['key', 'locale'], { unique: true }) +@Index(['namespace']) +export class Translation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 255 }) + @Index() + key: string; + + @Column({ type: 'varchar', length: 10 }) + @Index() + locale: string; + + @Column({ type: 'text' }) + content: string; + + @Column({ type: 'varchar', length: 50, default: 'common' }) + @Index() + namespace: string; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updatedAt: Date; +} diff --git a/src/common/i18n/locales/en/common.json b/src/common/i18n/locales/en/common.json new file mode 100644 index 0000000..9d29357 --- /dev/null +++ b/src/common/i18n/locales/en/common.json @@ -0,0 +1,6 @@ +{ + "GREETING": "Hello", + "WELCOME": "Welcome to LogiQuest", + "PUZZLE_RESOLVED": "Puzzle successfully resolved!", + "ERROR_NOT_FOUND": "Requested resource not found" +} \ No newline at end of file diff --git a/src/common/i18n/locales/es/common.json b/src/common/i18n/locales/es/common.json new file mode 100644 index 0000000..0b2968f --- /dev/null +++ b/src/common/i18n/locales/es/common.json @@ -0,0 +1,6 @@ +{ + "GREETING": "Hola", + "WELCOME": "Bienvenido a LogiQuest", + "PUZZLE_RESOLVED": "¡Rompecabezas resuelto con éxito!", + "ERROR_NOT_FOUND": "Recurso solicitado no encontrado" +} \ No newline at end of file diff --git a/src/common/i18n/localization.module.ts b/src/common/i18n/localization.module.ts new file mode 100644 index 0000000..842e612 --- /dev/null +++ b/src/common/i18n/localization.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common'; +import { + AcceptLanguageResolver, + HeaderResolver, + I18nModule, + QueryResolver, +} from 'nestjs-i18n'; +import * as path from 'path'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Translation } from './entities/translation.entity'; +import { LocalizationService } from './localization.service'; +import { UserPreferenceResolver } from './user-preference.resolver'; +import { TranslationsController } from './translations.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Translation]), + I18nModule.forRoot({ + fallbackLanguage: 'en', + loaderOptions: { + path: path.join(__dirname, 'locales'), + watch: true, + }, + resolvers: [ + UserPreferenceResolver, + { use: QueryResolver, options: ['lang'] }, + AcceptLanguageResolver, + new HeaderResolver(['x-custom-lang']), + ], + }), + ], + providers: [LocalizationService, UserPreferenceResolver], + controllers: [TranslationsController], + exports: [I18nModule, LocalizationService], +}) +export class LocalizationModule { } diff --git a/src/common/i18n/localization.service.spec.ts b/src/common/i18n/localization.service.spec.ts new file mode 100644 index 0000000..5e8013d --- /dev/null +++ b/src/common/i18n/localization.service.spec.ts @@ -0,0 +1,93 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LocalizationService } from './localization.service'; +import { I18nService } from 'nestjs-i18n'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Translation } from './entities/translation.entity'; + +describe('LocalizationService', () => { + let service: LocalizationService; + let i18nService: I18nService; + let translationRepo: any; + + const mockI18nService = { + translate: jest.fn(), + }; + + const mockTranslationRepo = { + findOne: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LocalizationService, + { + provide: I18nService, + useValue: mockI18nService, + }, + { + provide: getRepositoryToken(Translation), + useValue: mockTranslationRepo, + }, + ], + }).compile(); + + service = module.get(LocalizationService); + i18nService = module.get(I18nService); + translationRepo = module.get(getRepositoryToken(Translation)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('translate', () => { + it('should return translation from database if exists', async () => { + const mockTranslation = { content: 'Translated Content' }; + translationRepo.findOne.mockResolvedValue(mockTranslation); + + const result = await service.translate('test-key', { lang: 'en' }); + + expect(result).toBe('Translated Content'); + expect(translationRepo.findOne).toHaveBeenCalledWith({ + where: { key: 'test-key', locale: 'en' }, + }); + expect(mockI18nService.translate).not.toHaveBeenCalled(); + }); + + it('should fallback to static i18n if not in database', async () => { + translationRepo.findOne.mockResolvedValue(null); + mockI18nService.translate.mockResolvedValue('Static Translation'); + + const result = await service.translate('static-key', { lang: 'en' }); + + expect(result).toBe('Static Translation'); + expect(mockI18nService.translate).toHaveBeenCalledWith('static-key', { + lang: 'en', + args: undefined, + }); + }); + + it('should return defaultValue if translation not found anywhere', async () => { + translationRepo.findOne.mockResolvedValue(null); + mockI18nService.translate.mockResolvedValue('static-key'); // nestjs-i18n returns key if not found + + const result = await service.translate('static-key', { + lang: 'en', + defaultValue: 'Default Value', + }); + + expect(result).toBe('Default Value'); + }); + + it('should return key if translation and defaultValue not found', async () => { + translationRepo.findOne.mockResolvedValue(null); + mockI18nService.translate.mockResolvedValue('missing-key'); + + const result = await service.translate('missing-key', { lang: 'en' }); + + expect(result).toBe('missing-key'); + }); + }); +}); diff --git a/src/common/i18n/localization.service.ts b/src/common/i18n/localization.service.ts new file mode 100644 index 0000000..84a4b4c --- /dev/null +++ b/src/common/i18n/localization.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { I18nContext, I18nService } from 'nestjs-i18n'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Translation } from './entities/translation.entity'; + +@Injectable() +export class LocalizationService { + constructor( + private readonly i18n: I18nService, + @InjectRepository(Translation) + private readonly translationRepo: Repository, + ) { } + + /** + * Translates a key using both static files and database-backed translations. + * @param key The translation key + * @param options Translation options (args, lang, defaultValue) + */ + async translate( + key: string, + options: { lang?: string; args?: any; defaultValue?: string } = {}, + ): Promise { + const lang = options.lang || I18nContext.current()?.lang || 'en'; + + // 1. Try to find in database first (dynamic content has priority) + const dbTranslation = await this.translationRepo.findOne({ + where: { key, locale: lang }, + }); + + if (dbTranslation) { + return dbTranslation.content; + } + + // 2. Fallback to static files + try { + const translated = await this.i18n.translate(key, { + lang, + args: options.args, + }); + + // If translation fails or returns the key نفسه, return defaultValue or key + if (translated === key && options.defaultValue) { + return options.defaultValue; + } + + return translated as string; + } catch (error) { + return options.defaultValue || key; + } + } + + /** + * Bulk translate keys for a specific locale + */ + async getTranslationsForLocale(locale: string, namespace?: string) { + const query = this.translationRepo.createQueryBuilder('translation') + .where('translation.locale = :locale', { locale }); + + if (namespace) { + query.andWhere('translation.namespace = :namespace', { namespace }); + } + + return await query.getMany(); + } +} diff --git a/src/common/i18n/translations.controller.ts b/src/common/i18n/translations.controller.ts new file mode 100644 index 0000000..a9e1c31 --- /dev/null +++ b/src/common/i18n/translations.controller.ts @@ -0,0 +1,61 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, + HttpStatus, + HttpCode, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Translation } from './entities/translation.entity'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; + +@ApiTags('Translations') +@Controller('translations') +export class TranslationsController { + constructor( + @InjectRepository(Translation) + private readonly translationRepo: Repository, + ) { } + + @Post() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Create or update a translation' }) + @ApiResponse({ status: 200, description: 'Translation saved successfully' }) + async upsert(@Body() data: Partial) { + const existing = await this.translationRepo.findOne({ + where: { key: data.key, locale: data.locale }, + }); + + if (existing) { + Object.assign(existing, data); + return await this.translationRepo.save(existing); + } + + const translation = this.translationRepo.create(data); + return await this.translationRepo.save(translation); + } + + @Get() + @ApiOperation({ summary: 'List all translations' }) + async findAll(@Query('locale') locale?: string, @Query('namespace') namespace?: string) { + const where: any = {}; + if (locale) where.locale = locale; + if (namespace) where.namespace = namespace; + + return await this.translationRepo.find({ where }); + } + + @Get(':key') + @ApiOperation({ summary: 'Get translations for a specific key' }) + async findByKey(@Param('key') key: string, @Query('locale') locale?: string) { + const where: any = { key }; + if (locale) where.locale = locale; + + return await this.translationRepo.find({ where }); + } +} diff --git a/src/common/i18n/user-preference.resolver.ts b/src/common/i18n/user-preference.resolver.ts new file mode 100644 index 0000000..e955cd5 --- /dev/null +++ b/src/common/i18n/user-preference.resolver.ts @@ -0,0 +1,16 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { I18nResolver } from 'nestjs-i18n'; + +@Injectable() +export class UserPreferenceResolver implements I18nResolver { + async resolve(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + + // If user is authenticated, check their preferences + if (req.user && req.user.preferences && req.user.preferences.language) { + return req.user.preferences.language; + } + + return undefined; + } +} diff --git a/src/database/entities.ts b/src/database/entities.ts index f8e68a4..c21cc5e 100644 --- a/src/database/entities.ts +++ b/src/database/entities.ts @@ -14,3 +14,4 @@ export { GameSession } from '../game-engine/entities/game-session.entity'; // Export new entities for collections and categories export { Category } from '../puzzles/entities/category.entity'; export { Collection } from '../puzzles/entities/collection.entity'; +export { Translation } from '../common/i18n/entities/translation.entity'; diff --git a/src/migrations/1740156000000-CreateTranslationTable.ts b/src/migrations/1740156000000-CreateTranslationTable.ts new file mode 100644 index 0000000..6555572 --- /dev/null +++ b/src/migrations/1740156000000-CreateTranslationTable.ts @@ -0,0 +1,79 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateTranslationTable1740156000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'translations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + isGenerated: true, + generationStrategy: 'uuid', + }, + { + name: 'key', + type: 'varchar', + length: '255', + isNullable: false, + }, + { + name: 'locale', + type: 'varchar', + length: '10', + isNullable: false, + }, + { + name: 'content', + type: 'text', + isNullable: false, + }, + { + name: 'namespace', + type: 'varchar', + length: '50', + default: "'common'", + isNullable: false, + }, + { + name: 'createdAt', + type: 'timestamp with time zone', + default: 'now()', + }, + { + name: 'updatedAt', + type: 'timestamp with time zone', + default: 'now()', + }, + ], + }), + true, + ); + + await queryRunner.createIndices('translations', [ + new TableIndex({ + name: 'IDX_TRANSLATIONS_KEY_LOCALE', + columnNames: ['key', 'locale'], + isUnique: true, + }), + new TableIndex({ + name: 'IDX_TRANSLATIONS_NAMESPACE', + columnNames: ['namespace'], + }), + new TableIndex({ + name: 'IDX_TRANSLATIONS_KEY', + columnNames: ['key'], + }), + new TableIndex({ + name: 'IDX_TRANSLATIONS_LOCALE', + columnNames: ['locale'], + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('translations'); + } +} diff --git a/src/puzzles/puzzles.module.ts b/src/puzzles/puzzles.module.ts index dd63026..89b68ff 100644 --- a/src/puzzles/puzzles.module.ts +++ b/src/puzzles/puzzles.module.ts @@ -17,9 +17,11 @@ import { CollectionsController } from './collection.controller'; import { Theme } from './entities/theme.entity'; // Import Theme entity import { ThemesService } from './theme.service'; // Import ThemesService import { ThemesController } from './theme.controller'; // Import ThemesController +import { LocalizationModule } from '../common/i18n/localization.module'; @Module({ imports: [ + LocalizationModule, TypeOrmModule.forFeature([ Puzzle, PuzzleProgress, @@ -43,4 +45,4 @@ import { ThemesController } from './theme.controller'; // Import ThemesControlle ], exports: [PuzzlesService] }) -export class PuzzlesModule {} \ No newline at end of file +export class PuzzlesModule { } \ No newline at end of file diff --git a/src/puzzles/puzzles.service.ts b/src/puzzles/puzzles.service.ts index cb2873b..21b09fd 100644 --- a/src/puzzles/puzzles.service.ts +++ b/src/puzzles/puzzles.service.ts @@ -4,15 +4,16 @@ import { Repository, SelectQueryBuilder, In, Between, IsNull, Not } from 'typeor import { Puzzle } from './entities/puzzle.entity'; import { PuzzleProgress } from '../game-logic/entities/puzzle-progress.entity'; import { PuzzleRating } from './entities/puzzle-rating.entity'; -import { - CreatePuzzleDto, - UpdatePuzzleDto, - SearchPuzzleDto, +import { LocalizationService } from '../common/i18n/localization.service'; +import { + CreatePuzzleDto, + UpdatePuzzleDto, + SearchPuzzleDto, BulkUpdateDto, BulkAction, SortBy, SortOrder, - PuzzleDifficulty + PuzzleDifficulty } from './dto'; export interface PuzzleWithStats { @@ -85,7 +86,8 @@ export class PuzzlesService { private progressRepository: Repository, @InjectRepository(PuzzleRating) private ratingRepository: Repository, - ) {} + private readonly localizationService: LocalizationService, + ) { } async create(createPuzzleDto: CreatePuzzleDto, createdBy: string): Promise { try { @@ -128,7 +130,7 @@ export class PuzzlesService { const puzzle = this.puzzleRepository.create(puzzleData); const savedPuzzle = await this.puzzleRepository.save(puzzle); this.logger.log(`Created puzzle: ${savedPuzzle.id} by user: ${createdBy}`); - + return savedPuzzle; } catch (error) { this.logger.error(`Failed to create puzzle: ${error.message}`, error.stack); @@ -241,6 +243,15 @@ export class PuzzlesService { } const [enhancedPuzzle] = await this.enhanceWithStats([puzzle]); + + // Translate title and description + enhancedPuzzle.title = await this.localizationService.translate(`puzzle-${puzzle.id}-title`, { + defaultValue: puzzle.title, + }); + enhancedPuzzle.description = await this.localizationService.translate(`puzzle-${puzzle.id}-description`, { + defaultValue: puzzle.description, + }); + return enhancedPuzzle; } catch (error) { this.logger.error(`Failed to find puzzle ${id}: ${error.message}`, error.stack); @@ -264,10 +275,10 @@ export class PuzzlesService { } await this.puzzleRepository.update(id, updateData); - + const updatedPuzzle = await this.findOne(id, userId); this.logger.log(`Updated puzzle: ${id}`); - + return updatedPuzzle; } catch (error) { this.logger.error(`Failed to update puzzle ${id}: ${error.message}`, error.stack); @@ -375,14 +386,20 @@ export class PuzzlesService { } private async enhanceWithStats(puzzles: Puzzle[]): Promise { - return puzzles.map(puzzle => ({ + return Promise.all(puzzles.map(async puzzle => ({ ...puzzle, + title: await this.localizationService.translate(`puzzle-${puzzle.id}-title`, { + defaultValue: puzzle.title, + }), + description: await this.localizationService.translate(`puzzle-${puzzle.id}-description`, { + defaultValue: puzzle.description, + }), totalPlays: puzzle.attempts, uniquePlayers: 0, completionRate: puzzle.attempts > 0 ? (puzzle.completions / puzzle.attempts) * 100 : 0, averageRating: puzzle.averageRating, averageCompletionTime: puzzle.averageCompletionTime - })); + }))); } private async executeBulkAction(puzzleId: string, bulkUpdateDto: BulkUpdateDto, userId: string): Promise { diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index a4e387a..1c670fd 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -81,6 +81,7 @@ export class User { @Column({ type: 'jsonb', default: {} }) preferences: { theme?: 'light' | 'dark' | 'auto'; + language?: string; difficulty?: 'easy' | 'medium' | 'hard' | 'expert'; notifications?: { email?: boolean;