feat(platform): add i18n translations module (P3.20)

This commit is contained in:
saravanakumardb1 2026-03-27 11:32:39 -07:00
parent c1543e24fe
commit 3cda7190fb
6 changed files with 487 additions and 0 deletions

View File

@ -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> {

View 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);
});
});

View 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;
}
}

View 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;
});
}

View 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>;

View File

@ -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' });