search and ai stabilization work, localization stablized.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using StellaOps.Localization;
|
||||
using static StellaOps.Localization.T;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -114,6 +116,8 @@ var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
|
||||
builder.Services.AddStellaOpsTenantServices();
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
builder.Services.AddStellaOpsLocalization(builder.Configuration);
|
||||
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("notify");
|
||||
var app = builder.Build();
|
||||
@@ -130,6 +134,7 @@ ConfigureEndpoints(app);
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
await app.LoadTranslationsAsync();
|
||||
await app.RunAsync();
|
||||
|
||||
static void ConfigureAuthentication(WebApplicationBuilder builder, NotifyWebServiceOptions options, IConfiguration configuration)
|
||||
@@ -351,6 +356,7 @@ static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseStellaOpsLocalization();
|
||||
app.UseAuthentication();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthorization();
|
||||
@@ -364,7 +370,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
{
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
|
||||
.WithName("NotifyHealthz")
|
||||
.WithDescription("Liveness probe endpoint for the Notify service. Returns HTTP 200 with a JSON status body when the process is running. No authentication required.")
|
||||
.WithDescription(_t("notify.healthz.description"))
|
||||
.AllowAnonymous();
|
||||
|
||||
app.MapGet("/readyz", (ServiceStatus status) =>
|
||||
@@ -392,7 +398,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
StatusCodes.Status503ServiceUnavailable);
|
||||
})
|
||||
.WithName("NotifyReadyz")
|
||||
.WithDescription("Readiness probe endpoint for the Notify service. Returns HTTP 200 with a structured status body when the service is ready to accept traffic. Returns HTTP 503 if the service is not yet ready. No authentication required.")
|
||||
.WithDescription(_t("notify.readyz.description"))
|
||||
.AllowAnonymous();
|
||||
|
||||
var options = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
|
||||
@@ -403,19 +409,19 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
internalGroup.MapPost("/rules/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeRule))
|
||||
.WithName("notify.rules.normalize")
|
||||
.WithDescription("Internal endpoint that upgrades a notify rule JSON payload from an older schema version to the current canonical format. Returns the normalized rule JSON.")
|
||||
.WithDescription(_t("notify.internal.rules_normalize_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
internalGroup.MapPost("/channels/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeChannel))
|
||||
.WithName("notify.channels.normalize")
|
||||
.WithDescription("Internal endpoint that upgrades a notify channel JSON payload from an older schema version to the current canonical format. Returns the normalized channel JSON.")
|
||||
.WithDescription(_t("notify.internal.channels_normalize_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
internalGroup.MapPost("/templates/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeTemplate))
|
||||
.WithName("notify.templates.normalize")
|
||||
.WithDescription("Internal endpoint that upgrades a notify template JSON payload from an older schema version to the current canonical format. Returns the normalized template JSON.")
|
||||
.WithDescription(_t("notify.internal.templates_normalize_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapGet("/rules", async (IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -429,7 +435,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
return JsonResponse(rules.Select(ToNotifyRule));
|
||||
})
|
||||
.WithName("NotifyListRules")
|
||||
.WithDescription("Lists all notification rules for the tenant. Returns an array of rule objects including match filters and channel actions. Requires notify.viewer scope.")
|
||||
.WithDescription(_t("notify.rules.list_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapGet("/rules/{ruleId}", async (string ruleId, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -441,14 +447,14 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (!TryParseGuid(ruleId, out var id))
|
||||
{
|
||||
return Results.BadRequest(new { error = "ruleId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.rule_id_must_be_guid") });
|
||||
}
|
||||
|
||||
var rule = await repository.GetByIdAsync(tenant, id, cancellationToken).ConfigureAwait(false);
|
||||
return rule is null ? Results.NotFound() : JsonResponse(ToNotifyRule(rule));
|
||||
})
|
||||
.WithName("NotifyGetRule")
|
||||
.WithDescription("Returns the full notification rule for a specific rule ID. Returns 404 if the rule is not found. Requires notify.viewer scope.")
|
||||
.WithDescription(_t("notify.rules.get_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapPost("/rules", async (JsonNode? body, NotifySchemaMigrationService service, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -460,7 +466,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
||||
}
|
||||
|
||||
NotifyRule ruleModel;
|
||||
@@ -470,17 +476,17 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException or KeyNotFoundException or ArgumentException or FormatException)
|
||||
{
|
||||
return Results.BadRequest(new { error = $"Invalid rule payload: {ex.Message}" });
|
||||
return Results.BadRequest(new { error = _t("notify.error.rule_payload_invalid", ex.Message) });
|
||||
}
|
||||
|
||||
if (!string.Equals(ruleModel.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.tenant_mismatch") });
|
||||
}
|
||||
|
||||
if (!TryParseGuid(ruleModel.RuleId, out var ruleGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "ruleId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.rule_id_must_be_guid") });
|
||||
}
|
||||
|
||||
var entity = ToRuleEntity(ruleModel);
|
||||
@@ -497,7 +503,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "rules", ruleModel.RuleId), ruleModel);
|
||||
})
|
||||
.WithName("NotifyUpsertRule")
|
||||
.WithDescription("Creates or updates a notification rule for the tenant. Accepts the canonical rule JSON, validates schema migration, and upserts into storage. Returns 201 Created with the rule record. Requires notify.operator scope.")
|
||||
.WithDescription(_t("notify.rules.upsert_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapDelete("/rules/{ruleId}", async (string ruleId, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -509,14 +515,14 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (!TryParseGuid(ruleId, out var ruleGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "ruleId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.rule_id_must_be_guid") });
|
||||
}
|
||||
|
||||
var deleted = await repository.DeleteAsync(tenant, ruleGuid, cancellationToken).ConfigureAwait(false);
|
||||
return deleted ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("NotifyDeleteRule")
|
||||
.WithDescription("Permanently removes a notification rule from the tenant. Returns 204 No Content on success or 404 if the rule is not found. Requires notify.operator scope.")
|
||||
.WithDescription(_t("notify.rules.delete_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapGet("/channels", async (IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -530,7 +536,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
return JsonResponse(channels.Select(ToNotifyChannel));
|
||||
})
|
||||
.WithName("NotifyListChannels")
|
||||
.WithDescription("Lists all notification channels configured for the tenant, including channel type, enabled state, and configuration. Requires notify.viewer scope.")
|
||||
.WithDescription(_t("notify.channels.list_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapGet("/channels/{channelId}", async (string channelId, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -542,14 +548,14 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (!TryParseGuid(channelId, out var id))
|
||||
{
|
||||
return Results.BadRequest(new { error = "channelId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
||||
}
|
||||
|
||||
var channel = await repository.GetByIdAsync(tenant, id, cancellationToken).ConfigureAwait(false);
|
||||
return channel is null ? Results.NotFound() : JsonResponse(ToNotifyChannel(channel));
|
||||
})
|
||||
.WithName("NotifyGetChannel")
|
||||
.WithDescription("Returns the full channel record for a specific channel ID, including type, configuration, and enabled state. Returns 404 if the channel is not found. Requires notify.viewer scope.")
|
||||
.WithDescription(_t("notify.channels.get_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapPost("/channels", async (JsonNode? body, NotifySchemaMigrationService service, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -561,7 +567,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
||||
}
|
||||
|
||||
NotifyChannel channelModel;
|
||||
@@ -571,17 +577,17 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Text.Json.JsonException or InvalidOperationException or KeyNotFoundException or ArgumentException or FormatException or NotSupportedException)
|
||||
{
|
||||
return Results.BadRequest(new { error = $"Invalid channel payload: {ex.Message}" });
|
||||
return Results.BadRequest(new { error = _t("notify.error.channel_payload_invalid", ex.Message) });
|
||||
}
|
||||
|
||||
if (!string.Equals(channelModel.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.tenant_mismatch") });
|
||||
}
|
||||
|
||||
if (!TryParseGuid(channelModel.ChannelId, out var channelGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "channelId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
||||
}
|
||||
|
||||
var entity = ToChannelEntity(channelModel);
|
||||
@@ -598,7 +604,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "channels", channelModel.ChannelId), channelModel);
|
||||
})
|
||||
.WithName("NotifyUpsertChannel")
|
||||
.WithDescription("Creates or updates a notification channel for the tenant. Accepts a channel JSON payload with type and configuration, upgrades schema if needed, and upserts into storage. Returns 201 Created with the channel record. Requires notify.operator scope.")
|
||||
.WithDescription(_t("notify.channels.upsert_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapPost("/channels/{channelId}/test", async (
|
||||
@@ -616,12 +622,12 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
||||
}
|
||||
|
||||
if (!TryParseGuid(channelId, out var channelGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "channelId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
||||
}
|
||||
|
||||
var channelEntity = await repository.GetByIdAsync(tenant, channelGuid, cancellationToken)
|
||||
@@ -648,7 +654,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
}
|
||||
})
|
||||
.WithName("NotifyTestChannel")
|
||||
.WithDescription("Sends a test notification through the specified channel to validate connectivity and configuration. Returns 202 Accepted with the test send response. Subject to test-send rate limiting. Requires notify.operator scope.")
|
||||
.WithDescription(_t("notify.channels.test_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator)
|
||||
.RequireRateLimiting(NotifyRateLimitPolicies.TestSend);
|
||||
|
||||
@@ -661,14 +667,14 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (!TryParseGuid(channelId, out var channelGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "channelId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
||||
}
|
||||
|
||||
await repository.DeleteAsync(tenant, channelGuid, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithName("NotifyDeleteChannel")
|
||||
.WithDescription("Removes a notification channel from the tenant. Returns 204 No Content on successful deletion. Requires notify.operator scope.")
|
||||
.WithDescription(_t("notify.channels.delete_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapGet("/templates", async (ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -682,7 +688,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
return JsonResponse(templates.Select(ToNotifyTemplate));
|
||||
})
|
||||
.WithName("NotifyListTemplates")
|
||||
.WithDescription("Lists all notification templates configured for the tenant, including body templates and locale settings. Requires notify.viewer scope.")
|
||||
.WithDescription(_t("notify.templates.list_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapGet("/templates/{templateId}", async (string templateId, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -694,14 +700,14 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (!TryParseGuid(templateId, out var templateGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "templateId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.template_id_must_be_guid") });
|
||||
}
|
||||
|
||||
var template = await repository.GetByIdAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false);
|
||||
return template is null ? Results.NotFound() : JsonResponse(ToNotifyTemplate(template));
|
||||
})
|
||||
.WithName("NotifyGetTemplate")
|
||||
.WithDescription("Returns the full notification template for a specific template ID, including channel type, body template, and locale. Returns 404 if the template is not found. Requires notify.viewer scope.")
|
||||
.WithDescription(_t("notify.templates.get_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapPost("/templates", async (JsonNode? body, NotifySchemaMigrationService service, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -713,18 +719,18 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
||||
}
|
||||
|
||||
var templateModel = service.UpgradeTemplate(body);
|
||||
if (!string.Equals(templateModel.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.tenant_mismatch") });
|
||||
}
|
||||
|
||||
if (!TryParseGuid(templateModel.TemplateId, out var templateGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "templateId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.template_id_must_be_guid") });
|
||||
}
|
||||
|
||||
var entity = ToTemplateEntity(templateModel);
|
||||
@@ -741,7 +747,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "templates", templateModel.TemplateId), templateModel);
|
||||
})
|
||||
.WithName("NotifyUpsertTemplate")
|
||||
.WithDescription("Creates or updates a notification template for the tenant. Accepts a template JSON payload, applies schema migration, and upserts into storage. Returns 201 Created with the template record. Requires notify.operator scope.")
|
||||
.WithDescription(_t("notify.templates.upsert_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapDelete("/templates/{templateId}", async (string templateId, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -753,14 +759,14 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (!TryParseGuid(templateId, out var templateGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "templateId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.template_id_must_be_guid") });
|
||||
}
|
||||
|
||||
await repository.DeleteAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithName("NotifyDeleteTemplate")
|
||||
.WithDescription("Removes a notification template from the tenant. Returns 204 No Content on successful deletion. Requires notify.operator scope.")
|
||||
.WithDescription(_t("notify.templates.delete_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapPost("/deliveries", async ([FromBody] JsonNode? body, IDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -772,7 +778,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
||||
}
|
||||
|
||||
NotifyDelivery delivery;
|
||||
@@ -782,27 +788,27 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = $"Invalid delivery payload: {ex.Message}" });
|
||||
return Results.BadRequest(new { error = _t("notify.error.delivery_payload_invalid", ex.Message) });
|
||||
}
|
||||
|
||||
if (!string.Equals(delivery.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.tenant_mismatch") });
|
||||
}
|
||||
|
||||
if (!TryParseGuid(delivery.DeliveryId, out var deliveryId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "deliveryId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.delivery_id_must_be_guid") });
|
||||
}
|
||||
|
||||
if (!TryParseGuid(delivery.ActionId, out var channelId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "actionId must be a GUID representing the channel." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.action_id_must_be_guid") });
|
||||
}
|
||||
|
||||
if (!TryParseGuid(delivery.RuleId, out var ruleId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "ruleId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.rule_id_must_be_guid") });
|
||||
}
|
||||
|
||||
var entity = ToDeliveryEntity(delivery, deliveryId, channelId, ruleId, body);
|
||||
@@ -813,7 +819,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
ToDeliveryDetail(saved, channelName: null, channelType: null));
|
||||
})
|
||||
.WithName("NotifyCreateDelivery")
|
||||
.WithDescription("Records a notification delivery attempt for the tenant. Accepts the canonical delivery JSON including rendered content, channel reference, and delivery status. Returns 201 Created with the delivery detail record. Requires notify.operator scope.")
|
||||
.WithDescription(_t("notify.deliveries.create_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapGet("/deliveries", async (
|
||||
@@ -839,7 +845,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
{
|
||||
if (!Enum.TryParse<DeliveryStatus>(status, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Unknown delivery status." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.delivery_status_unknown") });
|
||||
}
|
||||
|
||||
statusFilter = parsed;
|
||||
@@ -850,7 +856,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
{
|
||||
if (!Guid.TryParse(channelId, out var parsedChannel))
|
||||
{
|
||||
return Results.BadRequest(new { error = "channelId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
||||
}
|
||||
|
||||
channelGuid = parsedChannel;
|
||||
@@ -893,7 +899,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
});
|
||||
})
|
||||
.WithName("NotifyListDeliveries")
|
||||
.WithDescription("Queries delivery history for the tenant with optional filters for status, channel, event type, and time range. Supports pagination via limit and offset. Returns a paged list of delivery summary records. Subject to delivery-history rate limiting. Requires notify.viewer scope.")
|
||||
.WithDescription(_t("notify.deliveries.list_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Viewer)
|
||||
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
|
||||
|
||||
@@ -915,12 +921,12 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (effectiveTenant is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant must be provided via header or query string." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.tenant_required") });
|
||||
}
|
||||
|
||||
if (!TryParseGuid(deliveryId, out var deliveryGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "deliveryId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.delivery_id_must_be_guid") });
|
||||
}
|
||||
|
||||
var delivery = await repository.GetByIdAsync(effectiveTenant, deliveryGuid, cancellationToken).ConfigureAwait(false);
|
||||
@@ -941,7 +947,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
return JsonResponse(ToDeliveryDetail(delivery, channelName, channelType));
|
||||
})
|
||||
.WithName("NotifyGetDelivery")
|
||||
.WithDescription("Returns the full delivery detail record for a specific delivery ID, including channel name, rendered subject, attempt count, sent timestamp, and error information. Subject to delivery-history rate limiting. Requires notify.viewer scope.")
|
||||
.WithDescription(_t("notify.deliveries.get_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Viewer)
|
||||
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
|
||||
|
||||
@@ -954,22 +960,22 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
||||
}
|
||||
|
||||
if (!TryParseGuid(request.ChannelId, out var channelIdGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "channelId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Recipient))
|
||||
{
|
||||
return Results.BadRequest(new { error = "recipient is required." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.recipient_required") });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.DigestKey))
|
||||
{
|
||||
return Results.BadRequest(new { error = "digestKey is required." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.digest_key_required") });
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
@@ -998,7 +1004,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
ToDigestResponse(saved));
|
||||
})
|
||||
.WithName("NotifyUpsertDigest")
|
||||
.WithDescription("Creates or updates a notification digest accumulator for a channel and recipient. Digests collect events over a collection window before sending a batched notification. Returns 201 Created with the digest record. Requires notify.operator scope.")
|
||||
.WithDescription(_t("notify.digests.upsert_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapGet("/digests/{actionKey}", async (
|
||||
@@ -1016,19 +1022,19 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (!TryParseGuid(channelId, out var channelGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "channelId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(recipient))
|
||||
{
|
||||
return Results.BadRequest(new { error = "recipient is required." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.recipient_required") });
|
||||
}
|
||||
|
||||
var digest = await repository.GetByKeyAsync(tenant, channelGuid, recipient, actionKey, cancellationToken).ConfigureAwait(false);
|
||||
return digest is null ? Results.NotFound() : JsonResponse(ToDigestResponse(digest));
|
||||
})
|
||||
.WithName("NotifyGetDigest")
|
||||
.WithDescription("Returns the current state of a notification digest identified by channel, recipient, and action key. Returns 404 if no active digest is found. Requires notify.viewer scope.")
|
||||
.WithDescription(_t("notify.digests.get_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapDelete("/digests/{actionKey}", async (
|
||||
@@ -1046,19 +1052,19 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (!TryParseGuid(channelId, out var channelGuid))
|
||||
{
|
||||
return Results.BadRequest(new { error = "channelId must be a GUID." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(recipient))
|
||||
{
|
||||
return Results.BadRequest(new { error = "recipient is required." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.recipient_required") });
|
||||
}
|
||||
|
||||
var deleted = await repository.DeleteByKeyAsync(tenant, channelGuid, recipient, actionKey, cancellationToken).ConfigureAwait(false);
|
||||
return deleted ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("NotifyDeleteDigest")
|
||||
.WithDescription("Removes a pending notification digest for a channel and recipient, cancelling any queued batched notification. Returns 204 No Content on success or 404 if not found. Requires notify.operator scope.")
|
||||
.WithDescription(_t("notify.digests.delete_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapPost("/audit", async ([FromBody] JsonNode? body, INotifyAuditRepository repository, TimeProvider timeProvider, HttpContext context, ClaimsPrincipal user, CancellationToken cancellationToken) =>
|
||||
@@ -1070,13 +1076,13 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
||||
}
|
||||
|
||||
var action = body["action"]?.GetValue<string>();
|
||||
if (string.IsNullOrWhiteSpace(action))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Action is required." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.action_required") });
|
||||
}
|
||||
|
||||
var entry = new NotifyAuditEntity
|
||||
@@ -1095,7 +1101,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "audit", id.ToString()), new { id });
|
||||
})
|
||||
.WithName("NotifyCreateAuditEntry")
|
||||
.WithDescription("Records an audit log entry for a notify action performed by the authenticated user. Captures the action, entity type, entity ID, and optional payload. Returns 201 Created with the new audit entry ID. Requires notify.operator scope.")
|
||||
.WithDescription(_t("notify.audit.create_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapGet("/audit", async (INotifyAuditRepository repository, HttpContext context, [FromQuery] int? limit, [FromQuery] int? offset, CancellationToken cancellationToken) =>
|
||||
@@ -1122,7 +1128,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
return JsonResponse(payload);
|
||||
})
|
||||
.WithName("NotifyListAuditEntries")
|
||||
.WithDescription("Returns paginated audit log entries for the tenant, ordered by creation time descending. Supports limit and offset parameters for pagination. Requires notify.viewer scope.")
|
||||
.WithDescription(_t("notify.audit.list_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Viewer);
|
||||
|
||||
apiGroup.MapPost("/locks/acquire", async ([FromBody] AcquireLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -1136,7 +1142,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
return JsonResponse(new { acquired });
|
||||
})
|
||||
.WithName("NotifyAcquireLock")
|
||||
.WithDescription("Attempts to acquire a distributed advisory lock for a named resource and owner with a TTL. Returns a JSON object with an acquired boolean indicating whether the lock was successfully taken. Used for coordinating plugin dispatch and digest flushing. Requires notify.operator scope.")
|
||||
.WithDescription(_t("notify.locks.acquire_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
|
||||
apiGroup.MapPost("/locks/release", async ([FromBody] ReleaseLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
@@ -1150,7 +1156,7 @@ static void ConfigureEndpoints(WebApplication app)
|
||||
return released ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("NotifyReleaseLock")
|
||||
.WithDescription("Releases a previously acquired distributed advisory lock for the specified resource and owner. Returns 204 No Content on success or 404 if the lock was not found or already released. Requires notify.operator scope.")
|
||||
.WithDescription(_t("notify.locks.release_description"))
|
||||
.RequireAuthorization(NotifyPolicies.Operator);
|
||||
}
|
||||
|
||||
@@ -1514,7 +1520,7 @@ static IResult Normalize<TModel>(JsonNode? body, Func<JsonNode, TModel> upgrade)
|
||||
{
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
return Results.BadRequest(new { error = _t("notify.error.request_body_required") });
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Translations\*.json" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"_meta": { "locale": "en-US", "namespace": "notify", "version": "1.0" },
|
||||
|
||||
"notify.healthz.description": "Liveness probe endpoint for the Notify service. Returns HTTP 200 with a JSON status body when the process is running. No authentication required.",
|
||||
"notify.readyz.description": "Readiness probe endpoint for the Notify service. Returns HTTP 200 with a structured status body when the service is ready to accept traffic. Returns HTTP 503 if the service is not yet ready. No authentication required.",
|
||||
"notify.internal.rules_normalize_description": "Internal endpoint that upgrades a notify rule JSON payload from an older schema version to the current canonical format. Returns the normalized rule JSON.",
|
||||
"notify.internal.channels_normalize_description": "Internal endpoint that upgrades a notify channel JSON payload from an older schema version to the current canonical format. Returns the normalized channel JSON.",
|
||||
"notify.internal.templates_normalize_description": "Internal endpoint that upgrades a notify template JSON payload from an older schema version to the current canonical format. Returns the normalized template JSON.",
|
||||
"notify.rules.list_description": "Lists all notification rules for the tenant. Returns an array of rule objects including match filters and channel actions. Requires notify.viewer scope.",
|
||||
"notify.rules.get_description": "Returns the full notification rule for a specific rule ID. Returns 404 if the rule is not found. Requires notify.viewer scope.",
|
||||
"notify.rules.upsert_description": "Creates or updates a notification rule for the tenant. Accepts the canonical rule JSON, validates schema migration, and upserts into storage. Returns 201 Created with the rule record. Requires notify.operator scope.",
|
||||
"notify.rules.delete_description": "Permanently removes a notification rule from the tenant. Returns 204 No Content on success or 404 if the rule is not found. Requires notify.operator scope.",
|
||||
"notify.channels.list_description": "Lists all notification channels configured for the tenant, including channel type, enabled state, and configuration. Requires notify.viewer scope.",
|
||||
"notify.channels.get_description": "Returns the full channel record for a specific channel ID, including type, configuration, and enabled state. Returns 404 if the channel is not found. Requires notify.viewer scope.",
|
||||
"notify.channels.upsert_description": "Creates or updates a notification channel for the tenant. Accepts a channel JSON payload with type and configuration, upgrades schema if needed, and upserts into storage. Returns 201 Created with the channel record. Requires notify.operator scope.",
|
||||
"notify.channels.test_description": "Sends a test notification through the specified channel to validate connectivity and configuration. Returns 202 Accepted with the test send response. Subject to test-send rate limiting. Requires notify.operator scope.",
|
||||
"notify.channels.delete_description": "Removes a notification channel from the tenant. Returns 204 No Content on successful deletion. Requires notify.operator scope.",
|
||||
"notify.templates.list_description": "Lists all notification templates configured for the tenant, including body templates and locale settings. Requires notify.viewer scope.",
|
||||
"notify.templates.get_description": "Returns the full notification template for a specific template ID, including channel type, body template, and locale. Returns 404 if the template is not found. Requires notify.viewer scope.",
|
||||
"notify.templates.upsert_description": "Creates or updates a notification template for the tenant. Accepts a template JSON payload, applies schema migration, and upserts into storage. Returns 201 Created with the template record. Requires notify.operator scope.",
|
||||
"notify.templates.delete_description": "Removes a notification template from the tenant. Returns 204 No Content on successful deletion. Requires notify.operator scope.",
|
||||
"notify.deliveries.create_description": "Records a notification delivery attempt for the tenant. Accepts the canonical delivery JSON including rendered content, channel reference, and delivery status. Returns 201 Created with the delivery detail record. Requires notify.operator scope.",
|
||||
"notify.deliveries.list_description": "Queries delivery history for the tenant with optional filters for status, channel, event type, and time range. Supports pagination via limit and offset. Returns a paged list of delivery summary records. Subject to delivery-history rate limiting. Requires notify.viewer scope.",
|
||||
"notify.deliveries.get_description": "Returns the full delivery detail record for a specific delivery ID, including channel name, rendered subject, attempt count, sent timestamp, and error information. Subject to delivery-history rate limiting. Requires notify.viewer scope.",
|
||||
"notify.digests.upsert_description": "Creates or updates a notification digest accumulator for a channel and recipient. Digests collect events over a collection window before sending a batched notification. Returns 201 Created with the digest record. Requires notify.operator scope.",
|
||||
"notify.digests.get_description": "Returns the current state of a notification digest identified by channel, recipient, and action key. Returns 404 if no active digest is found. Requires notify.viewer scope.",
|
||||
"notify.digests.delete_description": "Removes a pending notification digest for a channel and recipient, cancelling any queued batched notification. Returns 204 No Content on success or 404 if not found. Requires notify.operator scope.",
|
||||
"notify.audit.create_description": "Records an audit log entry for a notify action performed by the authenticated user. Captures the action, entity type, entity ID, and optional payload. Returns 201 Created with the new audit entry ID. Requires notify.operator scope.",
|
||||
"notify.audit.list_description": "Returns paginated audit log entries for the tenant, ordered by creation time descending. Supports limit and offset parameters for pagination. Requires notify.viewer scope.",
|
||||
"notify.locks.acquire_description": "Attempts to acquire a distributed advisory lock for a named resource and owner with a TTL. Returns a JSON object with an acquired boolean indicating whether the lock was successfully taken. Used for coordinating plugin dispatch and digest flushing. Requires notify.operator scope.",
|
||||
"notify.locks.release_description": "Releases a previously acquired distributed advisory lock for the specified resource and owner. Returns 204 No Content on success or 404 if the lock was not found or already released. Requires notify.operator scope.",
|
||||
|
||||
"notify.error.rule_id_must_be_guid": "ruleId must be a GUID.",
|
||||
"notify.error.channel_id_must_be_guid": "channelId must be a GUID.",
|
||||
"notify.error.template_id_must_be_guid": "templateId must be a GUID.",
|
||||
"notify.error.delivery_id_must_be_guid": "deliveryId must be a GUID.",
|
||||
"notify.error.action_id_must_be_guid": "actionId must be a GUID representing the channel.",
|
||||
"notify.error.request_body_required": "Request body is required.",
|
||||
"notify.error.rule_payload_invalid": "Invalid rule payload: {0}",
|
||||
"notify.error.channel_payload_invalid": "Invalid channel payload: {0}",
|
||||
"notify.error.delivery_payload_invalid": "Invalid delivery payload: {0}",
|
||||
"notify.error.tenant_mismatch": "Tenant mismatch between header and payload.",
|
||||
"notify.error.delivery_status_unknown": "Unknown delivery status.",
|
||||
"notify.error.tenant_required": "Tenant must be provided via header or query string.",
|
||||
"notify.error.recipient_required": "recipient is required.",
|
||||
"notify.error.digest_key_required": "digestKey is required.",
|
||||
"notify.error.action_required": "Action is required."
|
||||
}
|
||||
Reference in New Issue
Block a user