diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index a49377bf..486ac003 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -182,6 +182,9 @@ const CONTAINER_DEFS: Record = { // 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 { diff --git a/services/platform-service/src/modules/i18n/i18n.test.ts b/services/platform-service/src/modules/i18n/i18n.test.ts new file mode 100644 index 00000000..170ceb8b --- /dev/null +++ b/services/platform-service/src/modules/i18n/i18n.test.ts @@ -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); + }); +}); diff --git a/services/platform-service/src/modules/i18n/repository.ts b/services/platform-service/src/modules/i18n/repository.ts new file mode 100644 index 00000000..98847a1f --- /dev/null +++ b/services/platform-service/src/modules/i18n/repository.ts @@ -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 { + const { resources } = await getLocalesContainer() + .items.query({ + 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 { + try { + const { resource } = await getLocalesContainer().item(locale, locale).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function createLocale( + productId: string, + input: CreateLocaleInput +): Promise { + 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> { + 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({ + query: `SELECT * FROM c WHERE ${conditions.join(' AND ')}`, + parameters: params, + }) + .fetchAll(); + + const result: Record = {}; + 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; 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 { + 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(); + 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(); + 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 { + const id = translationId(locale, key); + try { + await getTranslationsContainer().item(id, locale).delete(); + return true; + } catch { + return false; + } +} diff --git a/services/platform-service/src/modules/i18n/routes.ts b/services/platform-service/src/modules/i18n/routes.ts new file mode 100644 index 00000000..b5888be4 --- /dev/null +++ b/services/platform-service/src/modules/i18n/routes.ts @@ -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 { + // ── 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; + }); +} diff --git a/services/platform-service/src/modules/i18n/types.ts b/services/platform-service/src/modules/i18n/types.ts new file mode 100644 index 00000000..5ac494c8 --- /dev/null +++ b/services/platform-service/src/modules/i18n/types.ts @@ -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; +export type BulkImportInput = z.infer; +export type CreateLocaleInput = z.infer; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index d35b8231..964ebb3c 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -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' });