feat(notify,notifier): postgres durable runtime base cutover
Sprint SPRINT_20260415_002_DOCS_notify_notifier_real_backend_cutover. Base durable storage wiring for both Notify and Notifier: - NotifyDbContext + EF migrations (002 pack_approvals, 003 operator_override) - Pack approval / operator override / retention / tenant isolation repos - Notifier worker Postgres repository adapters (audit, channel, delivery, inbox, localization, lock, rule, template) + runtime service base - Durable runtime fixture + integration test scaffolding - WebService compat shims for pack approval, operator override, throttle Sub-sprints _008 (suppression), _009 (escalation), _010 (quiet hours), _011 (security/deadletter) land as follow-ups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -165,15 +165,17 @@ public static class ObservabilityEndpoints
|
||||
// Dead letter handlers
|
||||
private static async Task<IResult> GetDeadLetters(
|
||||
string tenantId,
|
||||
[FromQuery] int limit,
|
||||
[FromQuery] int offset,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resolvedLimit = limit.GetValueOrDefault();
|
||||
var resolvedOffset = offset.GetValueOrDefault();
|
||||
var entries = await handler.GetEntriesAsync(
|
||||
tenantId,
|
||||
limit: limit > 0 ? limit : 100,
|
||||
offset: offset,
|
||||
limit: resolvedLimit > 0 ? resolvedLimit : 100,
|
||||
offset: resolvedOffset,
|
||||
ct: ct);
|
||||
return Results.Ok(entries);
|
||||
}
|
||||
@@ -229,11 +231,12 @@ public static class ObservabilityEndpoints
|
||||
|
||||
private static async Task<IResult> PurgeDeadLetters(
|
||||
string tenantId,
|
||||
[FromQuery] int olderThanDays,
|
||||
[FromQuery] int? olderThanDays,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var olderThan = TimeSpan.FromDays(olderThanDays > 0 ? olderThanDays : 7);
|
||||
var resolvedOlderThanDays = olderThanDays.GetValueOrDefault();
|
||||
var olderThan = TimeSpan.FromDays(resolvedOlderThanDays > 0 ? resolvedOlderThanDays : 7);
|
||||
var count = await handler.PurgeAsync(tenantId, olderThan, ct);
|
||||
return Results.Ok(new { purged = count });
|
||||
}
|
||||
@@ -487,7 +490,7 @@ internal static class DeadLetterHandlerCompatExtensions
|
||||
this IDeadLetterHandler handler,
|
||||
string tenantId,
|
||||
TimeSpan olderThan,
|
||||
CancellationToken ct) => Task.FromResult(0);
|
||||
CancellationToken ct) => handler.PurgeAsync(tenantId, olderThan, ct);
|
||||
}
|
||||
|
||||
internal static class RetentionPolicyServiceCompatExtensions
|
||||
|
||||
@@ -32,6 +32,7 @@ using Contracts = StellaOps.Notifier.WebService.Contracts;
|
||||
using WorkerTemplateService = StellaOps.Notifier.Worker.Templates.INotifyTemplateService;
|
||||
using WorkerTemplateRenderer = StellaOps.Notifier.Worker.Dispatch.INotifyTemplateRenderer;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Persistence.Extensions;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Cryptography;
|
||||
@@ -53,6 +54,9 @@ builder.Configuration
|
||||
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ICryptoHmac, DefaultCryptoHmac>();
|
||||
|
||||
var postgresSection = builder.Configuration.GetSection("notifier:storage:postgres");
|
||||
builder.Services.AddNotifyPersistence(builder.Configuration, postgresSection.Path);
|
||||
|
||||
// Core correlation engine registrations required by incident and escalation flows.
|
||||
builder.Services.AddCorrelationServices(builder.Configuration);
|
||||
|
||||
@@ -60,22 +64,17 @@ builder.Services.AddCorrelationServices(builder.Configuration);
|
||||
builder.Services.AddSingleton<StellaOps.Notify.Engine.INotifyRuleEvaluator, StellaOps.Notifier.Worker.Processing.DefaultNotifyRuleEvaluator>();
|
||||
StellaOps.Notifier.Worker.Simulation.SimulationServiceExtensions.AddSimulationServices(builder.Services, builder.Configuration);
|
||||
|
||||
// Fallback no-op event queue for environments that do not configure a real backend.
|
||||
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
|
||||
if (isTesting)
|
||||
{
|
||||
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddNotifyEventQueue(builder.Configuration, "notifier:queue");
|
||||
}
|
||||
|
||||
// In-memory storage (document store removed)
|
||||
builder.Services.AddSingleton<INotifyChannelRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<INotifyRuleRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<INotifyTemplateRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<INotifyDeliveryRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<INotifyAuditRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<INotifyLockRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<IInAppInboxStore, InMemoryInboxStore>();
|
||||
builder.Services.AddSingleton<INotifyInboxRepository, InMemoryInboxStore>();
|
||||
builder.Services.AddSingleton<INotifyLocalizationRepository, InMemoryNotifyRepositories>();
|
||||
builder.Services.AddSingleton<INotifyPackApprovalRepository, InMemoryPackApprovalRepository>();
|
||||
builder.Services.AddSingleton<INotifyThrottleConfigRepository, InMemoryThrottleConfigRepository>();
|
||||
builder.Services.AddSingleton<INotifyOperatorOverrideRepository, InMemoryOperatorOverrideRepository>();
|
||||
builder.Services.AddDurableNotifyWorkerStorage();
|
||||
builder.Services.AddScoped<INotifyPackApprovalRepository, PostgresPackApprovalRepository>();
|
||||
builder.Services.AddSingleton<INotifyQuietHoursRepository, InMemoryQuietHoursRepository>();
|
||||
builder.Services.AddSingleton<INotifyMaintenanceWindowRepository, InMemoryMaintenanceWindowRepository>();
|
||||
builder.Services.AddSingleton<INotifyEscalationPolicyRepository, InMemoryEscalationPolicyRepository>();
|
||||
@@ -84,10 +83,38 @@ builder.Services.AddSingleton<INotifyOnCallScheduleRepository, InMemoryOnCallSch
|
||||
// Correlation suppression services backing /api/v2/throttles, /api/v2/quiet-hours, /api/v2/overrides.
|
||||
builder.Services.Configure<SuppressionAuditOptions>(builder.Configuration.GetSection(SuppressionAuditOptions.SectionName));
|
||||
builder.Services.Configure<OperatorOverrideOptions>(builder.Configuration.GetSection(OperatorOverrideOptions.SectionName));
|
||||
builder.Services.AddSingleton<ISuppressionAuditLogger, InMemorySuppressionAuditLogger>();
|
||||
builder.Services.AddSingleton<IThrottleConfigurationService, InMemoryThrottleConfigurationService>();
|
||||
builder.Services.AddSingleton<IQuietHoursCalendarService, InMemoryQuietHoursCalendarService>();
|
||||
builder.Services.AddSingleton<IOperatorOverrideService, InMemoryOperatorOverrideService>();
|
||||
|
||||
if (isTesting)
|
||||
{
|
||||
builder.Services.AddSingleton<INotifyThrottleConfigRepository, InMemoryThrottleConfigRepository>();
|
||||
builder.Services.AddSingleton<INotifyOperatorOverrideRepository, InMemoryOperatorOverrideRepository>();
|
||||
builder.Services.AddSingleton<ISuppressionAuditLogger, InMemorySuppressionAuditLogger>();
|
||||
builder.Services.AddSingleton<IThrottleConfigurationService, InMemoryThrottleConfigurationService>();
|
||||
builder.Services.AddSingleton<IOperatorOverrideService, InMemoryOperatorOverrideService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.RemoveAll<INotifyQuietHoursRepository>();
|
||||
builder.Services.RemoveAll<INotifyMaintenanceWindowRepository>();
|
||||
builder.Services.RemoveAll<IQuietHoursCalendarService>();
|
||||
builder.Services.RemoveAll<IQuietHoursEvaluator>();
|
||||
builder.Services.RemoveAll<INotifyThrottleConfigRepository>();
|
||||
builder.Services.RemoveAll<INotifyOperatorOverrideRepository>();
|
||||
builder.Services.RemoveAll<ISuppressionAuditLogger>();
|
||||
builder.Services.RemoveAll<IThrottleConfigurationService>();
|
||||
builder.Services.RemoveAll<IOperatorOverrideService>();
|
||||
|
||||
builder.Services.AddSingleton<INotifyQuietHoursRepository, PostgresNotifyQuietHoursRepository>();
|
||||
builder.Services.AddSingleton<INotifyMaintenanceWindowRepository, PostgresNotifyMaintenanceWindowRepository>();
|
||||
builder.Services.AddSingleton<IQuietHoursCalendarService, PostgresQuietHoursCalendarService>();
|
||||
builder.Services.AddSingleton<IQuietHoursEvaluator, PostgresQuietHoursEvaluator>();
|
||||
builder.Services.AddScoped<INotifyThrottleConfigRepository, PostgresNotifyThrottleConfigRepository>();
|
||||
builder.Services.AddScoped<INotifyOperatorOverrideRepository, PostgresNotifyOperatorOverrideRepository>();
|
||||
builder.Services.AddScoped<ISuppressionAuditLogger, PostgresSuppressionAuditLogger>();
|
||||
builder.Services.AddScoped<IThrottleConfigurationService, PostgresThrottleConfigurationService>();
|
||||
builder.Services.AddScoped<IOperatorOverrideService, PostgresOperatorOverrideService>();
|
||||
}
|
||||
|
||||
// Template service with enhanced renderer (worker contracts)
|
||||
builder.Services.AddTemplateServices(options =>
|
||||
@@ -118,12 +145,30 @@ builder.Services.AddSingleton<IRetentionPolicyService, DefaultRetentionPolicySer
|
||||
// Escalation and on-call services
|
||||
builder.Services.AddEscalationServices(builder.Configuration);
|
||||
|
||||
if (!isTesting)
|
||||
{
|
||||
builder.Services.RemoveAll<IEscalationPolicyService>();
|
||||
builder.Services.RemoveAll<IOnCallScheduleService>();
|
||||
builder.Services.RemoveAll<INotifyEscalationPolicyRepository>();
|
||||
builder.Services.RemoveAll<INotifyOnCallScheduleRepository>();
|
||||
|
||||
builder.Services.AddSingleton<IEscalationPolicyService, PostgresEscalationPolicyService>();
|
||||
builder.Services.AddSingleton<IOnCallScheduleService, PostgresOnCallScheduleService>();
|
||||
builder.Services.AddSingleton<INotifyEscalationPolicyRepository, ServiceBackedNotifyEscalationPolicyRepository>();
|
||||
builder.Services.AddSingleton<INotifyOnCallScheduleRepository, ServiceBackedNotifyOnCallScheduleRepository>();
|
||||
}
|
||||
|
||||
// Storm breaker, localization, and fallback services
|
||||
builder.Services.AddStormBreakerServices(builder.Configuration);
|
||||
|
||||
// Security services (signing, webhook validation, HTML sanitization, tenant isolation)
|
||||
builder.Services.AddNotifierSecurityServices(builder.Configuration);
|
||||
|
||||
if (!isTesting)
|
||||
{
|
||||
builder.Services.AddPostgresNotifyAdminRuntimeServices();
|
||||
}
|
||||
|
||||
// Tenancy services (context accessor, RLS enforcement, channel resolution, notification enrichment)
|
||||
builder.Services.AddNotifierTenancy(builder.Configuration);
|
||||
|
||||
@@ -428,6 +473,7 @@ app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
|
||||
string packId,
|
||||
PackApprovalAckRequest request,
|
||||
INotifyLockRepository locks,
|
||||
INotifyPackApprovalRepository packApprovals,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
@@ -456,6 +502,16 @@ app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
|
||||
|
||||
try
|
||||
{
|
||||
await packApprovals.AcknowledgeAsync(
|
||||
tenantId,
|
||||
packId,
|
||||
request.AckToken,
|
||||
request.Decision,
|
||||
request.Comment,
|
||||
actor,
|
||||
timeProvider.GetUtcNow(),
|
||||
context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
var auditEntry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Persistence.Postgres.Models;
|
||||
using StellaOps.Notify.Persistence.Postgres.Repositories;
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Storage.Compat;
|
||||
|
||||
@@ -50,3 +53,117 @@ public sealed class InMemoryOperatorOverrideRepository : INotifyOperatorOverride
|
||||
private ConcurrentDictionary<string, NotifyOperatorOverride> ForTenant(string tenantId) =>
|
||||
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyOperatorOverride>());
|
||||
}
|
||||
|
||||
public sealed class PostgresNotifyOperatorOverrideRepository : INotifyOperatorOverrideRepository
|
||||
{
|
||||
private readonly IOperatorOverrideRepository _repository;
|
||||
|
||||
public PostgresNotifyOperatorOverrideRepository(IOperatorOverrideRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyOperatorOverride>> ListAsync(string tenantId, bool? activeOnly, DateTimeOffset now, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var items = entities.Select(MapModel);
|
||||
|
||||
if (activeOnly == true)
|
||||
{
|
||||
items = items.Where(item => item.IsActiveAt(now));
|
||||
}
|
||||
|
||||
return items.OrderBy(item => item.ExpiresAt).ToList();
|
||||
}
|
||||
|
||||
public async Task<NotifyOperatorOverride?> GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _repository.GetByIdAsync(tenantId, overrideId, cancellationToken).ConfigureAwait(false);
|
||||
return entity is null ? null : MapModel(entity);
|
||||
}
|
||||
|
||||
public async Task<NotifyOperatorOverride> UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@override);
|
||||
|
||||
var entity = MapEntity(@override);
|
||||
var existing = await _repository.GetByIdAsync(@override.TenantId, @override.OverrideId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
await _repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return @override;
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
|
||||
=> _repository.DeleteAsync(tenantId, overrideId, cancellationToken);
|
||||
|
||||
private static NotifyOperatorOverride MapModel(OperatorOverrideEntity entity)
|
||||
{
|
||||
return NotifyOperatorOverride.Create(
|
||||
overrideId: entity.OverrideId,
|
||||
tenantId: entity.TenantId,
|
||||
overrideType: MapOverrideType(entity.OverrideType),
|
||||
expiresAt: entity.ExpiresAt,
|
||||
channelId: entity.ChannelId,
|
||||
ruleId: entity.RuleId,
|
||||
reason: entity.Reason,
|
||||
createdBy: entity.CreatedBy,
|
||||
createdAt: entity.CreatedAt);
|
||||
}
|
||||
|
||||
private static OperatorOverrideEntity MapEntity(NotifyOperatorOverride @override)
|
||||
{
|
||||
return new OperatorOverrideEntity
|
||||
{
|
||||
OverrideId = @override.OverrideId,
|
||||
TenantId = @override.TenantId,
|
||||
OverrideType = MapOverrideType(@override.OverrideType).ToString(),
|
||||
EffectiveFrom = @override.CreatedAt,
|
||||
ExpiresAt = @override.ExpiresAt,
|
||||
ChannelId = @override.ChannelId,
|
||||
RuleId = @override.RuleId,
|
||||
Reason = @override.Reason,
|
||||
EventKinds = "[]",
|
||||
CorrelationKeys = "[]",
|
||||
MaxUsageCount = null,
|
||||
UsageCount = 0,
|
||||
Status = @override.ExpiresAt > DateTimeOffset.UtcNow ? "active" : "expired",
|
||||
RevokedBy = null,
|
||||
RevokedAt = null,
|
||||
RevocationReason = null,
|
||||
CreatedBy = @override.CreatedBy,
|
||||
CreatedAt = @override.CreatedAt,
|
||||
UpdatedAt = @override.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static NotifyOverrideType MapOverrideType(string value)
|
||||
{
|
||||
var parsed = Enum.TryParse<OverrideType>(value, ignoreCase: true, out var type)
|
||||
? type
|
||||
: OverrideType.All;
|
||||
|
||||
return parsed switch
|
||||
{
|
||||
OverrideType.QuietHours => NotifyOverrideType.BypassQuietHours,
|
||||
OverrideType.Throttle => NotifyOverrideType.BypassThrottle,
|
||||
OverrideType.Maintenance => NotifyOverrideType.BypassMaintenance,
|
||||
_ => NotifyOverrideType.ForceSuppression
|
||||
};
|
||||
}
|
||||
|
||||
private static OverrideType MapOverrideType(NotifyOverrideType value) => value switch
|
||||
{
|
||||
NotifyOverrideType.BypassQuietHours => OverrideType.QuietHours,
|
||||
NotifyOverrideType.BypassThrottle => OverrideType.Throttle,
|
||||
NotifyOverrideType.BypassMaintenance => OverrideType.Maintenance,
|
||||
NotifyOverrideType.ForceSuppression => OverrideType.All,
|
||||
_ => OverrideType.All
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Persistence.Postgres.Models;
|
||||
using StellaOps.Notify.Persistence.Postgres.Repositories;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Storage.Compat;
|
||||
|
||||
public interface INotifyPackApprovalRepository
|
||||
{
|
||||
Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> AcknowledgeAsync(
|
||||
string tenantId,
|
||||
string packId,
|
||||
string ackToken,
|
||||
string? decision,
|
||||
string? comment,
|
||||
string actor,
|
||||
DateTimeOffset acknowledgedAt,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class InMemoryPackApprovalRepository : INotifyPackApprovalRepository
|
||||
@@ -18,9 +31,107 @@ public sealed class InMemoryPackApprovalRepository : INotifyPackApprovalReposito
|
||||
_store[(document.TenantId, document.EventId, document.PackId)] = document;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> AcknowledgeAsync(
|
||||
string tenantId,
|
||||
string packId,
|
||||
string ackToken,
|
||||
string? decision,
|
||||
string? comment,
|
||||
string actor,
|
||||
DateTimeOffset acknowledgedAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var entry in _store)
|
||||
{
|
||||
if (!string.Equals(entry.Key.TenantId, tenantId, StringComparison.Ordinal) ||
|
||||
!string.Equals(entry.Key.PackId, packId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_store[entry.Key] = entry.Value with
|
||||
{
|
||||
AckToken = ackToken,
|
||||
AckDecision = decision,
|
||||
AckComment = comment,
|
||||
AckActor = actor,
|
||||
AcknowledgedAt = acknowledgedAt
|
||||
};
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PackApprovalDocument
|
||||
public sealed class PostgresPackApprovalRepository : INotifyPackApprovalRepository
|
||||
{
|
||||
private readonly IPackApprovalRepository _repository;
|
||||
|
||||
public PostgresPackApprovalRepository(IPackApprovalRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var approval = new PackApprovalEntity
|
||||
{
|
||||
TenantId = document.TenantId,
|
||||
PackId = document.PackId,
|
||||
EventId = document.EventId,
|
||||
Kind = document.Kind,
|
||||
Decision = document.Decision,
|
||||
Actor = document.Actor,
|
||||
IssuedAt = document.IssuedAt,
|
||||
CreatedAt = document.CreatedAt,
|
||||
PolicyId = document.PolicyId,
|
||||
PolicyVersion = document.PolicyVersion,
|
||||
ResumeToken = document.ResumeToken,
|
||||
Summary = document.Summary,
|
||||
Labels = SerializeMap(document.Labels),
|
||||
Metadata = SerializeMap(document.Metadata)
|
||||
};
|
||||
|
||||
await _repository.UpsertAsync(approval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<bool> AcknowledgeAsync(
|
||||
string tenantId,
|
||||
string packId,
|
||||
string ackToken,
|
||||
string? decision,
|
||||
string? comment,
|
||||
string actor,
|
||||
DateTimeOffset acknowledgedAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _repository.AcknowledgeAsync(
|
||||
tenantId,
|
||||
packId,
|
||||
ackToken,
|
||||
decision,
|
||||
comment,
|
||||
actor,
|
||||
acknowledgedAt,
|
||||
cancellationToken);
|
||||
|
||||
private static string SerializeMap(IDictionary<string, string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return "{}";
|
||||
}
|
||||
|
||||
var ordered = new SortedDictionary<string, string>(values, StringComparer.Ordinal);
|
||||
return JsonSerializer.Serialize(ordered);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PackApprovalDocument
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required Guid EventId { get; init; }
|
||||
@@ -36,4 +147,9 @@ public sealed class PackApprovalDocument
|
||||
public string? Summary { get; init; }
|
||||
public IDictionary<string, string>? Labels { get; init; }
|
||||
public IDictionary<string, string>? Metadata { get; init; }
|
||||
public string? AckToken { get; init; }
|
||||
public string? AckDecision { get; init; }
|
||||
public string? AckComment { get; init; }
|
||||
public string? AckActor { get; init; }
|
||||
public DateTimeOffset? AcknowledgedAt { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Persistence.Postgres.Models;
|
||||
using StellaOps.Notify.Persistence.Postgres.Repositories;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Storage.Compat;
|
||||
|
||||
@@ -47,3 +51,116 @@ public sealed class InMemoryThrottleConfigRepository : INotifyThrottleConfigRepo
|
||||
private ConcurrentDictionary<string, NotifyThrottleConfig> ForTenant(string tenantId) =>
|
||||
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyThrottleConfig>());
|
||||
}
|
||||
|
||||
public sealed class PostgresNotifyThrottleConfigRepository : INotifyThrottleConfigRepository
|
||||
{
|
||||
private readonly IThrottleConfigRepository _repository;
|
||||
|
||||
public PostgresNotifyThrottleConfigRepository(IThrottleConfigRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return entities.Select(MapModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _repository.GetByIdAsync(tenantId, configId, cancellationToken).ConfigureAwait(false);
|
||||
return entity is null ? null : MapModel(entity);
|
||||
}
|
||||
|
||||
public async Task<NotifyThrottleConfig> UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var entity = MapEntity(config);
|
||||
var existing = await _repository.GetByIdAsync(config.TenantId, config.ConfigId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
await _repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
|
||||
=> _repository.DeleteAsync(tenantId, configId, cancellationToken);
|
||||
|
||||
private static NotifyThrottleConfig MapModel(ThrottleConfigEntity entity)
|
||||
{
|
||||
return NotifyThrottleConfig.Create(
|
||||
configId: entity.ConfigId,
|
||||
tenantId: entity.TenantId,
|
||||
name: entity.Name,
|
||||
defaultWindow: entity.DefaultWindow,
|
||||
maxNotificationsPerWindow: entity.MaxNotificationsPerWindow,
|
||||
channelId: entity.ChannelId,
|
||||
isDefault: entity.IsDefault,
|
||||
enabled: entity.Enabled,
|
||||
description: entity.Description,
|
||||
metadata: DeserializeMetadata(entity.Metadata),
|
||||
createdBy: entity.CreatedBy,
|
||||
createdAt: entity.CreatedAt,
|
||||
updatedBy: entity.UpdatedBy,
|
||||
updatedAt: entity.UpdatedAt);
|
||||
}
|
||||
|
||||
private static ThrottleConfigEntity MapEntity(NotifyThrottleConfig config)
|
||||
{
|
||||
return new ThrottleConfigEntity
|
||||
{
|
||||
ConfigId = config.ConfigId,
|
||||
TenantId = config.TenantId,
|
||||
Name = config.Name,
|
||||
DefaultWindow = config.DefaultWindow,
|
||||
MaxNotificationsPerWindow = config.MaxNotificationsPerWindow,
|
||||
ChannelId = config.ChannelId,
|
||||
IsDefault = config.IsDefault,
|
||||
Enabled = config.Enabled,
|
||||
Description = config.Description,
|
||||
Metadata = SerializeMetadata(config.Metadata),
|
||||
CreatedBy = config.CreatedBy,
|
||||
CreatedAt = config.CreatedAt,
|
||||
UpdatedBy = config.UpdatedBy,
|
||||
UpdatedAt = config.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static string? SerializeMetadata(IReadOnlyDictionary<string, string>? metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ordered = metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
||||
return JsonSerializer.Serialize(ordered);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string>? DeserializeMetadata(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
|
||||
return metadata?.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,15 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0395-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Notifier.WebService. |
|
||||
| AUDIT-0395-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| NOTIFY-SUPPRESS-001 | DONE | 2026-04-16: replaced live in-memory throttle and override admin runtime with PostgreSQL-backed services/adapters. |
|
||||
| NOTIFY-SUPPRESS-002 | DONE | 2026-04-16: restart-survival proof covers durable throttle and override suppression APIs. |
|
||||
| NOTIFY-ESC-ONCALL-001 | DONE | 2026-04-16: replaced live in-memory escalation-policy and on-call compat repositories with PostgreSQL-backed adapters. |
|
||||
| NOTIFY-ESC-ONCALL-002 | DONE | 2026-04-16: restart-survival proof covers durable escalation-policy and on-call APIs. |
|
||||
| NOTIFY-QUIET-MAINT-001 | DONE | 2026-04-16: replaced live in-memory quiet-hours and maintenance-window compat/runtime services with PostgreSQL-backed adapters. |
|
||||
| NOTIFY-QUIET-MAINT-002 | DONE | 2026-04-16: restart-survival proof covers durable quiet-hours and maintenance APIs. |
|
||||
| NOTIFY-SEC-DEAD-001 | DONE | 2026-04-16: replaced live in-memory webhook security, tenant isolation, dead-letter, and retention runtime with PostgreSQL-backed services. |
|
||||
| NOTIFY-SEC-DEAD-002 | DONE | 2026-04-16: both hosts now wire compatibility and canonical security/dead-letter endpoints to the same durable runtime services. |
|
||||
| NOTIFY-SEC-DEAD-003 | DONE | 2026-04-16: restart-survival proof passed for webhook security, tenant isolation, dead-letter, and retention runtime. |
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user