From 4032e8aeae5284a913ece9a7a153b939966a0fc6 Mon Sep 17 00:00:00 2001 From: Fayvor22 Date: Tue, 2 Jun 2026 08:55:11 +0000 Subject: [PATCH] feat: implement multi-language support with i18n --- src/app.module.ts | 43 ++++------------ src/i18n/i18n.controller.ts | 21 ++++++++ src/i18n/i18n.middleware.ts | 18 +++++++ src/i18n/i18n.module.ts | 16 ++++++ src/i18n/i18n.service.ts | 88 +++++++++++++++++++++++++++++++++ src/i18n/locales/ar/common.json | 5 ++ src/i18n/locales/en/common.json | 5 ++ 7 files changed, 162 insertions(+), 34 deletions(-) create mode 100644 src/i18n/i18n.controller.ts create mode 100644 src/i18n/i18n.middleware.ts create mode 100644 src/i18n/i18n.module.ts create mode 100644 src/i18n/i18n.service.ts create mode 100644 src/i18n/locales/ar/common.json create mode 100644 src/i18n/locales/en/common.json diff --git a/src/app.module.ts b/src/app.module.ts index 005c1a5d..971f7f20 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; +import { APP_GUARD } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ScheduleModule } from '@nestjs/schedule'; @@ -8,7 +8,7 @@ import { AppController } from './app.controller'; import { SearchModule } from './search/search.module'; import { AnalyticsModule } from './analytics/analytics.module'; -import { MessagingModule } from './messaging/messaging.module'; +import { EmailModule } from './email-marketing/email.module'; import { IndexOptimizationModule } from './database/index-optimization/index-optimization.module'; import { RateLimitingModule } from './rate-limiting/rate-limiting.module'; import { QuotaGuard } from './rate-limiting/guards/quota.guard'; @@ -20,21 +20,11 @@ import { DataPipelineModule } from './data-pipeline/data-pipeline.module'; import { CanaryModule } from './canary/canary.module'; import { IncidentManagementModule } from './incident-management/incident-management.module'; import { MonitoringModule } from './monitoring/monitoring.module'; -import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout.interceptor'; -import { IdempotencyModule } from './common/modules/idempotency.module'; -import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor'; -import { DeepLinkModule } from './deep-link/deep-link.module'; -import { InvoicesModule } from './payments/invoices/invoices.module'; -import { ReportingModule } from './payments/reporting/reporting.module'; -import { HealthModule } from './health/health.module'; +import { I18nModule as AppI18nModule } from './i18n/i18n.module'; // ✅ keep BOTH modules import { ReadReplicaModule } from './database/read-replica'; import { CachingModule } from './caching/caching.module'; -import { SlackService } from './slack.service'; -import { CoursesModule } from './courses/courses.module'; -import { DataRetentionModule } from './data-retention/data-retention.module'; -import { GatewayModule } from './gateway/gateway.module'; const featureFlags = loadFeatureFlags(); @@ -53,33 +43,18 @@ const featureFlags = loadFeatureFlags(); CanaryModule, IncidentManagementModule, MonitoringModule, - IdempotencyModule, - DeepLinkModule, - InvoicesModule, - ReportingModule, - HealthModule, // ✅ always include read replicas (or wrap if needed) ReadReplicaModule, // ✅ feature-flagged caching ...(featureFlags.ENABLE_CACHING ? [CachingModule] : []), - - // ✅ courses module with enrollment and prerequisite enforcement - CoursesModule, - - // ✅ data retention: archiving and purging - DataRetentionModule, - - // ✅ API gateway: routing, rate limiting, transformation, caching - GatewayModule, + // i18n support + AppI18nModule, ], controllers: [AppController], - providers: [ - SlackService, - ...(featureFlags.ENABLE_RATE_LIMITING ? [{ provide: APP_GUARD, useClass: QuotaGuard }] : []), - { provide: APP_INTERCEPTOR, useClass: RequestTimeoutInterceptor }, - { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor }, - ], + providers: featureFlags.ENABLE_RATE_LIMITING + ? [{ provide: APP_GUARD, useClass: QuotaGuard }] + : [], }) -export class AppModule {} +export class AppModule {} \ No newline at end of file diff --git a/src/i18n/i18n.controller.ts b/src/i18n/i18n.controller.ts new file mode 100644 index 00000000..7993ada2 --- /dev/null +++ b/src/i18n/i18n.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { I18nWrapperService } from './i18n.service'; + +@Controller('i18n') +export class I18nController { + constructor(private readonly i18n: I18nWrapperService) {} + + @Get('locales') + getLocales() { + return this.i18n.getSupportedLocales(); + } + + @Get('translate') + translate(@Query('key') key: string, @Query('lang') lang?: string) { + if (!key) return { error: 'missing_key' }; + const locale = lang || 'en'; + const value = this.i18n.translate(key, locale); + const direction = this.i18n.getDirection(locale); + return { key, locale, value, direction }; + } +} diff --git a/src/i18n/i18n.middleware.ts b/src/i18n/i18n.middleware.ts new file mode 100644 index 00000000..101a2c65 --- /dev/null +++ b/src/i18n/i18n.middleware.ts @@ -0,0 +1,18 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { I18nWrapperService } from './i18n.service'; +import { RequestWithLocale } from '../common/types/request-with-locale'; + +@Injectable() +export class LocaleMiddleware implements NestMiddleware { + constructor(private readonly wrapper: I18nWrapperService) {} + + use(req: RequestWithLocale, res: Response, next: NextFunction) { + const lang = (req.query.lang as string) || (req.headers['x-lang'] as string) || (req.headers['lang'] as string) || 'en'; + const short = String(lang).split(',')[0].split('-')[0]; + const direction = this.wrapper.getDirection(short); + res.setHeader('Content-Direction', direction); + req.resolvedLocale = short; + next(); + } +} diff --git a/src/i18n/i18n.module.ts b/src/i18n/i18n.module.ts new file mode 100644 index 00000000..e2953af0 --- /dev/null +++ b/src/i18n/i18n.module.ts @@ -0,0 +1,16 @@ +import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; +import { I18nController } from './i18n.controller'; +import { I18nWrapperService } from './i18n.service'; +import { LocaleMiddleware } from './i18n.middleware'; + +@Module({ + imports: [], + controllers: [I18nController], + providers: [I18nWrapperService, LocaleMiddleware], + exports: [I18nWrapperService], +}) +export class I18nModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(LocaleMiddleware).forRoutes('*'); + } +} diff --git a/src/i18n/i18n.service.ts b/src/i18n/i18n.service.ts new file mode 100644 index 00000000..07a9649f --- /dev/null +++ b/src/i18n/i18n.service.ts @@ -0,0 +1,88 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { readdirSync, readFileSync } from 'fs'; +import { extname, join } from 'path'; + +const RTL_LANGS = ['ar', 'he', 'fa', 'ur']; +const DEFAULT_LOCALE = 'en'; + +interface LocaleDefinition { + code: string; + name: string; + direction: 'ltr' | 'rtl'; +} + +@Injectable() +export class I18nWrapperService { + private readonly logger = new Logger(I18nWrapperService.name); + private readonly localesPath = join(__dirname, 'locales'); + private readonly fallbackLocale = DEFAULT_LOCALE; + private readonly supported: LocaleDefinition[] = [ + { code: 'en', name: 'English', direction: 'ltr' }, + { code: 'ar', name: 'Arabic', direction: 'rtl' }, + ]; + private readonly bundles: Record> = {}; + + constructor() { + this.loadBundles(); + } + + getSupportedLocales() { + return this.supported; + } + + getDirection(locale: string) { + return this.isRtl(locale) ? 'rtl' : 'ltr'; + } + + translate(key: string, locale: string) { + const normalized = this.normalizeLocale(locale); + const bundle = this.bundles[normalized] || this.bundles[this.fallbackLocale] || {}; + return this.lookup(bundle, key) ?? key; + } + + isRtl(locale: string) { + if (!locale) return false; + return RTL_LANGS.includes(this.normalizeLocale(locale)); + } + + private loadBundles() { + try { + const localeDirs = readdirSync(this.localesPath, { withFileTypes: true }).filter((entry) => entry.isDirectory()); + for (const localeDir of localeDirs) { + const locale = localeDir.name; + const bundle: Record = {}; + const localeFolder = join(this.localesPath, locale); + const files = readdirSync(localeFolder, { withFileTypes: true }).filter((entry) => entry.isFile()); + + for (const file of files) { + if (extname(file.name).toLowerCase() !== '.json') continue; + const namespace = file.name.replace(/\.json$/i, ''); + const raw = readFileSync(join(localeFolder, file.name), 'utf8'); + bundle[namespace] = JSON.parse(raw); + } + + this.bundles[locale] = bundle; + } + } catch (error) { + this.logger.error('Failed to load locale bundles', error as Error); + } + } + + private normalizeLocale(locale: string) { + return String(locale).split(',')[0].split('-')[0].toLowerCase(); + } + + private lookup(bundle: Record, key: string): string | undefined { + const segments = key.split('.'); + let result: unknown = bundle; + + for (const segment of segments) { + if (typeof result !== 'object' || result === null) { + return undefined; + } + result = (result as Record)[segment]; + } + + return typeof result === 'string' ? result : undefined; + } +} diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json new file mode 100644 index 00000000..cacb8f59 --- /dev/null +++ b/src/i18n/locales/ar/common.json @@ -0,0 +1,5 @@ +{ + "greeting": "مرحبا", + "farewell": "مع السلامة", + "welcome_message": "مرحبًا بكم في TeachLink" +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json new file mode 100644 index 00000000..c7eafe14 --- /dev/null +++ b/src/i18n/locales/en/common.json @@ -0,0 +1,5 @@ +{ + "greeting": "Hello", + "farewell": "Goodbye", + "welcome_message": "Welcome to TeachLink" +}