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