Skip to content

Commit 4ee0c02

Browse files
Merge pull request #256 from SimpleX-T/issue-254
feat(localization): add backend i18n module with translations API and caching
2 parents 54505aa + 3759c74 commit 4ee0c02

22 files changed

Lines changed: 1170 additions & 143 deletions

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,7 @@ pids
5858
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
5959

6060
# Husky internal bootstrap directory (auto-generated, not hand-edited)
61-
.husky/_/
61+
.husky/_/
62+
63+
# Local PR / commit message drafts (not tracked)
64+
pr.md

package-lock.json

Lines changed: 67 additions & 142 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app.module.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { TenancyModule } from './tenancy/tenancy.module';
4949
import { CdnModule } from './cdn/cdn.module';
5050
import { AuthModule } from './auth/auth.module';
5151
import { PaymentsModule } from './payments/payments.module';
52+
import { LocalizationModule } from './localization/localization.module';
5253

5354
@Module({})
5455
export class AppModule {
@@ -355,6 +356,15 @@ export class AppModule {
355356
startupLogger.recordModuleSkipped('CDNModule', 'ENABLE_CDN=false');
356357
}
357358

359+
// Localization Module
360+
if (flags.ENABLE_LOCALIZATION) {
361+
const startTime = Date.now();
362+
featureModules.push(LocalizationModule);
363+
startupLogger.recordModuleLoaded('LocalizationModule', startTime);
364+
} else {
365+
startupLogger.recordModuleSkipped('LocalizationModule', 'ENABLE_LOCALIZATION=false');
366+
}
367+
358368
// Queue Module (always loaded for Bull)
359369
featureModules.push(QueueModule);
360370

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
2+
import { RequestWithLocale } from '../types/request-with-locale';
3+
4+
/**
5+
* Resolved request locale when LanguageMiddleware runs (Accept-Language / ?lang=).
6+
* Falls back to I18N_DEFAULT_LOCALE or the decorator argument when unset.
7+
*/
8+
export const CurrentLocale = createParamDecorator(
9+
(fallbackLocale: string | undefined, ctx: ExecutionContext): string => {
10+
const req = ctx.switchToHttp().getRequest<RequestWithLocale>();
11+
const envDefault = (process.env.I18N_DEFAULT_LOCALE || 'en').trim().toLowerCase();
12+
return req.resolvedLocale ?? fallbackLocale ?? envDefault;
13+
},
14+
);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { Request } from 'express';
2+
3+
export type RequestWithLocale = Request & { resolvedLocale?: string };

src/config/env.validation.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ export const envValidationSchema = Joi.object({
110110
ENABLE_SECURITY: Joi.boolean().default(true),
111111
ENABLE_TENANCY: Joi.boolean().default(true),
112112
ENABLE_CDN: Joi.boolean().default(true),
113+
ENABLE_LOCALIZATION: Joi.boolean().default(true),
114+
115+
// i18n / localization
116+
I18N_DEFAULT_LOCALE: Joi.string().default('en'),
117+
I18N_SUPPORTED_LOCALES: Joi.string().default('en'),
118+
I18N_CACHE_TTL_SECONDS: Joi.number().integer().min(0).default(300),
113119

114120
// Cluster Mode
115121
CLUSTER_MODE: Joi.boolean().default(false),

src/config/feature-flags.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export interface FeatureFlagsConfig {
3333
ENABLE_SECURITY: boolean;
3434
ENABLE_TENANCY: boolean;
3535
ENABLE_CDN: boolean;
36+
ENABLE_LOCALIZATION: boolean;
3637
}
3738

3839
/**
@@ -65,6 +66,7 @@ export const defaultFeatureFlags: FeatureFlagsConfig = {
6566
ENABLE_SECURITY: true,
6667
ENABLE_TENANCY: true,
6768
ENABLE_CDN: true,
69+
ENABLE_LOCALIZATION: true,
6870
};
6971

7072
/**
@@ -134,6 +136,10 @@ export function loadFeatureFlags(): FeatureFlagsConfig {
134136
ENABLE_SECURITY: getBooleanEnv('ENABLE_SECURITY', defaultFeatureFlags.ENABLE_SECURITY),
135137
ENABLE_TENANCY: getBooleanEnv('ENABLE_TENANCY', defaultFeatureFlags.ENABLE_TENANCY),
136138
ENABLE_CDN: getBooleanEnv('ENABLE_CDN', defaultFeatureFlags.ENABLE_CDN),
139+
ENABLE_LOCALIZATION: getBooleanEnv(
140+
'ENABLE_LOCALIZATION',
141+
defaultFeatureFlags.ENABLE_LOCALIZATION,
142+
),
137143
};
138144
}
139145

@@ -179,6 +185,7 @@ export function getEnabledModules(flags: FeatureFlagsConfig): string[] {
179185
if (flags.ENABLE_SECURITY) modules.push('SecurityModule');
180186
if (flags.ENABLE_TENANCY) modules.push('TenancyModule');
181187
if (flags.ENABLE_CDN) modules.push('CDNModule');
188+
if (flags.ENABLE_LOCALIZATION) modules.push('LocalizationModule');
182189

183190
return modules;
184191
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
3+
4+
export class BundleQueryDto {
5+
@ApiProperty({ example: 'errors' })
6+
@IsString()
7+
@IsNotEmpty()
8+
@MaxLength(128)
9+
namespace: string;
10+
11+
@ApiPropertyOptional({
12+
example: 'en',
13+
description: 'If omitted, uses detected language from middleware / Accept-Language',
14+
})
15+
@IsOptional()
16+
@IsString()
17+
@MaxLength(32)
18+
locale?: string;
19+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
3+
4+
export class CreateTranslationDto {
5+
@ApiProperty({ example: 'errors' })
6+
@IsString()
7+
@IsNotEmpty()
8+
@MaxLength(128)
9+
namespace: string;
10+
11+
@ApiProperty({ example: 'not_found' })
12+
@IsString()
13+
@IsNotEmpty()
14+
@MaxLength(512)
15+
key: string;
16+
17+
@ApiProperty({ example: 'en' })
18+
@IsString()
19+
@IsNotEmpty()
20+
@MaxLength(32)
21+
locale: string;
22+
23+
@ApiProperty({ example: 'Resource was not found.' })
24+
@IsString()
25+
@IsNotEmpty()
26+
value: string;
27+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
3+
4+
export class ExportQueryDto {
5+
@ApiProperty({ example: 'errors' })
6+
@IsString()
7+
@IsNotEmpty()
8+
@MaxLength(128)
9+
namespace: string;
10+
11+
@ApiPropertyOptional({ example: 'en' })
12+
@IsOptional()
13+
@IsString()
14+
@MaxLength(32)
15+
locale?: string;
16+
17+
@ApiPropertyOptional({ enum: ['json', 'csv'], default: 'json' })
18+
@IsOptional()
19+
@IsString()
20+
@IsIn(['json', 'csv'])
21+
format?: 'json' | 'csv' = 'json';
22+
}

0 commit comments

Comments
 (0)