feat(notifier): postgres suppression admin runtime

Sprint SPRINT_20260416_008_Notify_truthful_suppression_admin_runtime.

Postgres-backed suppression runtime services wired through the admin
runtime extension registered in the durable storage bootstrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-19 14:39:37 +03:00
parent 23bef5befc
commit b877e13b3c

View File

@@ -0,0 +1,841 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Models;
using AuditEntity = StellaOps.Notify.Persistence.Postgres.Models.NotifyAuditEntity;
using OperatorOverrideEntity = StellaOps.Notify.Persistence.Postgres.Models.OperatorOverrideEntity;
using ThrottleConfigEntity = StellaOps.Notify.Persistence.Postgres.Models.ThrottleConfigEntity;
using PostgresAuditRepository = StellaOps.Notify.Persistence.Postgres.Repositories.INotifyAuditRepository;
using PostgresOperatorOverrideRepository = StellaOps.Notify.Persistence.Postgres.Repositories.IOperatorOverrideRepository;
using PostgresThrottleConfigRepository = StellaOps.Notify.Persistence.Postgres.Repositories.IThrottleConfigRepository;
namespace StellaOps.Notifier.Worker.Correlation;
public sealed class PostgresSuppressionAuditLogger : ISuppressionAuditLogger
{
private readonly PostgresAuditRepository _auditRepository;
private readonly ILogger<PostgresSuppressionAuditLogger> _logger;
public PostgresSuppressionAuditLogger(
PostgresAuditRepository auditRepository,
ILogger<PostgresSuppressionAuditLogger> logger)
{
_auditRepository = auditRepository;
_logger = logger;
}
public async Task LogAsync(SuppressionAuditEntry entry, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var audit = new AuditEntity
{
TenantId = entry.TenantId,
Action = MapAction(entry.Action),
ResourceType = entry.ResourceType,
ResourceId = entry.ResourceId,
Details = SerializeDetails(entry),
CorrelationId = entry.CorrelationId,
CreatedAt = entry.Timestamp
};
await _auditRepository.CreateAsync(audit, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Suppression audit persisted for tenant {TenantId}: {Action} {ResourceType}/{ResourceId}.",
entry.TenantId,
entry.Action,
entry.ResourceType,
entry.ResourceId);
}
public async Task<IReadOnlyList<SuppressionAuditEntry>> QueryAsync(
SuppressionAuditQuery query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
var fetchLimit = Math.Clamp(query.Offset + query.Limit, query.Limit, 1000);
var rows = await _auditRepository.ListAsync(query.TenantId, fetchLimit, 0, cancellationToken).ConfigureAwait(false);
var filtered = rows
.Select(MapAudit)
.Where(static entry => entry is not null)
.Select(static entry => entry!)
.Where(entry => !query.From.HasValue || entry.Timestamp >= query.From.Value)
.Where(entry => !query.To.HasValue || entry.Timestamp <= query.To.Value)
.Where(entry => query.Actions is null || query.Actions.Count == 0 || query.Actions.Contains(entry.Action))
.Where(entry => string.IsNullOrWhiteSpace(query.Actor) || string.Equals(entry.Actor, query.Actor, StringComparison.OrdinalIgnoreCase))
.Where(entry => string.IsNullOrWhiteSpace(query.ResourceType) || string.Equals(entry.ResourceType, query.ResourceType, StringComparison.OrdinalIgnoreCase))
.Where(entry => string.IsNullOrWhiteSpace(query.ResourceId) || string.Equals(entry.ResourceId, query.ResourceId, StringComparison.OrdinalIgnoreCase))
.Skip(query.Offset)
.Take(query.Limit)
.ToList();
return filtered;
}
private static string MapAction(SuppressionAuditAction action) => action switch
{
SuppressionAuditAction.CalendarCreated => "suppression.calendar.created",
SuppressionAuditAction.CalendarUpdated => "suppression.calendar.updated",
SuppressionAuditAction.CalendarDeleted => "suppression.calendar.deleted",
SuppressionAuditAction.ThrottleConfigUpdated => "suppression.throttle.updated",
SuppressionAuditAction.ThrottleConfigDeleted => "suppression.throttle.deleted",
SuppressionAuditAction.MaintenanceWindowCreated => "suppression.maintenance.created",
SuppressionAuditAction.MaintenanceWindowUpdated => "suppression.maintenance.updated",
SuppressionAuditAction.MaintenanceWindowDeleted => "suppression.maintenance.deleted",
SuppressionAuditAction.OverrideCreated => "suppression.override.created",
SuppressionAuditAction.OverrideExpired => "suppression.override.expired",
SuppressionAuditAction.OverrideRevoked => "suppression.override.revoked",
SuppressionAuditAction.OverrideUsed => "suppression.override.used",
_ => "suppression.unknown"
};
private static SuppressionAuditAction? MapAction(string action) => action switch
{
"suppression.calendar.created" => SuppressionAuditAction.CalendarCreated,
"suppression.calendar.updated" => SuppressionAuditAction.CalendarUpdated,
"suppression.calendar.deleted" => SuppressionAuditAction.CalendarDeleted,
"suppression.throttle.updated" => SuppressionAuditAction.ThrottleConfigUpdated,
"suppression.throttle.deleted" => SuppressionAuditAction.ThrottleConfigDeleted,
"suppression.maintenance.created" => SuppressionAuditAction.MaintenanceWindowCreated,
"suppression.maintenance.updated" => SuppressionAuditAction.MaintenanceWindowUpdated,
"suppression.maintenance.deleted" => SuppressionAuditAction.MaintenanceWindowDeleted,
"suppression.override.created" => SuppressionAuditAction.OverrideCreated,
"suppression.override.expired" => SuppressionAuditAction.OverrideExpired,
"suppression.override.revoked" => SuppressionAuditAction.OverrideRevoked,
"suppression.override.used" => SuppressionAuditAction.OverrideUsed,
_ => null
};
private static string SerializeDetails(SuppressionAuditEntry entry)
{
var details = new SortedDictionary<string, object?>(StringComparer.Ordinal)
{
["clientIp"] = entry.ClientIp,
["correlationId"] = entry.CorrelationId,
["userAgent"] = entry.UserAgent
};
if (entry.Details is not null)
{
foreach (var (key, value) in entry.Details.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
details[key] = value;
}
}
return JsonSerializer.Serialize(details);
}
private static SuppressionAuditEntry? MapAudit(AuditEntity row)
{
var action = MapAction(row.Action);
if (!action.HasValue)
{
return null;
}
return new SuppressionAuditEntry
{
EntryId = row.Id.ToString(),
TenantId = row.TenantId,
Timestamp = row.CreatedAt,
Actor = "unknown",
Action = action.Value,
ResourceType = row.ResourceType,
ResourceId = row.ResourceId ?? string.Empty,
Details = DeserializeDetails(row.Details),
CorrelationId = row.CorrelationId
};
}
private static IReadOnlyDictionary<string, object>? DeserializeDetails(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
var details = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json);
if (details is null)
{
return null;
}
return details.ToDictionary(pair => pair.Key, pair => (object)pair.Value, StringComparer.Ordinal);
}
catch
{
return null;
}
}
}
public sealed class PostgresThrottleConfigurationService : IThrottleConfigurationService
{
private const string DefaultConfigId = "default";
private readonly PostgresThrottleConfigRepository _repository;
private readonly PostgresAuditRepository _auditRepository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PostgresThrottleConfigurationService> _logger;
public PostgresThrottleConfigurationService(
PostgresThrottleConfigRepository repository,
PostgresAuditRepository auditRepository,
TimeProvider timeProvider,
ILogger<PostgresThrottleConfigurationService> logger)
{
_repository = repository;
_auditRepository = auditRepository;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<ThrottleConfiguration?> GetConfigurationAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var entity = await _repository.GetDefaultAsync(tenantId, cancellationToken).ConfigureAwait(false);
return entity is null ? null : MapConfiguration(entity);
}
public async Task<ThrottleConfiguration> UpsertConfigurationAsync(
ThrottleConfiguration configuration,
string? actor,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(configuration);
var now = _timeProvider.GetUtcNow();
var existing = await _repository.GetDefaultAsync(configuration.TenantId, cancellationToken).ConfigureAwait(false);
var entity = BuildEntity(configuration, actor, now, existing);
if (existing is null)
{
await _repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
}
else
{
await _repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
}
await AppendAuditAsync(
configuration.TenantId,
existing is null ? "throttle_config_created" : "throttle_config_updated",
actor,
new SortedDictionary<string, object?>(StringComparer.Ordinal)
{
["burstWindowDurationSeconds"] = configuration.BurstWindowDuration.HasValue
? (int)configuration.BurstWindowDuration.Value.TotalSeconds
: null,
["defaultDurationSeconds"] = (int)configuration.DefaultDuration.TotalSeconds,
["enabled"] = configuration.Enabled,
["eventKindOverrides"] = configuration.EventKindOverrides?.ToDictionary(
pair => pair.Key,
pair => (int)pair.Value.TotalSeconds,
StringComparer.Ordinal),
["maxEventsPerWindow"] = configuration.MaxEventsPerWindow
},
cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Persisted throttle configuration for tenant {TenantId}.",
configuration.TenantId);
return MapConfiguration(entity);
}
public async Task<bool> DeleteConfigurationAsync(
string tenantId,
string? actor,
CancellationToken cancellationToken = default)
{
var deleted = await _repository.DeleteAsync(tenantId, DefaultConfigId, cancellationToken).ConfigureAwait(false);
if (!deleted)
{
return false;
}
await AppendAuditAsync(
tenantId,
"throttle_config_deleted",
actor,
null,
cancellationToken).ConfigureAwait(false);
return true;
}
public async Task<TimeSpan> GetEffectiveThrottleDurationAsync(
string tenantId,
string eventKind,
CancellationToken cancellationToken = default)
{
var configuration = await GetConfigurationAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (configuration is null || !configuration.Enabled)
{
return TimeSpan.FromMinutes(15);
}
if (configuration.EventKindOverrides is { Count: > 0 })
{
var match = configuration.EventKindOverrides
.Where(pair => eventKind.StartsWith(pair.Key, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(pair => pair.Key.Length)
.Select(pair => (TimeSpan?)pair.Value)
.FirstOrDefault();
if (match.HasValue)
{
return match.Value;
}
}
return configuration.DefaultDuration;
}
private static ThrottleConfiguration MapConfiguration(ThrottleConfigEntity entity)
{
var metadata = DeserializeThrottleMetadata(entity.Metadata);
var overrides = metadata.EventKindOverrides?.ToDictionary(
pair => pair.Key,
pair => TimeSpan.FromSeconds(pair.Value),
StringComparer.Ordinal);
return new ThrottleConfiguration
{
TenantId = entity.TenantId,
DefaultDuration = entity.DefaultWindow,
EventKindOverrides = overrides,
MaxEventsPerWindow = entity.MaxNotificationsPerWindow,
BurstWindowDuration = metadata.BurstWindowDurationSeconds.HasValue
? TimeSpan.FromSeconds(metadata.BurstWindowDurationSeconds.Value)
: null,
Enabled = entity.Enabled,
CreatedAt = entity.CreatedAt,
CreatedBy = entity.CreatedBy,
UpdatedAt = entity.UpdatedAt,
UpdatedBy = entity.UpdatedBy
};
}
private static ThrottleConfigEntity BuildEntity(
ThrottleConfiguration configuration,
string? actor,
DateTimeOffset now,
ThrottleConfigEntity? existing)
{
var metadata = new ThrottleMetadataPayload(
configuration.EventKindOverrides?.ToDictionary(
pair => pair.Key,
pair => (int)pair.Value.TotalSeconds,
StringComparer.Ordinal),
configuration.BurstWindowDuration.HasValue
? (int)configuration.BurstWindowDuration.Value.TotalSeconds
: null);
return new ThrottleConfigEntity
{
ConfigId = DefaultConfigId,
TenantId = configuration.TenantId,
Name = existing?.Name ?? "Tenant Default Throttle",
DefaultWindow = configuration.DefaultDuration,
MaxNotificationsPerWindow = configuration.MaxEventsPerWindow,
ChannelId = null,
IsDefault = true,
Enabled = configuration.Enabled,
Description = existing?.Description,
Metadata = JsonSerializer.Serialize(metadata),
CreatedBy = existing?.CreatedBy ?? actor,
CreatedAt = existing?.CreatedAt ?? now,
UpdatedBy = actor,
UpdatedAt = now
};
}
private async Task AppendAuditAsync(
string tenantId,
string action,
string? actor,
object? details,
CancellationToken cancellationToken)
{
var payload = new SortedDictionary<string, object?>(StringComparer.Ordinal)
{
["actor"] = actor
};
if (details is not null)
{
payload["details"] = details;
}
await _auditRepository.CreateAsync(
new AuditEntity
{
TenantId = tenantId,
Action = action,
ResourceType = "throttle-config",
ResourceId = DefaultConfigId,
Details = JsonSerializer.Serialize(payload),
CreatedAt = _timeProvider.GetUtcNow()
},
cancellationToken).ConfigureAwait(false);
}
private static ThrottleMetadataPayload DeserializeThrottleMetadata(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new ThrottleMetadataPayload(null, null);
}
try
{
return JsonSerializer.Deserialize<ThrottleMetadataPayload>(json)
?? new ThrottleMetadataPayload(null, null);
}
catch
{
return new ThrottleMetadataPayload(null, null);
}
}
private sealed record ThrottleMetadataPayload(
IReadOnlyDictionary<string, int>? EventKindOverrides,
int? BurstWindowDurationSeconds);
}
public sealed class PostgresOperatorOverrideService : IOperatorOverrideService
{
private readonly PostgresOperatorOverrideRepository _repository;
private readonly ISuppressionAuditLogger _auditLogger;
private readonly OperatorOverrideOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PostgresOperatorOverrideService> _logger;
public PostgresOperatorOverrideService(
PostgresOperatorOverrideRepository repository,
ISuppressionAuditLogger auditLogger,
IOptions<OperatorOverrideOptions> options,
TimeProvider timeProvider,
ILogger<PostgresOperatorOverrideService> logger)
{
_repository = repository;
_auditLogger = auditLogger;
_options = options.Value;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<OperatorOverride> CreateOverrideAsync(
string tenantId,
OperatorOverrideCreate request,
string actor,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var now = _timeProvider.GetUtcNow();
if (request.Duration > _options.MaxDuration)
{
throw new ArgumentException(
$"Override duration ({request.Duration}) exceeds maximum allowed ({_options.MaxDuration}).",
nameof(request));
}
if (request.Duration < _options.MinDuration)
{
throw new ArgumentException(
$"Override duration ({request.Duration}) is below minimum allowed ({_options.MinDuration}).",
nameof(request));
}
var activeCount = (await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false))
.Select(MapOverride)
.Count(existing => existing.Status == OverrideStatus.Active && existing.ExpiresAt > now);
if (activeCount >= _options.MaxActiveOverridesPerTenant)
{
throw new InvalidOperationException(
$"Maximum active overrides ({_options.MaxActiveOverridesPerTenant}) reached for tenant.");
}
var effectiveFrom = request.EffectiveFrom ?? now;
var created = new OperatorOverrideEntity
{
OverrideId = $"ovr-{Guid.NewGuid():N}"[..16],
TenantId = tenantId,
OverrideType = request.Type.ToString(),
EffectiveFrom = effectiveFrom,
ExpiresAt = effectiveFrom + request.Duration,
Reason = request.Reason,
EventKinds = SerializeStringList(request.EventKinds),
CorrelationKeys = SerializeStringList(request.CorrelationKeys),
MaxUsageCount = request.MaxUsageCount,
UsageCount = 0,
Status = "active",
CreatedBy = actor,
CreatedAt = now,
UpdatedAt = now
};
await _repository.CreateAsync(created, cancellationToken).ConfigureAwait(false);
var model = MapOverride(created);
var createdDetails = new Dictionary<string, object>
{
["correlationKeys"] = model.CorrelationKeys,
["eventKinds"] = model.EventKinds,
["expiresAt"] = model.ExpiresAt,
["type"] = model.Type.ToString()
};
if (model.MaxUsageCount.HasValue)
{
createdDetails["maxUsageCount"] = model.MaxUsageCount.Value;
}
if (!string.IsNullOrWhiteSpace(model.Reason))
{
createdDetails["reason"] = model.Reason;
}
await _auditLogger.LogAsync(
new SuppressionAuditEntry
{
EntryId = Guid.NewGuid().ToString("N")[..16],
TenantId = tenantId,
Timestamp = now,
Actor = actor,
Action = SuppressionAuditAction.OverrideCreated,
ResourceType = "OperatorOverride",
ResourceId = created.OverrideId,
Details = createdDetails
},
cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Persisted operator override {OverrideId} for tenant {TenantId}.",
created.OverrideId,
tenantId);
return model;
}
public async Task<OperatorOverride?> GetOverrideAsync(
string tenantId,
string overrideId,
CancellationToken cancellationToken = default)
{
var entity = await _repository.GetByIdAsync(tenantId, overrideId, cancellationToken).ConfigureAwait(false);
return entity is null ? null : MapOverride(entity);
}
public async Task<IReadOnlyList<OperatorOverride>> ListActiveOverridesAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var overrides = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
return overrides
.Select(MapOverride)
.Where(model => model.Status == OverrideStatus.Active && model.EffectiveFrom <= now && model.ExpiresAt > now)
.OrderBy(model => model.ExpiresAt)
.ToList();
}
public async Task<bool> RevokeOverrideAsync(
string tenantId,
string overrideId,
string actor,
string? reason,
CancellationToken cancellationToken = default)
{
var entity = await _repository.GetByIdAsync(tenantId, overrideId, cancellationToken).ConfigureAwait(false);
if (entity is null)
{
return false;
}
var current = MapOverride(entity);
if (current.Status != OverrideStatus.Active)
{
return false;
}
var now = _timeProvider.GetUtcNow();
var updated = CopyEntity(
entity,
status: "revoked",
revokedBy: actor,
revokedAt: now,
revocationReason: reason,
updatedAt: now);
var saved = await _repository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false);
if (!saved)
{
return false;
}
var revokedDetails = new Dictionary<string, object>
{
["usageCount"] = current.UsageCount
};
if (!string.IsNullOrWhiteSpace(current.Reason))
{
revokedDetails["originalReason"] = current.Reason;
}
if (!string.IsNullOrWhiteSpace(reason))
{
revokedDetails["reason"] = reason;
}
await _auditLogger.LogAsync(
new SuppressionAuditEntry
{
EntryId = Guid.NewGuid().ToString("N")[..16],
TenantId = tenantId,
Timestamp = now,
Actor = actor,
Action = SuppressionAuditAction.OverrideRevoked,
ResourceType = "OperatorOverride",
ResourceId = overrideId,
Details = revokedDetails
},
cancellationToken).ConfigureAwait(false);
return true;
}
public async Task<OverrideCheckResult> CheckOverrideAsync(
string tenantId,
string eventKind,
string? correlationKey,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var applicable = (await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false))
.Select(MapOverride)
.Where(model => model.Status == OverrideStatus.Active)
.Where(model => model.EffectiveFrom <= now && model.ExpiresAt > now)
.Where(model => MatchesEventKind(model, eventKind))
.Where(model => MatchesCorrelationKey(model, correlationKey))
.Where(model => !model.MaxUsageCount.HasValue || model.UsageCount < model.MaxUsageCount)
.OrderByDescending(model => model.CreatedAt)
.FirstOrDefault();
return applicable is null
? OverrideCheckResult.NoOverride()
: OverrideCheckResult.WithOverride(applicable);
}
public async Task RecordOverrideUsageAsync(
string tenantId,
string overrideId,
string eventKind,
CancellationToken cancellationToken = default)
{
var entity = await _repository.GetByIdAsync(tenantId, overrideId, cancellationToken).ConfigureAwait(false);
if (entity is null)
{
return;
}
var model = MapOverride(entity);
var now = _timeProvider.GetUtcNow();
var usageCount = model.UsageCount + 1;
var updated = CopyEntity(
entity,
usageCount: usageCount,
status: entity.MaxUsageCount.HasValue && usageCount >= entity.MaxUsageCount.Value ? "exhausted" : entity.Status,
updatedAt: now);
if (!await _repository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false))
{
return;
}
var usageDetails = new Dictionary<string, object>
{
["eventKind"] = eventKind,
["usageCount"] = usageCount,
["status"] = updated.Status
};
if (entity.MaxUsageCount.HasValue)
{
usageDetails["maxUsageCount"] = entity.MaxUsageCount.Value;
}
await _auditLogger.LogAsync(
new SuppressionAuditEntry
{
EntryId = Guid.NewGuid().ToString("N")[..16],
TenantId = tenantId,
Timestamp = now,
Actor = "system",
Action = SuppressionAuditAction.OverrideUsed,
ResourceType = "OperatorOverride",
ResourceId = overrideId,
Details = usageDetails
},
cancellationToken).ConfigureAwait(false);
}
private OperatorOverride MapOverride(OperatorOverrideEntity entity)
{
var usageCount = entity.UsageCount;
var status = MapStatus(entity.Status);
var now = _timeProvider.GetUtcNow();
if (status == OverrideStatus.Active && entity.ExpiresAt <= now)
{
status = OverrideStatus.Expired;
}
else if (status == OverrideStatus.Active && entity.MaxUsageCount.HasValue && usageCount >= entity.MaxUsageCount.Value)
{
status = OverrideStatus.Exhausted;
}
return new OperatorOverride
{
OverrideId = entity.OverrideId,
TenantId = entity.TenantId,
Type = ParseOverrideType(entity.OverrideType),
Reason = entity.Reason ?? string.Empty,
EffectiveFrom = entity.EffectiveFrom,
ExpiresAt = entity.ExpiresAt,
EventKinds = DeserializeStringList(entity.EventKinds),
CorrelationKeys = DeserializeStringList(entity.CorrelationKeys),
MaxUsageCount = entity.MaxUsageCount,
UsageCount = usageCount,
CreatedBy = entity.CreatedBy ?? "system",
CreatedAt = entity.CreatedAt,
Status = status,
RevokedBy = entity.RevokedBy,
RevokedAt = entity.RevokedAt,
RevocationReason = entity.RevocationReason
};
}
private static OverrideType ParseOverrideType(string value)
{
return Enum.TryParse<OverrideType>(value, ignoreCase: true, out var parsed)
? parsed
: OverrideType.All;
}
private static OverrideStatus MapStatus(string value)
{
return value.ToLowerInvariant() switch
{
"active" => OverrideStatus.Active,
"expired" => OverrideStatus.Expired,
"revoked" => OverrideStatus.Revoked,
"exhausted" => OverrideStatus.Exhausted,
_ => OverrideStatus.Active
};
}
private static bool MatchesEventKind(OperatorOverride model, string eventKind)
{
return model.EventKinds.Count == 0
|| model.EventKinds.Any(kind => kind == "*" || eventKind.StartsWith(kind, StringComparison.OrdinalIgnoreCase));
}
private static bool MatchesCorrelationKey(OperatorOverride model, string? correlationKey)
{
if (model.CorrelationKeys.Count == 0)
{
return true;
}
if (string.IsNullOrWhiteSpace(correlationKey))
{
return false;
}
return model.CorrelationKeys.Contains(correlationKey, StringComparer.OrdinalIgnoreCase);
}
private static string SerializeStringList(IReadOnlyList<string>? values)
{
var normalized = NormalizeList(values);
return JsonSerializer.Serialize(normalized);
}
private static IReadOnlyList<string> DeserializeStringList(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return NormalizeList(JsonSerializer.Deserialize<List<string>>(json));
}
catch
{
return [];
}
}
private static IReadOnlyList<string> NormalizeList(IEnumerable<string>? values)
{
if (values is null)
{
return [];
}
return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static OperatorOverrideEntity CopyEntity(
OperatorOverrideEntity source,
string? status = null,
string? revokedBy = null,
DateTimeOffset? revokedAt = null,
string? revocationReason = null,
int? usageCount = null,
DateTimeOffset? updatedAt = null)
{
return new OperatorOverrideEntity
{
OverrideId = source.OverrideId,
TenantId = source.TenantId,
OverrideType = source.OverrideType,
EffectiveFrom = source.EffectiveFrom,
ExpiresAt = source.ExpiresAt,
ChannelId = source.ChannelId,
RuleId = source.RuleId,
Reason = source.Reason,
EventKinds = source.EventKinds,
CorrelationKeys = source.CorrelationKeys,
MaxUsageCount = source.MaxUsageCount,
UsageCount = usageCount ?? source.UsageCount,
Status = status ?? source.Status,
RevokedBy = revokedBy ?? source.RevokedBy,
RevokedAt = revokedAt ?? source.RevokedAt,
RevocationReason = revocationReason ?? source.RevocationReason,
CreatedBy = source.CreatedBy,
CreatedAt = source.CreatedAt,
UpdatedAt = updatedAt ?? source.UpdatedAt
};
}
}