- Delete dead Notify Worker (NoOp handler) - Move 51 source files (endpoints, contracts, services, compat stores) - Transform namespaces from Notifier.WebService to Notify.WebService - Update DI registrations, WebSocket support, v2 endpoint mapping - Comment out notifier-web in compose, update gateway routes - Update architecture docs, port registry, rollout matrix - Notifier Worker stays as separate delivery engine container Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
322 lines
12 KiB
C#
322 lines
12 KiB
C#
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
using StellaOps.Notify.WebService.Constants;
|
|
using StellaOps.Notify.WebService.Extensions;
|
|
using StellaOps.Notifier.Worker.Correlation;
|
|
using static StellaOps.Localization.T;
|
|
|
|
namespace StellaOps.Notify.WebService.Endpoints;
|
|
|
|
/// <summary>
|
|
/// API endpoints for operator override management.
|
|
/// </summary>
|
|
public static class OperatorOverrideEndpoints
|
|
{
|
|
/// <summary>
|
|
/// Maps operator override endpoints.
|
|
/// </summary>
|
|
public static IEndpointRouteBuilder MapOperatorOverrideEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
var group = app.MapGroup("/api/v2/overrides")
|
|
.WithTags("Overrides")
|
|
.WithOpenApi()
|
|
.RequireAuthorization(NotifierPolicies.NotifyViewer)
|
|
.RequireTenant();
|
|
|
|
group.MapGet("/", ListOverridesAsync)
|
|
.WithName("ListOperatorOverrides")
|
|
.WithSummary("List active operator overrides")
|
|
.WithDescription(_t("notifier.override.list_description"));
|
|
|
|
group.MapGet("/{overrideId}", GetOverrideAsync)
|
|
.WithName("GetOperatorOverride")
|
|
.WithSummary("Get an operator override")
|
|
.WithDescription(_t("notifier.override.get_description"));
|
|
|
|
group.MapPost("/", CreateOverrideAsync)
|
|
.WithName("CreateOperatorOverride")
|
|
.WithSummary("Create an operator override")
|
|
.WithDescription(_t("notifier.override.create_description"))
|
|
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
|
|
|
group.MapPost("/{overrideId}/revoke", RevokeOverrideAsync)
|
|
.WithName("RevokeOperatorOverride")
|
|
.WithSummary("Revoke an operator override")
|
|
.WithDescription(_t("notifier.override.revoke_description"))
|
|
.RequireAuthorization(NotifierPolicies.NotifyOperator);
|
|
|
|
group.MapPost("/check", CheckOverrideAsync)
|
|
.WithName("CheckOperatorOverride")
|
|
.WithSummary("Check for applicable override")
|
|
.WithDescription(_t("notifier.override.check_description"));
|
|
|
|
return app;
|
|
}
|
|
|
|
private static async Task<IResult> ListOverridesAsync(
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromServices] IOperatorOverrideService overrideService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tenantId))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
|
}
|
|
|
|
var overrides = await overrideService.ListActiveOverridesAsync(tenantId, cancellationToken);
|
|
|
|
return Results.Ok(overrides.Select(MapToApiResponse));
|
|
}
|
|
|
|
private static async Task<IResult> GetOverrideAsync(
|
|
string overrideId,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromServices] IOperatorOverrideService overrideService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tenantId))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
|
}
|
|
|
|
var @override = await overrideService.GetOverrideAsync(tenantId, overrideId, cancellationToken);
|
|
|
|
if (@override is null)
|
|
{
|
|
return Results.NotFound(new { error = _t("notifier.error.override_not_found", overrideId) });
|
|
}
|
|
|
|
return Results.Ok(MapToApiResponse(@override));
|
|
}
|
|
|
|
private static async Task<IResult> CreateOverrideAsync(
|
|
[FromBody] OperatorOverrideApiRequest request,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
|
[FromHeader(Name = "X-Actor")] string? actorHeader,
|
|
[FromServices] IOperatorOverrideService overrideService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = request.TenantId ?? tenantIdHeader;
|
|
if (string.IsNullOrWhiteSpace(tenantId))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
|
|
}
|
|
|
|
var actor = request.Actor ?? actorHeader;
|
|
if (string.IsNullOrWhiteSpace(actor))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notifier.error.actor_required") });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Reason))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notifier.error.reason_required") });
|
|
}
|
|
|
|
if (request.DurationMinutes is null or <= 0)
|
|
{
|
|
return Results.BadRequest(new { error = _t("notifier.error.duration_required") });
|
|
}
|
|
|
|
var createRequest = new OperatorOverrideCreate
|
|
{
|
|
Type = MapOverrideType(request.Type),
|
|
Reason = request.Reason,
|
|
Duration = TimeSpan.FromMinutes(request.DurationMinutes.Value),
|
|
EffectiveFrom = request.EffectiveFrom,
|
|
EventKinds = request.EventKinds,
|
|
CorrelationKeys = request.CorrelationKeys,
|
|
MaxUsageCount = request.MaxUsageCount
|
|
};
|
|
|
|
try
|
|
{
|
|
var created = await overrideService.CreateOverrideAsync(tenantId, createRequest, actor, cancellationToken);
|
|
return Results.Created($"/api/v2/overrides/{created.OverrideId}", MapToApiResponse(created));
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.Conflict(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> RevokeOverrideAsync(
|
|
string overrideId,
|
|
[FromBody] RevokeOverrideApiRequest? request,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromHeader(Name = "X-Actor")] string? actorHeader,
|
|
[FromServices] IOperatorOverrideService overrideService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tenantId))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
|
|
}
|
|
|
|
var actor = request?.Actor ?? actorHeader;
|
|
if (string.IsNullOrWhiteSpace(actor))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notifier.error.actor_required") });
|
|
}
|
|
|
|
var revoked = await overrideService.RevokeOverrideAsync(
|
|
tenantId,
|
|
overrideId,
|
|
actor,
|
|
request?.Reason,
|
|
cancellationToken);
|
|
|
|
if (!revoked)
|
|
{
|
|
return Results.NotFound(new { error = _t("notifier.error.override_not_found_or_inactive", overrideId) });
|
|
}
|
|
|
|
return Results.NoContent();
|
|
}
|
|
|
|
private static async Task<IResult> CheckOverrideAsync(
|
|
[FromBody] CheckOverrideApiRequest request,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
|
[FromServices] IOperatorOverrideService overrideService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = request.TenantId ?? tenantIdHeader;
|
|
if (string.IsNullOrWhiteSpace(tenantId))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.EventKind))
|
|
{
|
|
return Results.BadRequest(new { error = _t("notifier.error.event_kind_required") });
|
|
}
|
|
|
|
var result = await overrideService.CheckOverrideAsync(
|
|
tenantId,
|
|
request.EventKind,
|
|
request.CorrelationKey,
|
|
cancellationToken);
|
|
|
|
return Results.Ok(new CheckOverrideApiResponse
|
|
{
|
|
HasOverride = result.HasOverride,
|
|
BypassedTypes = MapOverrideTypeToStrings(result.BypassedTypes),
|
|
Override = result.Override is not null ? MapToApiResponse(result.Override) : null
|
|
});
|
|
}
|
|
|
|
private static OverrideType MapOverrideType(string? type) => type?.ToLowerInvariant() switch
|
|
{
|
|
"quiethours" or "quiet_hours" => OverrideType.QuietHours,
|
|
"throttle" => OverrideType.Throttle,
|
|
"maintenance" => OverrideType.Maintenance,
|
|
"all" or _ => OverrideType.All
|
|
};
|
|
|
|
private static List<string> MapOverrideTypeToStrings(OverrideType type)
|
|
{
|
|
var result = new List<string>();
|
|
if (type.HasFlag(OverrideType.QuietHours)) result.Add("quiet_hours");
|
|
if (type.HasFlag(OverrideType.Throttle)) result.Add("throttle");
|
|
if (type.HasFlag(OverrideType.Maintenance)) result.Add("maintenance");
|
|
return result;
|
|
}
|
|
|
|
private static OperatorOverrideApiResponse MapToApiResponse(OperatorOverride @override) => new()
|
|
{
|
|
OverrideId = @override.OverrideId,
|
|
TenantId = @override.TenantId,
|
|
Type = MapOverrideTypeToStrings(@override.Type),
|
|
Reason = @override.Reason,
|
|
EffectiveFrom = @override.EffectiveFrom,
|
|
ExpiresAt = @override.ExpiresAt,
|
|
EventKinds = @override.EventKinds.ToList(),
|
|
CorrelationKeys = @override.CorrelationKeys.ToList(),
|
|
MaxUsageCount = @override.MaxUsageCount,
|
|
UsageCount = @override.UsageCount,
|
|
Status = @override.Status.ToString().ToLowerInvariant(),
|
|
CreatedBy = @override.CreatedBy,
|
|
CreatedAt = @override.CreatedAt,
|
|
RevokedBy = @override.RevokedBy,
|
|
RevokedAt = @override.RevokedAt,
|
|
RevocationReason = @override.RevocationReason
|
|
};
|
|
}
|
|
|
|
#region API Request/Response Models
|
|
|
|
/// <summary>
|
|
/// Request to create an operator override.
|
|
/// </summary>
|
|
public sealed class OperatorOverrideApiRequest
|
|
{
|
|
public string? TenantId { get; set; }
|
|
public string? Actor { get; set; }
|
|
public string? Type { get; set; }
|
|
public string? Reason { get; set; }
|
|
public int? DurationMinutes { get; set; }
|
|
public DateTimeOffset? EffectiveFrom { get; set; }
|
|
public List<string>? EventKinds { get; set; }
|
|
public List<string>? CorrelationKeys { get; set; }
|
|
public int? MaxUsageCount { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request to revoke an operator override.
|
|
/// </summary>
|
|
public sealed class RevokeOverrideApiRequest
|
|
{
|
|
public string? Actor { get; set; }
|
|
public string? Reason { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request to check for applicable override.
|
|
/// </summary>
|
|
public sealed class CheckOverrideApiRequest
|
|
{
|
|
public string? TenantId { get; set; }
|
|
public string? EventKind { get; set; }
|
|
public string? CorrelationKey { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Response for an operator override.
|
|
/// </summary>
|
|
public sealed class OperatorOverrideApiResponse
|
|
{
|
|
public required string OverrideId { get; set; }
|
|
public required string TenantId { get; set; }
|
|
public required List<string> Type { get; set; }
|
|
public required string Reason { get; set; }
|
|
public required DateTimeOffset EffectiveFrom { get; set; }
|
|
public required DateTimeOffset ExpiresAt { get; set; }
|
|
public required List<string> EventKinds { get; set; }
|
|
public required List<string> CorrelationKeys { get; set; }
|
|
public int? MaxUsageCount { get; set; }
|
|
public required int UsageCount { get; set; }
|
|
public required string Status { get; set; }
|
|
public required string CreatedBy { get; set; }
|
|
public required DateTimeOffset CreatedAt { get; set; }
|
|
public string? RevokedBy { get; set; }
|
|
public DateTimeOffset? RevokedAt { get; set; }
|
|
public string? RevocationReason { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Response for override check.
|
|
/// </summary>
|
|
public sealed class CheckOverrideApiResponse
|
|
{
|
|
public required bool HasOverride { get; set; }
|
|
public required List<string> BypassedTypes { get; set; }
|
|
public OperatorOverrideApiResponse? Override { get; set; }
|
|
}
|
|
|
|
#endregion
|