search and ai stabilization work, localization stablized.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for translation management and serving UI bundles.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to upsert translations.
|
||||
/// </summary>
|
||||
public sealed record UpsertTranslationsRequest
|
||||
{
|
||||
/// <summary>Target locale (e.g., "en-US", "de-DE").</summary>
|
||||
public required string Locale { get; init; }
|
||||
|
||||
/// <summary>Key-value pairs to upsert.</summary>
|
||||
public required Dictionary<string, string> Strings { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user