feat(platform): add i18n translations module (P3.20)
This commit is contained in:
parent
c1543e24fe
commit
3cda7190fb
@ -182,6 +182,9 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
||||
// API Versioning (P3)
|
||||
api_versions: { partitionKeyPath: '/productId' },
|
||||
api_version_pins: { partitionKeyPath: '/productId' },
|
||||
// i18n (P3)
|
||||
translations: { partitionKeyPath: '/locale' },
|
||||
i18n_locales: { partitionKeyPath: '/locale' },
|
||||
};
|
||||
|
||||
export async function initCosmosIfNeeded(): Promise<void> {
|
||||
|
||||
130
services/platform-service/src/modules/i18n/i18n.test.ts
Normal file
130
services/platform-service/src/modules/i18n/i18n.test.ts
Normal file
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* i18n module — unit tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { UpsertTranslationSchema, BulkImportSchema, CreateLocaleSchema } from './types.js';
|
||||
|
||||
// ── UpsertTranslationSchema ──────────────────────────────────
|
||||
|
||||
describe('UpsertTranslationSchema', () => {
|
||||
it('accepts valid value', () => {
|
||||
const result = UpsertTranslationSchema.parse({ value: 'Save' });
|
||||
expect(result.value).toBe('Save');
|
||||
});
|
||||
|
||||
it('rejects empty value', () => {
|
||||
expect(() => UpsertTranslationSchema.parse({ value: '' })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects value over 5000 chars', () => {
|
||||
expect(() => UpsertTranslationSchema.parse({ value: 'x'.repeat(5001) })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── BulkImportSchema ─────────────────────────────────────────
|
||||
|
||||
describe('BulkImportSchema', () => {
|
||||
it('accepts valid translations map', () => {
|
||||
const result = BulkImportSchema.parse({
|
||||
translations: { 'common.save': 'Save', 'common.cancel': 'Cancel' },
|
||||
});
|
||||
expect(Object.keys(result.translations)).toHaveLength(2);
|
||||
expect(result.overwrite).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts overwrite flag', () => {
|
||||
const result = BulkImportSchema.parse({
|
||||
translations: { 'common.save': 'Sauvegarder' },
|
||||
overwrite: true,
|
||||
});
|
||||
expect(result.overwrite).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty value in translations', () => {
|
||||
expect(() => BulkImportSchema.parse({ translations: { 'common.save': '' } })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects value over 5000 chars in translations', () => {
|
||||
expect(() =>
|
||||
BulkImportSchema.parse({ translations: { 'common.save': 'x'.repeat(5001) } })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── CreateLocaleSchema ───────────────────────────────────────
|
||||
|
||||
describe('CreateLocaleSchema', () => {
|
||||
it('accepts valid locale', () => {
|
||||
const result = CreateLocaleSchema.parse({
|
||||
locale: 'fr',
|
||||
name: 'French',
|
||||
nativeName: 'Français',
|
||||
});
|
||||
expect(result.locale).toBe('fr');
|
||||
expect(result.direction).toBe('ltr');
|
||||
expect(result.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts locale with region', () => {
|
||||
const result = CreateLocaleSchema.parse({
|
||||
locale: 'fr-CA',
|
||||
name: 'French (Canada)',
|
||||
nativeName: 'Français (Canada)',
|
||||
});
|
||||
expect(result.locale).toBe('fr-CA');
|
||||
});
|
||||
|
||||
it('accepts RTL direction', () => {
|
||||
const result = CreateLocaleSchema.parse({
|
||||
locale: 'ar',
|
||||
name: 'Arabic',
|
||||
nativeName: 'العربية',
|
||||
direction: 'rtl',
|
||||
});
|
||||
expect(result.direction).toBe('rtl');
|
||||
});
|
||||
|
||||
it('rejects invalid locale format', () => {
|
||||
expect(() =>
|
||||
CreateLocaleSchema.parse({ locale: 'INVALID', name: 'Test', nativeName: 'Test' })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects single-char locale', () => {
|
||||
expect(() =>
|
||||
CreateLocaleSchema.parse({ locale: 'f', name: 'Test', nativeName: 'Test' })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects empty name', () => {
|
||||
expect(() =>
|
||||
CreateLocaleSchema.parse({ locale: 'fr', name: '', nativeName: 'Français' })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects empty nativeName', () => {
|
||||
expect(() =>
|
||||
CreateLocaleSchema.parse({ locale: 'fr', name: 'French', nativeName: '' })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('defaults enabled to true', () => {
|
||||
const result = CreateLocaleSchema.parse({
|
||||
locale: 'de',
|
||||
name: 'German',
|
||||
nativeName: 'Deutsch',
|
||||
});
|
||||
expect(result.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts disabled locale', () => {
|
||||
const result = CreateLocaleSchema.parse({
|
||||
locale: 'de',
|
||||
name: 'German',
|
||||
nativeName: 'Deutsch',
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
211
services/platform-service/src/modules/i18n/repository.ts
Normal file
211
services/platform-service/src/modules/i18n/repository.ts
Normal file
@ -0,0 +1,211 @@
|
||||
/**
|
||||
* i18n repository — Cosmos DB CRUD for translations and locales.
|
||||
* Fallback chain: requested locale → language-only → 'en'.
|
||||
*/
|
||||
|
||||
import { getRegisteredContainer } from '@bytelyst/cosmos';
|
||||
import type { TranslationDoc, LocaleDoc, BulkImportInput, CreateLocaleInput } from './types.js';
|
||||
|
||||
// ── Container Access ──────────────────────────────────────────
|
||||
|
||||
function getTranslationsContainer() {
|
||||
return getRegisteredContainer('translations');
|
||||
}
|
||||
|
||||
function getLocalesContainer() {
|
||||
return getRegisteredContainer('i18n_locales');
|
||||
}
|
||||
|
||||
// ── Locale CRUD ──────────────────────────────────────────────
|
||||
|
||||
export async function listLocales(productId: string): Promise<LocaleDoc[]> {
|
||||
const { resources } = await getLocalesContainer()
|
||||
.items.query<LocaleDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.productId = @pid ORDER BY c.locale ASC',
|
||||
parameters: [{ name: '@pid', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
}
|
||||
|
||||
export async function getLocale(locale: string): Promise<LocaleDoc | null> {
|
||||
try {
|
||||
const { resource } = await getLocalesContainer().item(locale, locale).read<LocaleDoc>();
|
||||
return resource ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLocale(
|
||||
productId: string,
|
||||
input: CreateLocaleInput
|
||||
): Promise<LocaleDoc> {
|
||||
const now = new Date().toISOString();
|
||||
const doc: LocaleDoc = {
|
||||
id: input.locale,
|
||||
locale: input.locale,
|
||||
name: input.name,
|
||||
nativeName: input.nativeName,
|
||||
direction: input.direction ?? 'ltr',
|
||||
productId,
|
||||
enabled: input.enabled ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await getLocalesContainer().items.create(doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── Translation CRUD ─────────────────────────────────────────
|
||||
|
||||
function translationId(locale: string, key: string): string {
|
||||
return `${locale}:${key}`;
|
||||
}
|
||||
|
||||
function extractNamespace(key: string): string {
|
||||
const idx = key.indexOf('.');
|
||||
return idx > 0 ? key.substring(0, idx) : key;
|
||||
}
|
||||
|
||||
export async function getTranslations(
|
||||
locale: string,
|
||||
productId: string,
|
||||
namespace?: string
|
||||
): Promise<Record<string, string>> {
|
||||
const conditions = ['c.locale = @loc', 'c.productId = @pid'];
|
||||
const params = [
|
||||
{ name: '@loc', value: locale },
|
||||
{ name: '@pid', value: productId },
|
||||
];
|
||||
|
||||
if (namespace) {
|
||||
conditions.push('c.namespace = @ns');
|
||||
params.push({ name: '@ns', value: namespace });
|
||||
}
|
||||
|
||||
const { resources } = await getTranslationsContainer()
|
||||
.items.query<TranslationDoc>({
|
||||
query: `SELECT * FROM c WHERE ${conditions.join(' AND ')}`,
|
||||
parameters: params,
|
||||
})
|
||||
.fetchAll();
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const doc of resources) {
|
||||
result[doc.key] = doc.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getTranslationsWithFallback(
|
||||
locale: string,
|
||||
productId: string,
|
||||
namespace?: string
|
||||
): Promise<{ translations: Record<string, string>; resolvedLocale: string }> {
|
||||
// Try exact locale (e.g. 'fr-CA')
|
||||
let translations = await getTranslations(locale, productId, namespace);
|
||||
if (Object.keys(translations).length > 0) {
|
||||
return { translations, resolvedLocale: locale };
|
||||
}
|
||||
|
||||
// Try language-only (e.g. 'fr')
|
||||
const langOnly = locale.split('-')[0];
|
||||
if (langOnly !== locale) {
|
||||
translations = await getTranslations(langOnly, productId, namespace);
|
||||
if (Object.keys(translations).length > 0) {
|
||||
return { translations, resolvedLocale: langOnly };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to 'en'
|
||||
if (langOnly !== 'en') {
|
||||
translations = await getTranslations('en', productId, namespace);
|
||||
return { translations, resolvedLocale: 'en' };
|
||||
}
|
||||
|
||||
return { translations: {}, resolvedLocale: locale };
|
||||
}
|
||||
|
||||
export async function upsertTranslation(
|
||||
locale: string,
|
||||
key: string,
|
||||
value: string,
|
||||
productId: string
|
||||
): Promise<TranslationDoc> {
|
||||
const id = translationId(locale, key);
|
||||
const namespace = extractNamespace(key);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Try to read existing
|
||||
try {
|
||||
const { resource } = await getTranslationsContainer().item(id, locale).read<TranslationDoc>();
|
||||
if (resource) {
|
||||
const updated: TranslationDoc = {
|
||||
...resource,
|
||||
value,
|
||||
updatedAt: now,
|
||||
};
|
||||
await getTranslationsContainer().item(id, locale).replace(updated);
|
||||
return updated;
|
||||
}
|
||||
} catch {
|
||||
// Does not exist — create
|
||||
}
|
||||
|
||||
const doc: TranslationDoc = {
|
||||
id,
|
||||
locale,
|
||||
key,
|
||||
namespace,
|
||||
value,
|
||||
productId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await getTranslationsContainer().items.create(doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function bulkImport(
|
||||
locale: string,
|
||||
productId: string,
|
||||
input: BulkImportInput
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const [key, value] of Object.entries(input.translations)) {
|
||||
const id = translationId(locale, key);
|
||||
|
||||
if (!input.overwrite) {
|
||||
// Check if already exists
|
||||
try {
|
||||
const { resource } = await getTranslationsContainer()
|
||||
.item(id, locale)
|
||||
.read<TranslationDoc>();
|
||||
if (resource) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Does not exist — proceed with create
|
||||
}
|
||||
}
|
||||
|
||||
await upsertTranslation(locale, key, value, productId);
|
||||
imported++;
|
||||
}
|
||||
|
||||
return { imported, skipped };
|
||||
}
|
||||
|
||||
export async function deleteTranslation(locale: string, key: string): Promise<boolean> {
|
||||
const id = translationId(locale, key);
|
||||
try {
|
||||
await getTranslationsContainer().item(id, locale).delete();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
84
services/platform-service/src/modules/i18n/routes.ts
Normal file
84
services/platform-service/src/modules/i18n/routes.ts
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* i18n routes.
|
||||
* Public: get translations, list locales. Admin: upsert, bulk import, manage locales.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { UnauthorizedError, ForbiddenError, NotFoundError } from '../../lib/errors.js';
|
||||
import { getRequestProductId, getRequestProductIdForPublic } from '../../lib/request-context.js';
|
||||
import { UpsertTranslationSchema, BulkImportSchema, CreateLocaleSchema } from './types.js';
|
||||
import {
|
||||
listLocales,
|
||||
getTranslationsWithFallback,
|
||||
upsertTranslation,
|
||||
bulkImport,
|
||||
deleteTranslation,
|
||||
createLocale,
|
||||
} from './repository.js';
|
||||
|
||||
function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): string {
|
||||
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
|
||||
if (req.jwtPayload.role !== 'admin') throw new ForbiddenError('Admin access required');
|
||||
return req.jwtPayload.sub;
|
||||
}
|
||||
|
||||
export async function i18nRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ── Public: List available locales ────────────────────────
|
||||
app.get('/i18n/locales', async req => {
|
||||
const productId = getRequestProductIdForPublic(req);
|
||||
const locales = await listLocales(productId);
|
||||
return locales.filter(l => l.enabled);
|
||||
});
|
||||
|
||||
// ── Public: Get translations for locale ──────────────────
|
||||
app.get<{ Params: { locale: string } }>('/i18n/:locale', async req => {
|
||||
const productId = getRequestProductIdForPublic(req);
|
||||
return getTranslationsWithFallback(req.params.locale, productId);
|
||||
});
|
||||
|
||||
// ── Public: Get translations for locale + namespace ──────
|
||||
app.get<{ Params: { locale: string; namespace: string } }>(
|
||||
'/i18n/:locale/:namespace',
|
||||
async req => {
|
||||
const productId = getRequestProductIdForPublic(req);
|
||||
return getTranslationsWithFallback(req.params.locale, productId, req.params.namespace);
|
||||
}
|
||||
);
|
||||
|
||||
// ── Admin: Upsert translation ────────────────────────────
|
||||
app.put<{ Params: { locale: string; key: string } }>('/admin/i18n/:locale/:key', async req => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { value } = UpsertTranslationSchema.parse(req.body);
|
||||
return upsertTranslation(req.params.locale, req.params.key, value, productId);
|
||||
});
|
||||
|
||||
// ── Admin: Bulk import translations ──────────────────────
|
||||
app.post<{ Params: { locale: string } }>('/admin/i18n/:locale/import', async req => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const input = BulkImportSchema.parse(req.body);
|
||||
return bulkImport(req.params.locale, productId, input);
|
||||
});
|
||||
|
||||
// ── Admin: Delete translation ────────────────────────────
|
||||
app.delete<{ Params: { locale: string; key: string } }>(
|
||||
'/admin/i18n/:locale/:key',
|
||||
async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
const ok = await deleteTranslation(req.params.locale, req.params.key);
|
||||
if (!ok) throw new NotFoundError('Translation not found');
|
||||
reply.status(204);
|
||||
}
|
||||
);
|
||||
|
||||
// ── Admin: Create locale ─────────────────────────────────
|
||||
app.post('/admin/i18n/locales', async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const input = CreateLocaleSchema.parse(req.body);
|
||||
const locale = await createLocale(productId, input);
|
||||
reply.status(201);
|
||||
return locale;
|
||||
});
|
||||
}
|
||||
56
services/platform-service/src/modules/i18n/types.ts
Normal file
56
services/platform-service/src/modules/i18n/types.ts
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* i18n module — types and schemas.
|
||||
* Translation management with locale/namespace structure and fallback chain.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface TranslationDoc {
|
||||
id: string; // `${locale}:${key}`
|
||||
locale: string; // partition key — e.g. 'en', 'fr', 'ja'
|
||||
key: string; // dot-separated: 'common.save', 'dashboard.title'
|
||||
namespace: string; // first segment of key: 'common', 'dashboard'
|
||||
value: string;
|
||||
productId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LocaleDoc {
|
||||
id: string; // locale code
|
||||
locale: string; // partition key (same as id)
|
||||
name: string; // 'English', 'Français'
|
||||
nativeName: string; // 'English', 'Français'
|
||||
direction: 'ltr' | 'rtl';
|
||||
productId: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Schemas ────────────────────────────────────────────────────
|
||||
|
||||
export const UpsertTranslationSchema = z.object({
|
||||
value: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
export const BulkImportSchema = z.object({
|
||||
translations: z.record(z.string().min(1).max(5000)), // { "common.save": "Save", ... }
|
||||
overwrite: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const CreateLocaleSchema = z.object({
|
||||
locale: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(10)
|
||||
.regex(/^[a-z]{2}(-[A-Z]{2})?$/),
|
||||
name: z.string().min(1).max(100),
|
||||
nativeName: z.string().min(1).max(100),
|
||||
direction: z.enum(['ltr', 'rtl']).default('ltr'),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type UpsertTranslationInput = z.infer<typeof UpsertTranslationSchema>;
|
||||
export type BulkImportInput = z.infer<typeof BulkImportSchema>;
|
||||
export type CreateLocaleInput = z.infer<typeof CreateLocaleSchema>;
|
||||
@ -98,6 +98,7 @@ import { tenantRoutes } from './modules/tenants/routes.js';
|
||||
import { retentionRoutes } from './modules/retention/routes.js';
|
||||
import { backupRoutes } from './modules/backups/routes.js';
|
||||
import { apiVersioningRoutes } from './modules/api-versioning/routes.js';
|
||||
import { i18nRoutes } from './modules/i18n/routes.js';
|
||||
import aiDiagnosticsRoutes from './modules/ai-diagnostics/routes.js';
|
||||
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||
import { config } from './lib/config.js';
|
||||
@ -264,6 +265,8 @@ await app.register(tenantRoutes, { prefix: '/api' });
|
||||
await app.register(retentionRoutes, { prefix: '/api' });
|
||||
await app.register(backupRoutes, { prefix: '/api' });
|
||||
await app.register(apiVersioningRoutes, { prefix: '/api' });
|
||||
// i18n translations
|
||||
await app.register(i18nRoutes, { prefix: '/api' });
|
||||
|
||||
// AI Diagnostics (NL query, LLM root-cause, error clustering)
|
||||
await app.register(aiDiagnosticsRoutes, { prefix: '/api/ai-diagnostics' });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user