using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.Localization; using StellaOps.Platform.WebService.Constants; using StellaOps.Platform.WebService.Services; namespace StellaOps.Platform.WebService.Endpoints; /// /// REST API endpoints for translation management and serving UI bundles. /// public static class LocalizationEndpoints { public static IEndpointRouteBuilder MapLocalizationEndpoints(this IEndpointRouteBuilder app) { // Anonymous UI bundle endpoint (same pattern as /platform/envsettings.json) app.MapGet("/platform/i18n/{locale}.json", async ( string locale, HttpContext context, PlatformTranslationService translationService, CancellationToken ct) => { var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system"; var bundle = await translationService.GetMergedBundleAsync(tenantId, locale, ct); // Cache for 5 minutes — translations don't change often context.Response.Headers.CacheControl = "public, max-age=300"; return Results.Ok(bundle); }) .WithTags("Localization") .WithName("GetUiTranslationBundle") .WithSummary("Gets the merged translation bundle for the UI") .WithDescription( "Returns all translations for the specified locale, merging embedded defaults with DB overrides. " + "Anonymous access, cacheable. Used by the Angular frontend at boot time.") .AllowAnonymous(); // Authenticated API group var group = app.MapGroup("/api/v1/platform/localization") .WithTags("Localization") .RequireAuthorization(PlatformPolicies.PreferencesRead) .RequireTenant(); // Get all translations for a locale group.MapGet("/bundles/{locale}", async ( string locale, HttpContext context, PlatformTranslationService translationService, CancellationToken ct) => { var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system"; var bundle = await translationService.GetMergedBundleAsync(tenantId, locale, ct); return Results.Ok(new { locale, strings = bundle, count = bundle.Count }); }) .WithName("GetTranslationBundle") .WithSummary("Gets all translations for a locale") .WithDescription( "Returns the merged set of all translations for the specified locale, " + "combining embedded defaults with system and tenant DB overrides in priority order."); // Get translations filtered by namespace group.MapGet("/bundles/{locale}/{ns}", async ( string locale, string ns, HttpContext context, PlatformTranslationService translationService, CancellationToken ct) => { var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system"; var bundle = await translationService.GetMergedBundleAsync(tenantId, locale, ns, ct); return Results.Ok(new { locale, @namespace = ns, strings = bundle, count = bundle.Count }); }) .WithName("GetTranslationBundleByNamespace") .WithSummary("Gets translations for a locale filtered by namespace prefix"); // Get available locales group.MapGet("/locales", async ( HttpContext context, PlatformTranslationService translationService, CancellationToken ct) => { var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system"; var locales = await translationService.GetAllLocalesAsync(tenantId, ct); return Results.Ok(new { locales, count = locales.Count }); }) .WithName("GetAvailableLocales") .WithSummary("Gets all available locales"); // Upsert translations (admin) group.MapPut("/bundles", async ( UpsertTranslationsRequest request, HttpContext context, ITranslationStore store, TranslationRegistry registry, CancellationToken ct) => { var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system"; var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system"; if (string.IsNullOrWhiteSpace(request.Locale)) { return Results.BadRequest(new { error = "Locale is required." }); } if (request.Strings is null || request.Strings.Count == 0) { return Results.BadRequest(new { error = "At least one translation string is required." }); } await store.UpsertBatchAsync(tenantId, request.Locale, request.Strings, actor, ct); // Also merge into the in-memory registry for immediate effect registry.MergeBundles(request.Locale, request.Strings); return Results.Ok(new { locale = request.Locale, upserted = request.Strings.Count, message = "Translations updated successfully." }); }) .WithName("UpsertTranslations") .WithSummary("Creates or updates translation strings") .WithDescription( "Upserts translation key-value pairs for a locale. DB values override embedded defaults. " + "Changes take immediate effect in the in-memory registry.") .RequireAuthorization(PlatformPolicies.PreferencesWrite); // Delete a translation (admin) group.MapDelete("/strings/{locale}/{key}", async ( string locale, string key, HttpContext context, ITranslationStore store, CancellationToken ct) => { var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system"; var deleted = await store.DeleteAsync(tenantId, locale, key, ct); if (!deleted) { return Results.NotFound(new { error = $"Translation '{key}' not found for locale '{locale}'." }); } return Results.Ok(new { message = $"Translation '{key}' deleted for locale '{locale}'." }); }) .WithName("DeleteTranslation") .WithSummary("Deletes a translation override") .RequireAuthorization(PlatformPolicies.PreferencesWrite); return app; } } /// /// Request to upsert translations. /// public sealed record UpsertTranslationsRequest { /// Target locale (e.g., "en-US", "de-DE"). public required string Locale { get; init; } /// Key-value pairs to upsert. public required Dictionary Strings { get; init; } }