Files
git.stella-ops.org/src/Notify/StellaOps.Notify.WebService/Endpoints/OperatorOverrideEndpoints.cs
master 9eec100204 refactor(notify): merge Notifier WebService into Notify WebService
- 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>
2026-04-08 13:17:13 +03:00

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