wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -12,6 +12,7 @@ using Microsoft.IdentityModel.Tokens;
using Serilog;
using Serilog.Events;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Configuration;
using StellaOps.Notify.Models;
using StellaOps.Notify.Persistence.Extensions;
@@ -111,6 +112,7 @@ var routerEnabled = builder.Services.AddRouterMicroservice(
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.Services.AddStellaOpsTenantServices();
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.TryAddStellaOpsLocalBinding("notify");
@@ -352,6 +354,7 @@ static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions
app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();
// Stella Router integration
app.TryUseStellaRouter(routerEnabled);
@@ -359,7 +362,10 @@ static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions
static void ConfigureEndpoints(WebApplication app)
{
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
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.")
.AllowAnonymous();
app.MapGet("/readyz", (ServiceStatus status) =>
{
@@ -384,7 +390,10 @@ static void ConfigureEndpoints(WebApplication app)
latencyMs = snapshot.Ready.Latency?.TotalMilliseconds
},
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.")
.AllowAnonymous();
var options = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
var tenantHeader = options.Api.TenantHeader;
@@ -394,14 +403,20 @@ 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.")
.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");
.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.")
.RequireAuthorization(NotifyPolicies.Operator);
internalGroup.MapPost("/templates/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeTemplate))
.WithName("notify.templates.normalize");
.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.")
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapGet("/rules", async (IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
@@ -413,6 +428,8 @@ static void ConfigureEndpoints(WebApplication app)
var rules = await repository.ListAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false);
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.")
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapGet("/rules/{ruleId}", async (string ruleId, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
@@ -430,6 +447,8 @@ static void ConfigureEndpoints(WebApplication app)
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.")
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapPost("/rules", async (JsonNode? body, NotifySchemaMigrationService service, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
@@ -477,6 +496,8 @@ 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.")
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapDelete("/rules/{ruleId}", async (string ruleId, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
@@ -494,6 +515,8 @@ static void ConfigureEndpoints(WebApplication app)
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.")
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapGet("/channels", async (IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
@@ -506,6 +529,8 @@ static void ConfigureEndpoints(WebApplication app)
var channels = await repository.GetAllAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false);
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.")
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapGet("/channels/{channelId}", async (string channelId, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
@@ -523,6 +548,8 @@ static void ConfigureEndpoints(WebApplication app)
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.")
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapPost("/channels", async (JsonNode? body, NotifySchemaMigrationService service, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
@@ -570,6 +597,8 @@ 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.")
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapPost("/channels/{channelId}/test", async (
@@ -618,6 +647,8 @@ static void ConfigureEndpoints(WebApplication app)
return Results.BadRequest(new { error = ex.Message });
}
})
.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.")
.RequireAuthorization(NotifyPolicies.Operator)
.RequireRateLimiting(NotifyRateLimitPolicies.TestSend);
@@ -636,6 +667,8 @@ static void ConfigureEndpoints(WebApplication app)
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.")
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapGet("/templates", async (ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
@@ -648,6 +681,8 @@ static void ConfigureEndpoints(WebApplication app)
var templates = await repository.ListAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false);
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.")
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapGet("/templates/{templateId}", async (string templateId, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
@@ -665,6 +700,8 @@ static void ConfigureEndpoints(WebApplication app)
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.")
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapPost("/templates", async (JsonNode? body, NotifySchemaMigrationService service, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
@@ -703,6 +740,8 @@ 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.")
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapDelete("/templates/{templateId}", async (string templateId, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
@@ -720,6 +759,8 @@ static void ConfigureEndpoints(WebApplication app)
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.")
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapPost("/deliveries", async ([FromBody] JsonNode? body, IDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) =>
@@ -771,6 +812,8 @@ static void ConfigureEndpoints(WebApplication app)
BuildResourceLocation(apiBasePath, "deliveries", delivery.DeliveryId),
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.")
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapGet("/deliveries", async (
@@ -849,6 +892,8 @@ static void ConfigureEndpoints(WebApplication app)
continuationToken = nextCursor
});
})
.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.")
.RequireAuthorization(NotifyPolicies.Viewer)
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
@@ -895,6 +940,8 @@ 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.")
.RequireAuthorization(NotifyPolicies.Viewer)
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
@@ -950,6 +997,8 @@ static void ConfigureEndpoints(WebApplication app)
BuildResourceLocation(apiBasePath, "digests", request.DigestKey),
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.")
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapGet("/digests/{actionKey}", async (
@@ -978,6 +1027,8 @@ static void ConfigureEndpoints(WebApplication app)
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.")
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapDelete("/digests/{actionKey}", async (
@@ -1006,6 +1057,8 @@ static void ConfigureEndpoints(WebApplication app)
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.")
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapPost("/audit", async ([FromBody] JsonNode? body, INotifyAuditRepository repository, TimeProvider timeProvider, HttpContext context, ClaimsPrincipal user, CancellationToken cancellationToken) =>
@@ -1041,6 +1094,8 @@ static void ConfigureEndpoints(WebApplication app)
var id = await repository.CreateAsync(entry, cancellationToken).ConfigureAwait(false);
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.")
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapGet("/audit", async (INotifyAuditRepository repository, HttpContext context, [FromQuery] int? limit, [FromQuery] int? offset, CancellationToken cancellationToken) =>
@@ -1066,6 +1121,8 @@ 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.")
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapPost("/locks/acquire", async ([FromBody] AcquireLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
@@ -1078,6 +1135,8 @@ static void ConfigureEndpoints(WebApplication app)
var acquired = await repository.TryAcquireAsync(tenant, request.Resource, request.Owner, TimeSpan.FromSeconds(request.TtlSeconds), cancellationToken).ConfigureAwait(false);
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.")
.RequireAuthorization(NotifyPolicies.Operator);
apiGroup.MapPost("/locks/release", async ([FromBody] ReleaseLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
@@ -1090,6 +1149,8 @@ static void ConfigureEndpoints(WebApplication app)
var released = await repository.ReleaseAsync(tenant, request.Resource, request.Owner, cancellationToken).ConfigureAwait(false);
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.")
.RequireAuthorization(NotifyPolicies.Operator);
}
@@ -1402,16 +1463,25 @@ static T? TryDeserialize<T>(string? json)
static bool TryResolveTenant(HttpContext context, string tenantHeader, out string tenant, out IResult? error)
{
if (!context.Request.Headers.TryGetValue(tenantHeader, out var header) || string.IsNullOrWhiteSpace(header))
// Delegate to unified StellaOps tenant resolver (claims + canonical headers + legacy headers)
if (StellaOpsTenantResolver.TryResolveTenantId(context, out var resolvedTenant, out var resolverError))
{
tenant = string.Empty;
error = Results.BadRequest(new { error = $"{tenantHeader} header is required." });
return false;
tenant = resolvedTenant;
error = null;
return true;
}
tenant = header.ToString().Trim();
error = null;
return true;
// Fall back to legacy configurable header for backward compatibility
if (context.Request.Headers.TryGetValue(tenantHeader, out var header) && !string.IsNullOrWhiteSpace(header))
{
tenant = header.ToString().Trim().ToLowerInvariant();
error = null;
return true;
}
tenant = string.Empty;
error = Results.BadRequest(new { error = $"Tenant is required. Provide via stellaops:tenant claim, X-StellaOps-Tenant header, or {tenantHeader} header.", error_code = resolverError ?? "tenant_missing" });
return false;
}
static string BuildResourceLocation(string basePath, params string[] segments)

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Notify.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model stub for NotifyDbContext.
/// This is a placeholder that delegates to runtime model building.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
[DbContext(typeof(Context.NotifyDbContext))]
public partial class NotifyDbContextModel : RuntimeModel
{
private static NotifyDbContextModel _instance;
public static IModel Instance
{
get
{
if (_instance == null)
{
_instance = new NotifyDbContextModel();
_instance.Initialize();
_instance.Customize();
}
return _instance;
}
}
partial void Initialize();
partial void Customize();
}

View File

@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Notify.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model builder stub for NotifyDbContext.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
public partial class NotifyDbContextModel
{
partial void Initialize()
{
// Stub: when a real compiled model is generated, entity types will be registered here.
// The runtime factory will fall back to reflection-based model building for all schemas
// until this stub is replaced with a full compiled model.
}
}

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.EfCore.Context;
public partial class NotifyDbContext
{
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
{
// ── FK: escalation_states.policy_id -> escalation_policies.id ──
modelBuilder.Entity<EscalationStateEntity>(entity =>
{
entity.HasOne<EscalationPolicyEntity>()
.WithMany()
.HasForeignKey(e => e.PolicyId)
.OnDelete(DeleteBehavior.Restrict);
});
// ── FK: incidents.escalation_policy_id -> escalation_policies.id ──
modelBuilder.Entity<IncidentEntity>(entity =>
{
entity.HasOne<EscalationPolicyEntity>()
.WithMany()
.HasForeignKey(e => e.EscalationPolicyId)
.OnDelete(DeleteBehavior.SetNull);
});
// ── FK: digests.channel_id -> channels.id ──
modelBuilder.Entity<DigestEntity>(entity =>
{
entity.HasOne<ChannelEntity>()
.WithMany()
.HasForeignKey(e => e.ChannelId)
.OnDelete(DeleteBehavior.Restrict);
});
}
}

View File

@@ -1,32 +1,676 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Infrastructure.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for the Notify module.
/// Placeholder for future EF Core scaffolding from PostgreSQL schema.
/// Maps to the notify PostgreSQL schema: channels, rules, templates, deliveries,
/// digests, quiet_hours, maintenance_windows, escalation_policies, escalation_states,
/// on_call_schedules, inbox, incidents, audit, and locks tables.
/// </summary>
public class NotifyDbContext : StellaOpsDbContextBase
public partial class NotifyDbContext : DbContext
{
/// <summary>
/// Creates a new Notify DbContext.
/// </summary>
public NotifyDbContext(DbContextOptions<NotifyDbContext> options)
private readonly string _schemaName;
public NotifyDbContext(DbContextOptions<NotifyDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "notify"
: schemaName.Trim();
}
/// <inheritdoc />
protected override string SchemaName => "notify";
public virtual DbSet<ChannelEntity> Channels { get; set; }
public virtual DbSet<RuleEntity> Rules { get; set; }
public virtual DbSet<TemplateEntity> Templates { get; set; }
public virtual DbSet<DeliveryEntity> Deliveries { get; set; }
public virtual DbSet<DigestEntity> Digests { get; set; }
public virtual DbSet<QuietHoursEntity> QuietHours { get; set; }
public virtual DbSet<MaintenanceWindowEntity> MaintenanceWindows { get; set; }
public virtual DbSet<EscalationPolicyEntity> EscalationPolicies { get; set; }
public virtual DbSet<EscalationStateEntity> EscalationStates { get; set; }
public virtual DbSet<OnCallScheduleEntity> OnCallSchedules { get; set; }
public virtual DbSet<InboxEntity> Inbox { get; set; }
public virtual DbSet<IncidentEntity> Incidents { get; set; }
public virtual DbSet<NotifyAuditEntity> Audit { get; set; }
public virtual DbSet<LockEntity> Locks { get; set; }
public virtual DbSet<OperatorOverrideEntity> OperatorOverrides { get; set; }
public virtual DbSet<ThrottleConfigEntity> ThrottleConfigs { get; set; }
public virtual DbSet<LocalizationBundleEntity> LocalizationBundles { get; set; }
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
var schemaName = _schemaName;
// Entity configurations will be added after scaffolding
// from the PostgreSQL database using:
// dotnet ef dbcontext scaffold
// ── PostgreSQL enum types ────────────────────────────────────────
modelBuilder.HasPostgresEnum(schemaName, "channel_type",
new[] { "email", "slack", "teams", "webhook", "pagerduty", "opsgenie" });
modelBuilder.HasPostgresEnum(schemaName, "delivery_status",
new[] { "pending", "queued", "sending", "sent", "delivered", "failed", "bounced" });
// ── channels ─────────────────────────────────────────────────────
modelBuilder.Entity<ChannelEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("channels_pkey");
entity.ToTable("channels", schemaName);
entity.HasIndex(e => e.TenantId, "idx_channels_tenant");
entity.HasIndex(e => new { e.TenantId, e.ChannelType }, "idx_channels_type");
entity.HasAlternateKey(e => new { e.TenantId, e.Name }).HasName("channels_tenant_id_name_key");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.ChannelType)
.HasColumnName("channel_type");
entity.Property(e => e.Enabled)
.HasDefaultValue(true)
.HasColumnName("enabled");
entity.Property(e => e.Config)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("config");
entity.Property(e => e.Credentials)
.HasColumnType("jsonb")
.HasColumnName("credentials");
entity.Property(e => e.Metadata)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("metadata");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
});
// ── rules ────────────────────────────────────────────────────────
modelBuilder.Entity<RuleEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("rules_pkey");
entity.ToTable("rules", schemaName);
entity.HasIndex(e => e.TenantId, "idx_rules_tenant");
entity.HasIndex(e => new { e.TenantId, e.Enabled, e.Priority }, "idx_rules_enabled")
.IsDescending(false, false, true);
entity.HasAlternateKey(e => new { e.TenantId, e.Name }).HasName("rules_tenant_id_name_key");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.Enabled)
.HasDefaultValue(true)
.HasColumnName("enabled");
entity.Property(e => e.Priority)
.HasDefaultValue(0)
.HasColumnName("priority");
entity.Property(e => e.EventTypes)
.HasDefaultValueSql("'{}'::text[]")
.HasColumnName("event_types");
entity.Property(e => e.Filter)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("filter");
entity.Property(e => e.ChannelIds)
.HasDefaultValueSql("'{}'::uuid[]")
.HasColumnName("channel_ids");
entity.Property(e => e.TemplateId).HasColumnName("template_id");
entity.Property(e => e.Metadata)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("metadata");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
});
// ── templates ────────────────────────────────────────────────────
modelBuilder.Entity<TemplateEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("templates_pkey");
entity.ToTable("templates", schemaName);
entity.HasIndex(e => e.TenantId, "idx_templates_tenant");
entity.HasAlternateKey(e => new { e.TenantId, e.Name, e.ChannelType, e.Locale })
.HasName("templates_tenant_id_name_channel_type_locale_key");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.ChannelType)
.HasColumnName("channel_type");
entity.Property(e => e.SubjectTemplate).HasColumnName("subject_template");
entity.Property(e => e.BodyTemplate).HasColumnName("body_template");
entity.Property(e => e.Locale)
.HasDefaultValue("en")
.HasColumnName("locale");
entity.Property(e => e.Metadata)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("metadata");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
});
// ── deliveries (partitioned table) ───────────────────────────────
modelBuilder.Entity<DeliveryEntity>(entity =>
{
entity.HasKey(e => new { e.Id, e.CreatedAt }).HasName("deliveries_pkey");
entity.ToTable("deliveries", schemaName);
entity.HasIndex(e => e.TenantId, "ix_deliveries_part_tenant");
entity.HasIndex(e => new { e.TenantId, e.Status }, "ix_deliveries_part_status");
entity.HasIndex(e => e.ChannelId, "ix_deliveries_part_channel");
entity.HasIndex(e => e.CorrelationId, "ix_deliveries_part_correlation")
.HasFilter("(correlation_id IS NOT NULL)");
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "ix_deliveries_part_created")
.IsDescending(false, true);
entity.HasIndex(e => e.ExternalId, "ix_deliveries_part_external_id")
.HasFilter("(external_id IS NOT NULL)");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ChannelId).HasColumnName("channel_id");
entity.Property(e => e.RuleId).HasColumnName("rule_id");
entity.Property(e => e.TemplateId).HasColumnName("template_id");
entity.Property(e => e.Status)
.HasDefaultValue(DeliveryStatus.Pending)
.HasColumnName("status");
entity.Property(e => e.Recipient).HasColumnName("recipient");
entity.Property(e => e.Subject).HasColumnName("subject");
entity.Property(e => e.Body).HasColumnName("body");
entity.Property(e => e.EventType).HasColumnName("event_type");
entity.Property(e => e.EventPayload)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("event_payload");
entity.Property(e => e.Attempt)
.HasDefaultValue(0)
.HasColumnName("attempt");
entity.Property(e => e.MaxAttempts)
.HasDefaultValue(3)
.HasColumnName("max_attempts");
entity.Property(e => e.NextRetryAt).HasColumnName("next_retry_at");
entity.Property(e => e.ErrorMessage).HasColumnName("error_message");
entity.Property(e => e.ExternalId).HasColumnName("external_id");
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.QueuedAt).HasColumnName("queued_at");
entity.Property(e => e.SentAt).HasColumnName("sent_at");
entity.Property(e => e.DeliveredAt).HasColumnName("delivered_at");
entity.Property(e => e.FailedAt).HasColumnName("failed_at");
});
// ── digests ──────────────────────────────────────────────────────
modelBuilder.Entity<DigestEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("digests_pkey");
entity.ToTable("digests", schemaName);
entity.HasIndex(e => e.TenantId, "idx_digests_tenant");
entity.HasIndex(e => new { e.Status, e.CollectUntil }, "idx_digests_collect")
.HasFilter("(status = 'collecting')");
entity.HasAlternateKey(e => new { e.TenantId, e.ChannelId, e.Recipient, e.DigestKey })
.HasName("digests_tenant_id_channel_id_recipient_digest_key_key");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ChannelId).HasColumnName("channel_id");
entity.Property(e => e.Recipient).HasColumnName("recipient");
entity.Property(e => e.DigestKey).HasColumnName("digest_key");
entity.Property(e => e.EventCount)
.HasDefaultValue(0)
.HasColumnName("event_count");
entity.Property(e => e.Events)
.HasDefaultValueSql("'[]'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("events");
entity.Property(e => e.Status)
.HasDefaultValue("collecting")
.HasColumnName("status");
entity.Property(e => e.CollectUntil).HasColumnName("collect_until");
entity.Property(e => e.SentAt).HasColumnName("sent_at");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
});
// ── quiet_hours ──────────────────────────────────────────────────
modelBuilder.Entity<QuietHoursEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("quiet_hours_pkey");
entity.ToTable("quiet_hours", schemaName);
entity.HasIndex(e => e.TenantId, "idx_quiet_hours_tenant");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.UserId).HasColumnName("user_id");
entity.Property(e => e.ChannelId).HasColumnName("channel_id");
entity.Property(e => e.StartTime).HasColumnName("start_time");
entity.Property(e => e.EndTime).HasColumnName("end_time");
entity.Property(e => e.Timezone)
.HasDefaultValue("UTC")
.HasColumnName("timezone");
entity.Property(e => e.DaysOfWeek)
.HasDefaultValueSql("'{0,1,2,3,4,5,6}'::int[]")
.HasColumnName("days_of_week");
entity.Property(e => e.Enabled)
.HasDefaultValue(true)
.HasColumnName("enabled");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
});
// ── maintenance_windows ──────────────────────────────────────────
modelBuilder.Entity<MaintenanceWindowEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("maintenance_windows_pkey");
entity.ToTable("maintenance_windows", schemaName);
entity.HasIndex(e => e.TenantId, "idx_maintenance_windows_tenant");
entity.HasIndex(e => new { e.StartAt, e.EndAt }, "idx_maintenance_windows_active");
entity.HasAlternateKey(e => new { e.TenantId, e.Name }).HasName("maintenance_windows_tenant_id_name_key");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.StartAt).HasColumnName("start_at");
entity.Property(e => e.EndAt).HasColumnName("end_at");
entity.Property(e => e.SuppressChannels).HasColumnName("suppress_channels");
entity.Property(e => e.SuppressEventTypes).HasColumnName("suppress_event_types");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
});
// ── escalation_policies ──────────────────────────────────────────
modelBuilder.Entity<EscalationPolicyEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("escalation_policies_pkey");
entity.ToTable("escalation_policies", schemaName);
entity.HasIndex(e => e.TenantId, "idx_escalation_policies_tenant");
entity.HasAlternateKey(e => new { e.TenantId, e.Name }).HasName("escalation_policies_tenant_id_name_key");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.Enabled)
.HasDefaultValue(true)
.HasColumnName("enabled");
entity.Property(e => e.Steps)
.HasDefaultValueSql("'[]'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("steps");
entity.Property(e => e.RepeatCount)
.HasDefaultValue(0)
.HasColumnName("repeat_count");
entity.Property(e => e.Metadata)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("metadata");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
});
// ── escalation_states ────────────────────────────────────────────
modelBuilder.Entity<EscalationStateEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("escalation_states_pkey");
entity.ToTable("escalation_states", schemaName);
entity.HasIndex(e => e.TenantId, "idx_escalation_states_tenant");
entity.HasIndex(e => new { e.Status, e.NextEscalationAt }, "idx_escalation_states_active")
.HasFilter("(status = 'active')");
entity.HasIndex(e => e.CorrelationId, "idx_escalation_states_correlation");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.PolicyId).HasColumnName("policy_id");
entity.Property(e => e.IncidentId).HasColumnName("incident_id");
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
entity.Property(e => e.CurrentStep)
.HasDefaultValue(0)
.HasColumnName("current_step");
entity.Property(e => e.RepeatIteration)
.HasDefaultValue(0)
.HasColumnName("repeat_iteration");
entity.Property(e => e.Status)
.HasDefaultValue("active")
.HasColumnName("status");
entity.Property(e => e.StartedAt)
.HasDefaultValueSql("now()")
.HasColumnName("started_at");
entity.Property(e => e.NextEscalationAt).HasColumnName("next_escalation_at");
entity.Property(e => e.AcknowledgedAt).HasColumnName("acknowledged_at");
entity.Property(e => e.AcknowledgedBy).HasColumnName("acknowledged_by");
entity.Property(e => e.ResolvedAt).HasColumnName("resolved_at");
entity.Property(e => e.ResolvedBy).HasColumnName("resolved_by");
entity.Property(e => e.Metadata)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("metadata");
});
// ── on_call_schedules ────────────────────────────────────────────
modelBuilder.Entity<OnCallScheduleEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("on_call_schedules_pkey");
entity.ToTable("on_call_schedules", schemaName);
entity.HasIndex(e => e.TenantId, "idx_on_call_schedules_tenant");
entity.HasAlternateKey(e => new { e.TenantId, e.Name }).HasName("on_call_schedules_tenant_id_name_key");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.Timezone)
.HasDefaultValue("UTC")
.HasColumnName("timezone");
entity.Property(e => e.RotationType)
.HasDefaultValue("weekly")
.HasColumnName("rotation_type");
entity.Property(e => e.Participants)
.HasDefaultValueSql("'[]'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("participants");
entity.Property(e => e.Overrides)
.HasDefaultValueSql("'[]'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("overrides");
entity.Property(e => e.Metadata)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("metadata");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
});
// ── inbox ────────────────────────────────────────────────────────
modelBuilder.Entity<InboxEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("inbox_pkey");
entity.ToTable("inbox", schemaName);
entity.HasIndex(e => new { e.TenantId, e.UserId }, "idx_inbox_tenant_user");
entity.HasIndex(e => new { e.TenantId, e.UserId, e.Read, e.CreatedAt }, "idx_inbox_unread")
.IsDescending(false, false, false, true)
.HasFilter("(read = false AND archived = false)");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.UserId).HasColumnName("user_id");
entity.Property(e => e.Title).HasColumnName("title");
entity.Property(e => e.Body).HasColumnName("body");
entity.Property(e => e.EventType).HasColumnName("event_type");
entity.Property(e => e.EventPayload)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("event_payload");
entity.Property(e => e.Read)
.HasDefaultValue(false)
.HasColumnName("read");
entity.Property(e => e.Archived)
.HasDefaultValue(false)
.HasColumnName("archived");
entity.Property(e => e.ActionUrl).HasColumnName("action_url");
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.ReadAt).HasColumnName("read_at");
entity.Property(e => e.ArchivedAt).HasColumnName("archived_at");
});
// ── incidents ────────────────────────────────────────────────────
modelBuilder.Entity<IncidentEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("incidents_pkey");
entity.ToTable("incidents", schemaName);
entity.HasIndex(e => e.TenantId, "idx_incidents_tenant");
entity.HasIndex(e => new { e.TenantId, e.Status }, "idx_incidents_status");
entity.HasIndex(e => new { e.TenantId, e.Severity }, "idx_incidents_severity");
entity.HasIndex(e => e.CorrelationId, "idx_incidents_correlation");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Title).HasColumnName("title");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.Severity)
.HasDefaultValue("medium")
.HasColumnName("severity");
entity.Property(e => e.Status)
.HasDefaultValue("open")
.HasColumnName("status");
entity.Property(e => e.Source).HasColumnName("source");
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
entity.Property(e => e.AssignedTo).HasColumnName("assigned_to");
entity.Property(e => e.EscalationPolicyId).HasColumnName("escalation_policy_id");
entity.Property(e => e.Metadata)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("metadata");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.AcknowledgedAt).HasColumnName("acknowledged_at");
entity.Property(e => e.ResolvedAt).HasColumnName("resolved_at");
entity.Property(e => e.ClosedAt).HasColumnName("closed_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
});
// ── audit ────────────────────────────────────────────────────────
modelBuilder.Entity<NotifyAuditEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("audit_pkey");
entity.ToTable("audit", schemaName);
entity.HasIndex(e => e.TenantId, "idx_audit_tenant");
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_audit_created");
entity.Property(e => e.Id)
.ValueGeneratedOnAdd()
.UseIdentityByDefaultColumn()
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.UserId).HasColumnName("user_id");
entity.Property(e => e.Action).HasColumnName("action");
entity.Property(e => e.ResourceType).HasColumnName("resource_type");
entity.Property(e => e.ResourceId).HasColumnName("resource_id");
entity.Property(e => e.Details)
.HasColumnType("jsonb")
.HasColumnName("details");
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
});
// ── locks ────────────────────────────────────────────────────────
modelBuilder.Entity<LockEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("locks_pkey");
entity.ToTable("locks", schemaName);
entity.HasIndex(e => e.TenantId, "idx_locks_tenant");
entity.HasIndex(e => e.ExpiresAt, "idx_locks_expiry");
entity.HasAlternateKey(e => new { e.TenantId, e.Resource }).HasName("locks_tenant_id_resource_key");
entity.Property(e => e.Id)
.HasDefaultValueSql("gen_random_uuid()")
.HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Resource).HasColumnName("resource");
entity.Property(e => e.Owner).HasColumnName("owner");
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
});
// ── operator_overrides ─────────────────────────────────────────
modelBuilder.Entity<OperatorOverrideEntity>(entity =>
{
entity.HasKey(e => new { e.TenantId, e.OverrideId }).HasName("operator_overrides_pkey");
entity.ToTable("operator_overrides", schemaName);
entity.HasIndex(e => new { e.TenantId, e.OverrideType }, "idx_operator_overrides_type");
entity.HasIndex(e => new { e.TenantId, e.ExpiresAt }, "idx_operator_overrides_expires");
entity.HasIndex(e => new { e.TenantId, e.OverrideType, e.ExpiresAt }, "idx_operator_overrides_active")
.HasFilter("(expires_at > now())");
entity.Property(e => e.OverrideId).HasColumnName("override_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.OverrideType).HasColumnName("override_type");
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
entity.Property(e => e.ChannelId).HasColumnName("channel_id");
entity.Property(e => e.RuleId).HasColumnName("rule_id");
entity.Property(e => e.Reason).HasColumnName("reason");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
});
// ── throttle_configs ──────────────────────────────────────────────
modelBuilder.Entity<ThrottleConfigEntity>(entity =>
{
entity.HasKey(e => new { e.TenantId, e.ConfigId }).HasName("throttle_configs_pkey");
entity.ToTable("throttle_configs", schemaName);
entity.HasIndex(e => new { e.TenantId, e.ChannelId }, "idx_throttle_configs_channel")
.HasFilter("(channel_id IS NOT NULL)");
entity.HasIndex(e => new { e.TenantId, e.IsDefault }, "idx_throttle_configs_default")
.HasFilter("(is_default = TRUE)");
entity.Property(e => e.ConfigId).HasColumnName("config_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.DefaultWindow)
.HasConversion(
v => (long)v.TotalSeconds,
v => TimeSpan.FromSeconds(v))
.HasColumnName("default_window_seconds")
.HasDefaultValue(TimeSpan.FromSeconds(300));
entity.Property(e => e.MaxNotificationsPerWindow).HasColumnName("max_notifications_per_window");
entity.Property(e => e.ChannelId).HasColumnName("channel_id");
entity.Property(e => e.IsDefault)
.HasDefaultValue(false)
.HasColumnName("is_default");
entity.Property(e => e.Enabled)
.HasDefaultValue(true)
.HasColumnName("enabled");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.Metadata)
.HasColumnType("jsonb")
.HasColumnName("metadata");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
});
// ── localization_bundles ──────────────────────────────────────────
modelBuilder.Entity<LocalizationBundleEntity>(entity =>
{
entity.HasKey(e => new { e.TenantId, e.BundleId }).HasName("localization_bundles_pkey");
entity.ToTable("localization_bundles", schemaName);
entity.HasIndex(e => new { e.TenantId, e.BundleKey }, "idx_localization_bundles_key");
entity.HasIndex(e => new { e.TenantId, e.BundleKey, e.Locale }, "idx_localization_bundles_key_locale")
.IsUnique();
entity.HasIndex(e => new { e.TenantId, e.BundleKey, e.IsDefault }, "idx_localization_bundles_default")
.HasFilter("(is_default = TRUE)");
entity.Property(e => e.BundleId).HasColumnName("bundle_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Locale).HasColumnName("locale");
entity.Property(e => e.BundleKey).HasColumnName("bundle_key");
entity.Property(e => e.Strings)
.HasColumnType("jsonb")
.HasColumnName("strings");
entity.Property(e => e.IsDefault)
.HasDefaultValue(false)
.HasColumnName("is_default");
entity.Property(e => e.ParentLocale).HasColumnName("parent_locale");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.Metadata)
.HasColumnType("jsonb")
.HasColumnName("metadata");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.EfCore.Context;
/// <summary>
/// Design-time factory for <see cref="NotifyDbContext"/>.
/// Used by dotnet ef CLI for scaffold and optimize commands.
/// Does NOT use compiled models (reflection-based discovery at design time).
/// </summary>
public sealed class NotifyDesignTimeDbContextFactory : IDesignTimeDbContextFactory<NotifyDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=notify,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_NOTIFY_EF_CONNECTION";
public NotifyDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<NotifyDbContext>()
.UseNpgsql(connectionString, npgsql =>
{
npgsql.MapEnum<ChannelType>("notify.channel_type");
npgsql.MapEnum<DeliveryStatus>("notify.delivery_status");
})
.Options;
return new NotifyDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -1,21 +1,30 @@
using NpgsqlTypes;
namespace StellaOps.Notify.Persistence.Postgres.Models;
/// <summary>
/// Channel types for notifications.
/// Values map to the <c>notify.channel_type</c> PostgreSQL enum.
/// </summary>
public enum ChannelType
{
/// <summary>Email channel.</summary>
[PgName("email")]
Email,
/// <summary>Slack channel.</summary>
[PgName("slack")]
Slack,
/// <summary>Microsoft Teams channel.</summary>
[PgName("teams")]
Teams,
/// <summary>Generic webhook channel.</summary>
[PgName("webhook")]
Webhook,
/// <summary>PagerDuty integration.</summary>
[PgName("pagerduty")]
PagerDuty,
/// <summary>OpsGenie integration.</summary>
[PgName("opsgenie")]
OpsGenie
}

View File

@@ -1,23 +1,33 @@
using NpgsqlTypes;
namespace StellaOps.Notify.Persistence.Postgres.Models;
/// <summary>
/// Delivery status values.
/// Values map to the <c>notify.delivery_status</c> PostgreSQL enum.
/// </summary>
public enum DeliveryStatus
{
/// <summary>Delivery is pending.</summary>
[PgName("pending")]
Pending,
/// <summary>Delivery is queued for sending.</summary>
[PgName("queued")]
Queued,
/// <summary>Delivery is being sent.</summary>
[PgName("sending")]
Sending,
/// <summary>Delivery was sent.</summary>
[PgName("sent")]
Sent,
/// <summary>Delivery was confirmed delivered.</summary>
[PgName("delivered")]
Delivered,
/// <summary>Delivery failed.</summary>
[PgName("failed")]
Failed,
/// <summary>Delivery bounced.</summary>
[PgName("bounced")]
Bounced
}

View File

@@ -1,7 +1,9 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres;
@@ -27,6 +29,15 @@ public sealed class NotifyDataSource : DataSourceBase
/// <inheritdoc />
protected override string ModuleName => "Notify";
/// <inheritdoc />
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
{
// Register PostgreSQL enum type mappings so Npgsql sends enum values natively
// instead of text, matching the notify.channel_type and notify.delivery_status DB types.
builder.MapEnum<ChannelType>(DefaultSchemaName + ".channel_type");
builder.MapEnum<DeliveryStatus>(DefaultSchemaName + ".delivery_status");
}
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
{
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))

View File

@@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Notify.Persistence.EfCore.CompiledModels;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres;
/// <summary>
/// Runtime factory for creating <see cref="NotifyDbContext"/> instances.
/// Uses the static compiled model when schema matches the default; falls back to
/// reflection-based model building for non-default schemas (integration tests).
/// </summary>
internal static class NotifyDbContextFactory
{
public static NotifyDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? NotifyDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<NotifyDbContext>()
.UseNpgsql(connection, npgsql =>
{
npgsql.CommandTimeout(commandTimeoutSeconds);
// Register PostgreSQL enum type mappings so the Npgsql EF Core provider
// sends ChannelType and DeliveryStatus as native enum values, not integers.
npgsql.MapEnum<ChannelType>(normalizedSchema + ".channel_type");
npgsql.MapEnum<DeliveryStatus>(normalizedSchema + ".delivery_status");
});
// NOTE: UseModel(NotifyDbContextModel.Instance) is intentionally disabled while the
// compiled model is still a stub (no entity types registered). Once `dotnet ef dbcontext
// optimize` is run against a provisioned database, uncomment the block below to enable
// the compiled model path for the default schema, which eliminates runtime model building.
//
// if (string.Equals(normalizedSchema, NotifyDataSource.DefaultSchemaName, StringComparison.Ordinal))
// {
// optionsBuilder.UseModel(NotifyDbContextModel.Instance);
// }
return new NotifyDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -1,100 +1,63 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for notification channel operations.
/// EF Core repository for notification channel operations.
/// </summary>
public sealed class ChannelRepository : RepositoryBase<NotifyDataSource>, IChannelRepository
public sealed class ChannelRepository : IChannelRepository
{
/// <summary>
/// Creates a new channel repository.
/// </summary>
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<ChannelRepository> _logger;
public ChannelRepository(NotifyDataSource dataSource, ILogger<ChannelRepository> logger)
: base(dataSource, logger)
{
_dataSource = dataSource;
_logger = logger;
}
/// <inheritdoc />
public async Task<ChannelEntity> CreateAsync(ChannelEntity channel, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.channels (
id, tenant_id, name, channel_type, enabled, config, credentials, metadata, created_by
)
VALUES (
@id, @tenant_id, @name, @channel_type::notify.channel_type, @enabled,
@config::jsonb, @credentials::jsonb, @metadata::jsonb, @created_by
)
RETURNING id, tenant_id, name, channel_type::text, enabled,
config::text, credentials::text, metadata::text, created_at, updated_at, created_by
""";
await using var connection = await DataSource.OpenConnectionAsync(channel.TenantId, "writer", cancellationToken)
await using var connection = await _dataSource.OpenConnectionAsync(channel.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
AddParameter(command, "id", channel.Id);
AddParameter(command, "tenant_id", channel.TenantId);
AddParameter(command, "name", channel.Name);
AddParameter(command, "channel_type", ChannelTypeToString(channel.ChannelType));
AddParameter(command, "enabled", channel.Enabled);
AddJsonbParameter(command, "config", channel.Config);
AddJsonbParameter(command, "credentials", channel.Credentials);
AddJsonbParameter(command, "metadata", channel.Metadata);
AddParameter(command, "created_by", channel.CreatedBy);
dbContext.Channels.Add(channel);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapChannel(reader);
return channel;
}
/// <inheritdoc />
public async Task<ChannelEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, channel_type::text, enabled,
config::text, credentials::text, metadata::text, created_at, updated_at, created_by
FROM notify.channels
WHERE tenant_id = @tenant_id AND id = @id
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapChannel,
cancellationToken).ConfigureAwait(false);
return await dbContext.Channels
.AsNoTracking()
.FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<ChannelEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, channel_type::text, enabled,
config::text, credentials::text, metadata::text, created_at, updated_at, created_by
FROM notify.channels
WHERE tenant_id = @tenant_id AND name = @name
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
},
MapChannel,
cancellationToken).ConfigureAwait(false);
return await dbContext.Channels
.AsNoTracking()
.FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Name == name, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -106,93 +69,58 @@ public sealed class ChannelRepository : RepositoryBase<NotifyDataSource>, IChann
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, name, channel_type::text, enabled,
config::text, credentials::text, metadata::text, created_at, updated_at, created_by
FROM notify.channels
WHERE tenant_id = @tenant_id
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
IQueryable<ChannelEntity> query = dbContext.Channels
.AsNoTracking()
.Where(c => c.TenantId == tenantId);
if (enabled.HasValue)
{
sql += " AND enabled = @enabled";
}
query = query.Where(c => c.Enabled == enabled.Value);
if (channelType.HasValue)
{
sql += " AND channel_type = @channel_type::notify.channel_type";
}
query = query.Where(c => c.ChannelType == channelType.Value);
sql += " ORDER BY name, id LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (enabled.HasValue)
{
AddParameter(cmd, "enabled", enabled.Value);
}
if (channelType.HasValue)
{
AddParameter(cmd, "channel_type", ChannelTypeToString(channelType.Value));
}
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapChannel,
cancellationToken).ConfigureAwait(false);
return await query
.OrderBy(c => c.Name).ThenBy(c => c.Id)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(ChannelEntity channel, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.channels
SET name = @name,
channel_type = @channel_type::notify.channel_type,
enabled = @enabled,
config = @config::jsonb,
credentials = @credentials::jsonb,
metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
await using var connection = await _dataSource.OpenConnectionAsync(channel.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await ExecuteAsync(
channel.TenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", channel.TenantId);
AddParameter(cmd, "id", channel.Id);
AddParameter(cmd, "name", channel.Name);
AddParameter(cmd, "channel_type", ChannelTypeToString(channel.ChannelType));
AddParameter(cmd, "enabled", channel.Enabled);
AddJsonbParameter(cmd, "config", channel.Config);
AddJsonbParameter(cmd, "credentials", channel.Credentials);
AddJsonbParameter(cmd, "metadata", channel.Metadata);
},
cancellationToken).ConfigureAwait(false);
var existing = await dbContext.Channels
.FirstOrDefaultAsync(c => c.TenantId == channel.TenantId && c.Id == channel.Id, cancellationToken)
.ConfigureAwait(false);
if (existing is null)
return false;
dbContext.Entry(existing).CurrentValues.SetValues(channel);
var rows = await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.channels WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
var rows = await dbContext.Channels
.Where(c => c.TenantId == tenantId && c.Id == id)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
return rows > 0;
}
@@ -203,62 +131,17 @@ public sealed class ChannelRepository : RepositoryBase<NotifyDataSource>, IChann
ChannelType channelType,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, channel_type::text, enabled,
config::text, credentials::text, metadata::text, created_at, updated_at, created_by
FROM notify.channels
WHERE tenant_id = @tenant_id
AND channel_type = @channel_type::notify.channel_type
AND enabled = TRUE
ORDER BY name, id
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "channel_type", ChannelTypeToString(channelType));
},
MapChannel,
cancellationToken).ConfigureAwait(false);
return await dbContext.Channels
.AsNoTracking()
.Where(c => c.TenantId == tenantId && c.ChannelType == channelType && c.Enabled)
.OrderBy(c => c.Name).ThenBy(c => c.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
private static ChannelEntity MapChannel(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
ChannelType = ParseChannelType(reader.GetString(3)),
Enabled = reader.GetBoolean(4),
Config = reader.GetString(5),
Credentials = GetNullableString(reader, 6),
Metadata = reader.GetString(7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(9),
CreatedBy = GetNullableString(reader, 10)
};
private static string ChannelTypeToString(ChannelType channelType) => channelType switch
{
ChannelType.Email => "email",
ChannelType.Slack => "slack",
ChannelType.Teams => "teams",
ChannelType.Webhook => "webhook",
ChannelType.PagerDuty => "pagerduty",
ChannelType.OpsGenie => "opsgenie",
_ => throw new ArgumentException($"Unknown channel type: {channelType}", nameof(channelType))
};
private static ChannelType ParseChannelType(string channelType) => channelType switch
{
"email" => ChannelType.Email,
"slack" => ChannelType.Slack,
"teams" => ChannelType.Teams,
"webhook" => ChannelType.Webhook,
"pagerduty" => ChannelType.PagerDuty,
"opsgenie" => ChannelType.OpsGenie,
_ => throw new ArgumentException($"Unknown channel type: {channelType}", nameof(channelType))
};
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,83 +1,102 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using NpgsqlTypes;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
using System.Text;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for notification delivery operations.
/// EF Core repository for notification delivery operations.
/// Uses raw SQL for complex operations on the partitioned deliveries table.
/// </summary>
public sealed class DeliveryRepository : RepositoryBase<NotifyDataSource>, IDeliveryRepository
public sealed class DeliveryRepository : IDeliveryRepository
{
/// <summary>
/// Creates a new delivery repository.
/// </summary>
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<DeliveryRepository> _logger;
public DeliveryRepository(NotifyDataSource dataSource, ILogger<DeliveryRepository> logger)
: base(dataSource, logger)
{
_dataSource = dataSource;
_logger = logger;
}
/// <inheritdoc />
public async Task<DeliveryEntity> CreateAsync(DeliveryEntity delivery, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.deliveries (
id, tenant_id, channel_id, rule_id, template_id, status, recipient,
subject, body, event_type, event_payload, max_attempts, correlation_id
)
VALUES (
@id, @tenant_id, @channel_id, @rule_id, @template_id, @status::notify.delivery_status, @recipient,
@subject, @body, @event_type, @event_payload::jsonb, @max_attempts, @correlation_id
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(delivery.TenantId, "writer", cancellationToken)
await using var connection = await _dataSource.OpenConnectionAsync(delivery.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
AddDeliveryParameters(command, delivery);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapDelivery(reader);
dbContext.Deliveries.Add(delivery);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return delivery;
}
/// <inheritdoc />
public async Task<DeliveryEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM notify.deliveries WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapDelivery,
cancellationToken).ConfigureAwait(false);
return await dbContext.Deliveries
.AsNoTracking()
.FirstOrDefaultAsync(d => d.TenantId == tenantId && d.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<DeliveryEntity> UpsertAsync(DeliveryEntity delivery, CancellationToken cancellationToken = default)
{
// Note: With partitioned tables, ON CONFLICT requires partition key in unique constraint.
// Using INSERT ... ON CONFLICT (id, created_at) for partition-safe upsert.
// For existing records, we fall back to UPDATE if insert conflicts.
const string sql = """
// Partitioned table UPSERT requires raw SQL: ON CONFLICT (id, created_at) which is the composite PK.
// COALESCE on external_id preserves the existing value when the new value is NULL.
await using var connection = await _dataSource.OpenConnectionAsync(delivery.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use named NpgsqlParameters for nullable fields because EF Core's
// ExecuteSqlRawAsync cannot map DBNull.Value without explicit type info.
var parameters = new object[]
{
new NpgsqlParameter("@p_id", NpgsqlDbType.Uuid) { Value = delivery.Id },
new NpgsqlParameter("@p_tenant_id", NpgsqlDbType.Text) { Value = delivery.TenantId },
new NpgsqlParameter("@p_channel_id", NpgsqlDbType.Uuid) { Value = delivery.ChannelId },
new NpgsqlParameter("@p_rule_id", NpgsqlDbType.Uuid) { Value = (object?)delivery.RuleId ?? DBNull.Value },
new NpgsqlParameter("@p_template_id", NpgsqlDbType.Uuid) { Value = (object?)delivery.TemplateId ?? DBNull.Value },
new NpgsqlParameter("@p_status", NpgsqlDbType.Text) { Value = StatusToString(delivery.Status) },
new NpgsqlParameter("@p_recipient", NpgsqlDbType.Text) { Value = delivery.Recipient },
new NpgsqlParameter("@p_subject", NpgsqlDbType.Text) { Value = (object?)delivery.Subject ?? DBNull.Value },
new NpgsqlParameter("@p_body", NpgsqlDbType.Text) { Value = (object?)delivery.Body ?? DBNull.Value },
new NpgsqlParameter("@p_event_type", NpgsqlDbType.Text) { Value = delivery.EventType },
new NpgsqlParameter("@p_event_payload", NpgsqlDbType.Jsonb) { Value = delivery.EventPayload },
new NpgsqlParameter("@p_attempt", NpgsqlDbType.Integer) { Value = delivery.Attempt },
new NpgsqlParameter("@p_max_attempts", NpgsqlDbType.Integer) { Value = delivery.MaxAttempts },
new NpgsqlParameter("@p_next_retry_at", NpgsqlDbType.TimestampTz) { Value = (object?)delivery.NextRetryAt ?? DBNull.Value },
new NpgsqlParameter("@p_error_message", NpgsqlDbType.Text) { Value = (object?)delivery.ErrorMessage ?? DBNull.Value },
new NpgsqlParameter("@p_external_id", NpgsqlDbType.Text) { Value = (object?)delivery.ExternalId ?? DBNull.Value },
new NpgsqlParameter("@p_correlation_id", NpgsqlDbType.Text) { Value = (object?)delivery.CorrelationId ?? DBNull.Value },
new NpgsqlParameter("@p_created_at", NpgsqlDbType.TimestampTz) { Value = delivery.CreatedAt },
new NpgsqlParameter("@p_queued_at", NpgsqlDbType.TimestampTz) { Value = (object?)delivery.QueuedAt ?? DBNull.Value },
new NpgsqlParameter("@p_sent_at", NpgsqlDbType.TimestampTz) { Value = (object?)delivery.SentAt ?? DBNull.Value },
new NpgsqlParameter("@p_delivered_at", NpgsqlDbType.TimestampTz) { Value = (object?)delivery.DeliveredAt ?? DBNull.Value },
new NpgsqlParameter("@p_failed_at", NpgsqlDbType.TimestampTz) { Value = (object?)delivery.FailedAt ?? DBNull.Value }
};
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO notify.deliveries (
id, tenant_id, channel_id, rule_id, template_id, status, recipient, subject, body,
event_type, event_payload, attempt, max_attempts, next_retry_at, error_message,
external_id, correlation_id, created_at, queued_at, sent_at, delivered_at, failed_at
) VALUES (
@id, @tenant_id, @channel_id, @rule_id, @template_id, @status::notify.delivery_status, @recipient, @subject, @body,
@event_type, @event_payload::jsonb, @attempt, @max_attempts, @next_retry_at, @error_message,
@external_id, @correlation_id, @created_at, @queued_at, @sent_at, @delivered_at, @failed_at
@p_id, @p_tenant_id, @p_channel_id, @p_rule_id, @p_template_id,
@p_status::notify.delivery_status, @p_recipient, @p_subject, @p_body,
@p_event_type, @p_event_payload, @p_attempt, @p_max_attempts, @p_next_retry_at, @p_error_message,
@p_external_id, @p_correlation_id, @p_created_at, @p_queued_at, @p_sent_at, @p_delivered_at, @p_failed_at
)
ON CONFLICT (id, created_at) DO UPDATE SET
status = EXCLUDED.status,
@@ -99,15 +118,11 @@ public sealed class DeliveryRepository : RepositoryBase<NotifyDataSource>, IDeli
sent_at = EXCLUDED.sent_at,
delivered_at = EXCLUDED.delivered_at,
failed_at = EXCLUDED.failed_at
RETURNING *
""";
""",
parameters,
cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenConnectionAsync(delivery.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddDeliveryParameters(command, delivery);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapDelivery(reader);
return delivery;
}
/// <inheritdoc />
@@ -122,71 +137,35 @@ public sealed class DeliveryRepository : RepositoryBase<NotifyDataSource>, IDeli
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = new StringBuilder("SELECT * FROM notify.deliveries WHERE tenant_id = @tenant_id");
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
IQueryable<DeliveryEntity> query = dbContext.Deliveries
.AsNoTracking()
.Where(d => d.TenantId == tenantId);
if (status is not null)
{
sql.Append(" AND status = @status::notify.delivery_status");
}
query = query.Where(d => d.Status == status.Value);
if (channelId is not null)
{
sql.Append(" AND channel_id = @channel_id");
}
query = query.Where(d => d.ChannelId == channelId.Value);
if (!string.IsNullOrWhiteSpace(eventType))
{
sql.Append(" AND event_type = @event_type");
}
query = query.Where(d => d.EventType == eventType);
if (since is not null)
{
sql.Append(" AND created_at >= @since");
}
query = query.Where(d => d.CreatedAt >= since.Value);
if (until is not null)
{
sql.Append(" AND created_at <= @until");
}
query = query.Where(d => d.CreatedAt <= until.Value);
sql.Append(" ORDER BY created_at DESC, id LIMIT @limit OFFSET @offset");
return await QueryAsync(
tenantId,
sql.ToString(),
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (status is not null)
{
AddParameter(cmd, "status", StatusToString(status.Value));
}
if (channelId is not null)
{
AddParameter(cmd, "channel_id", channelId.Value);
}
if (!string.IsNullOrWhiteSpace(eventType))
{
AddParameter(cmd, "event_type", eventType);
}
if (since is not null)
{
AddParameter(cmd, "since", since.Value);
}
if (until is not null)
{
AddParameter(cmd, "until", until.Value);
}
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapDelivery,
cancellationToken).ConfigureAwait(false);
return await query
.OrderByDescending(d => d.CreatedAt).ThenBy(d => d.Id)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -195,26 +174,26 @@ public sealed class DeliveryRepository : RepositoryBase<NotifyDataSource>, IDeli
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM notify.deliveries
WHERE tenant_id = @tenant_id
AND status IN ('pending', 'queued')
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
AND attempt < max_attempts
ORDER BY created_at, id
LIMIT @limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
},
MapDelivery,
cancellationToken).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
// Use variables for enum values so the LINQ translator parameterizes them
// instead of inlining with ::notify.delivery_status casts that require
// the enum type to be resolved in the connection's type catalog.
var pendingStatus = DeliveryStatus.Pending;
var queuedStatus = DeliveryStatus.Queued;
return await dbContext.Deliveries
.AsNoTracking()
.Where(d => d.TenantId == tenantId
&& (d.Status == pendingStatus || d.Status == queuedStatus)
&& (d.NextRetryAt == null || d.NextRetryAt <= now)
&& d.Attempt < d.MaxAttempts)
.OrderBy(d => d.CreatedAt).ThenBy(d => d.Id)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -225,25 +204,18 @@ public sealed class DeliveryRepository : RepositoryBase<NotifyDataSource>, IDeli
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM notify.deliveries
WHERE tenant_id = @tenant_id AND status = @status::notify.delivery_status
ORDER BY created_at DESC, id
LIMIT @limit OFFSET @offset
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "status", StatusToString(status));
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapDelivery,
cancellationToken).ConfigureAwait(false);
return await dbContext.Deliveries
.AsNoTracking()
.Where(d => d.TenantId == tenantId && d.Status == status)
.OrderByDescending(d => d.CreatedAt).ThenBy(d => d.Id)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -252,92 +224,65 @@ public sealed class DeliveryRepository : RepositoryBase<NotifyDataSource>, IDeli
string correlationId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM notify.deliveries
WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id
ORDER BY created_at, id
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "correlation_id", correlationId);
},
MapDelivery,
cancellationToken).ConfigureAwait(false);
return await dbContext.Deliveries
.AsNoTracking()
.Where(d => d.TenantId == tenantId && d.CorrelationId == correlationId)
.OrderBy(d => d.CreatedAt).ThenBy(d => d.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> MarkQueuedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.deliveries
SET status = 'queued'::notify.delivery_status,
queued_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'pending'
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.deliveries SET status = 'queued'::notify.delivery_status, queued_at = NOW() WHERE tenant_id = {0} AND id = {1} AND status = 'pending'",
new object[] { tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> MarkSentAsync(string tenantId, Guid id, string? externalId = null, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.deliveries
SET status = 'sent'::notify.delivery_status,
sent_at = NOW(),
external_id = COALESCE(@external_id, external_id)
WHERE tenant_id = @tenant_id AND id = @id AND status IN ('pending', 'queued', 'sending')
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
// Use named NpgsqlParameters for all values because the nullable external_id
// requires explicit type info (EF Core cannot map DBNull.Value without it),
// and mixing named + positional parameters is not supported.
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.deliveries SET status = 'sent'::notify.delivery_status, sent_at = NOW(), external_id = COALESCE(@p_ext_id, external_id) WHERE tenant_id = @p_tid AND id = @p_id AND status IN ('pending', 'queued', 'sending')",
new object[]
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "external_id", externalId);
new NpgsqlParameter("@p_ext_id", NpgsqlDbType.Text) { Value = (object?)externalId ?? DBNull.Value },
new NpgsqlParameter("@p_tid", NpgsqlDbType.Text) { Value = tenantId },
new NpgsqlParameter("@p_id", NpgsqlDbType.Uuid) { Value = id }
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> MarkDeliveredAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.deliveries
SET status = 'delivered'::notify.delivery_status,
delivered_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'sent'
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.deliveries SET status = 'delivered'::notify.delivery_status, delivered_at = NOW() WHERE tenant_id = {0} AND id = {1} AND status = 'sent'",
new object[] { tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
@@ -349,66 +294,49 @@ public sealed class DeliveryRepository : RepositoryBase<NotifyDataSource>, IDeli
TimeSpan? retryDelay = null,
CancellationToken cancellationToken = default)
{
// Use separate SQL queries to avoid PostgreSQL type inference issues with NULL parameters
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
int rows;
if (retryDelay.HasValue)
{
// Retry case: set to pending if retries remain, otherwise failed
const string sql = """
rows = await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE notify.deliveries
SET status = CASE
WHEN attempt + 1 < max_attempts THEN 'pending'::notify.delivery_status
ELSE 'failed'::notify.delivery_status
END,
attempt = attempt + 1,
error_message = @error_message,
error_message = {0},
failed_at = CASE WHEN attempt + 1 >= max_attempts THEN NOW() ELSE failed_at END,
next_retry_at = CASE
WHEN attempt + 1 < max_attempts THEN NOW() + @retry_delay
WHEN attempt + 1 < max_attempts THEN NOW() + {1}
ELSE NULL
END
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "error_message", errorMessage);
AddParameter(cmd, "retry_delay", retryDelay.Value);
},
WHERE tenant_id = {2} AND id = {3}
""",
new object[] { errorMessage, retryDelay.Value, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
else
{
// No retry: always set to failed
const string sql = """
rows = await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE notify.deliveries
SET status = 'failed'::notify.delivery_status,
attempt = attempt + 1,
error_message = @error_message,
error_message = {0},
failed_at = NOW(),
next_retry_at = NULL
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "error_message", errorMessage);
},
WHERE tenant_id = {1} AND id = {2}
""",
new object[] { errorMessage, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
return rows > 0;
}
/// <inheritdoc />
@@ -418,93 +346,50 @@ public sealed class DeliveryRepository : RepositoryBase<NotifyDataSource>, IDeli
DateTimeOffset to,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'pending') as pending,
COUNT(*) FILTER (WHERE status = 'sent') as sent,
COUNT(*) FILTER (WHERE status = 'delivered') as delivered,
COUNT(*) FILTER (WHERE status = 'failed') as failed,
COUNT(*) FILTER (WHERE status = 'bounced') as bounced
FROM notify.deliveries
WHERE tenant_id = @tenant_id
AND created_at >= @from
AND created_at < @to
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
// Stats query uses PostgreSQL FILTER clause which is not expressible via EF Core LINQ;
// routed through DbContext.Database for connection lifecycle consistency.
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "from", from);
AddParameter(command, "to", to);
var result = await dbContext.Database
.SqlQueryRaw<DeliveryStatsRow>(
"""
SELECT
COUNT(*)::bigint as "Total",
COUNT(*) FILTER (WHERE status = 'pending')::bigint as "Pending",
COUNT(*) FILTER (WHERE status = 'sent')::bigint as "Sent",
COUNT(*) FILTER (WHERE status = 'delivered')::bigint as "Delivered",
COUNT(*) FILTER (WHERE status = 'failed')::bigint as "Failed",
COUNT(*) FILTER (WHERE status = 'bounced')::bigint as "Bounced"
FROM notify.deliveries
WHERE tenant_id = {0}
AND created_at >= {1}
AND created_at < {2}
""",
tenantId, from, to)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return new DeliveryStats(
Total: reader.GetInt64(0),
Pending: reader.GetInt64(1),
Sent: reader.GetInt64(2),
Delivered: reader.GetInt64(3),
Failed: reader.GetInt64(4),
Bounced: reader.GetInt64(5));
return result is not null
? new DeliveryStats(result.Total, result.Pending, result.Sent, result.Delivered, result.Failed, result.Bounced)
: new DeliveryStats(0, 0, 0, 0, 0, 0);
}
private static void AddDeliveryParameters(NpgsqlCommand command, DeliveryEntity delivery)
/// <summary>
/// Internal projection type for <see cref="GetStatsAsync"/> raw SQL query.
/// Property names must match the column aliases in the SQL query.
/// </summary>
internal sealed class DeliveryStatsRow
{
AddParameter(command, "id", delivery.Id);
AddParameter(command, "tenant_id", delivery.TenantId);
AddParameter(command, "channel_id", delivery.ChannelId);
AddParameter(command, "rule_id", delivery.RuleId);
AddParameter(command, "template_id", delivery.TemplateId);
AddParameter(command, "status", StatusToString(delivery.Status));
AddParameter(command, "recipient", delivery.Recipient);
AddParameter(command, "subject", delivery.Subject);
AddParameter(command, "body", delivery.Body);
AddParameter(command, "event_type", delivery.EventType);
AddJsonbParameter(command, "event_payload", delivery.EventPayload);
AddParameter(command, "max_attempts", delivery.MaxAttempts);
AddParameter(command, "correlation_id", delivery.CorrelationId);
// Partition-aware parameters (required for partitioned table upsert)
AddParameter(command, "attempt", delivery.Attempt);
AddParameter(command, "next_retry_at", delivery.NextRetryAt);
AddParameter(command, "error_message", delivery.ErrorMessage);
AddParameter(command, "external_id", delivery.ExternalId);
AddParameter(command, "created_at", delivery.CreatedAt);
AddParameter(command, "queued_at", delivery.QueuedAt);
AddParameter(command, "sent_at", delivery.SentAt);
AddParameter(command, "delivered_at", delivery.DeliveredAt);
AddParameter(command, "failed_at", delivery.FailedAt);
public long Total { get; set; }
public long Pending { get; set; }
public long Sent { get; set; }
public long Delivered { get; set; }
public long Failed { get; set; }
public long Bounced { get; set; }
}
private static DeliveryEntity MapDelivery(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
ChannelId = reader.GetGuid(reader.GetOrdinal("channel_id")),
RuleId = GetNullableGuid(reader, reader.GetOrdinal("rule_id")),
TemplateId = GetNullableGuid(reader, reader.GetOrdinal("template_id")),
Status = ParseStatus(reader.GetString(reader.GetOrdinal("status"))),
Recipient = reader.GetString(reader.GetOrdinal("recipient")),
Subject = GetNullableString(reader, reader.GetOrdinal("subject")),
Body = GetNullableString(reader, reader.GetOrdinal("body")),
EventType = reader.GetString(reader.GetOrdinal("event_type")),
EventPayload = reader.GetString(reader.GetOrdinal("event_payload")),
Attempt = reader.GetInt32(reader.GetOrdinal("attempt")),
MaxAttempts = reader.GetInt32(reader.GetOrdinal("max_attempts")),
NextRetryAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("next_retry_at")),
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
ExternalId = GetNullableString(reader, reader.GetOrdinal("external_id")),
CorrelationId = GetNullableString(reader, reader.GetOrdinal("correlation_id")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
QueuedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("queued_at")),
SentAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("sent_at")),
DeliveredAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("delivered_at")),
FailedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("failed_at"))
};
private static string StatusToString(DeliveryStatus status) => status switch
{
DeliveryStatus.Pending => "pending",
@@ -517,15 +402,5 @@ public sealed class DeliveryRepository : RepositoryBase<NotifyDataSource>, IDeli
_ => throw new ArgumentException($"Unknown delivery status: {status}", nameof(status))
};
private static DeliveryStatus ParseStatus(string status) => status switch
{
"pending" => DeliveryStatus.Pending,
"queued" => DeliveryStatus.Queued,
"sending" => DeliveryStatus.Sending,
"sent" => DeliveryStatus.Sent,
"delivered" => DeliveryStatus.Delivered,
"failed" => DeliveryStatus.Failed,
"bounced" => DeliveryStatus.Bounced,
_ => throw new ArgumentException($"Unknown delivery status: {status}", nameof(status))
};
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,159 +1,126 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
public sealed class DigestRepository : RepositoryBase<NotifyDataSource>, IDigestRepository
public sealed class DigestRepository : IDigestRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<DigestRepository> _logger;
public DigestRepository(NotifyDataSource dataSource, ILogger<DigestRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<DigestEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, channel_id, recipient, digest_key, event_count, events, status, collect_until, sent_at, created_at, updated_at
FROM notify.digests WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapDigest, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Digests.AsNoTracking()
.FirstOrDefaultAsync(d => d.TenantId == tenantId && d.Id == id, cancellationToken).ConfigureAwait(false);
}
public async Task<DigestEntity?> GetByKeyAsync(string tenantId, Guid channelId, string recipient, string digestKey, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, channel_id, recipient, digest_key, event_count, events, status, collect_until, sent_at, created_at, updated_at
FROM notify.digests WHERE tenant_id = @tenant_id AND channel_id = @channel_id AND recipient = @recipient AND digest_key = @digest_key
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "channel_id", channelId);
AddParameter(cmd, "recipient", recipient);
AddParameter(cmd, "digest_key", digestKey);
}, MapDigest, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Digests.AsNoTracking()
.FirstOrDefaultAsync(d => d.TenantId == tenantId && d.ChannelId == channelId && d.Recipient == recipient && d.DigestKey == digestKey, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<DigestEntity>> GetReadyToSendAsync(int limit = 100, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, channel_id, recipient, digest_key, event_count, events, status, collect_until, sent_at, created_at, updated_at
FROM notify.digests WHERE status = 'collecting' AND collect_until <= NOW()
ORDER BY collect_until LIMIT @limit
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "limit", limit);
var results = new List<DigestEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
results.Add(MapDigest(reader));
return results;
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = DateTimeOffset.UtcNow;
return await dbContext.Digests.AsNoTracking()
.Where(d => d.Status == DigestStatus.Collecting && d.CollectUntil <= now)
.OrderBy(d => d.CollectUntil)
.Take(limit)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<DigestEntity> UpsertAsync(DigestEntity digest, CancellationToken cancellationToken = default)
{
const string sql = """
// Complex UPSERT with aggregate expression (event_count + EXCLUDED.event_count, events || EXCLUDED.events) requires raw SQL.
await using var connection = await _dataSource.OpenConnectionAsync(digest.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var id = digest.Id == Guid.Empty ? Guid.NewGuid() : digest.Id;
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO notify.digests (id, tenant_id, channel_id, recipient, digest_key, event_count, events, status, collect_until)
VALUES (@id, @tenant_id, @channel_id, @recipient, @digest_key, @event_count, @events::jsonb, @status, @collect_until)
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}::jsonb, {7}, {8})
ON CONFLICT (tenant_id, channel_id, recipient, digest_key) DO UPDATE SET
event_count = notify.digests.event_count + EXCLUDED.event_count,
events = notify.digests.events || EXCLUDED.events,
collect_until = GREATEST(notify.digests.collect_until, EXCLUDED.collect_until)
RETURNING *
""";
var id = digest.Id == Guid.Empty ? Guid.NewGuid() : digest.Id;
await using var connection = await DataSource.OpenConnectionAsync(digest.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", digest.TenantId);
AddParameter(command, "channel_id", digest.ChannelId);
AddParameter(command, "recipient", digest.Recipient);
AddParameter(command, "digest_key", digest.DigestKey);
AddParameter(command, "event_count", digest.EventCount);
AddJsonbParameter(command, "events", digest.Events);
AddParameter(command, "status", digest.Status);
AddParameter(command, "collect_until", digest.CollectUntil);
""",
new object[] { id, digest.TenantId, digest.ChannelId, digest.Recipient, digest.DigestKey, digest.EventCount, digest.Events, digest.Status, digest.CollectUntil },
cancellationToken).ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapDigest(reader);
// Read back the upserted entity
return await dbContext.Digests.AsNoTracking()
.FirstAsync(d => d.TenantId == digest.TenantId && d.ChannelId == digest.ChannelId && d.Recipient == digest.Recipient && d.DigestKey == digest.DigestKey, cancellationToken)
.ConfigureAwait(false);
}
public async Task<bool> AddEventAsync(string tenantId, Guid id, string eventJson, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.digests SET event_count = event_count + 1, events = events || @event::jsonb
WHERE tenant_id = @tenant_id AND id = @id AND status = 'collecting'
""";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddJsonbParameter(cmd, "event", eventJson);
}, cancellationToken).ConfigureAwait(false);
// JSON concatenation (events || @event::jsonb) requires raw SQL
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.digests SET event_count = event_count + 1, events = events || {0}::jsonb WHERE tenant_id = {1} AND id = {2} AND status = 'collecting'",
new object[] { eventJson, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> MarkSendingAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.digests SET status = 'sending' WHERE tenant_id = @tenant_id AND id = @id AND status = 'collecting'";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.digests SET status = 'sending' WHERE tenant_id = {0} AND id = {1} AND status = 'collecting'",
new object[] { tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> MarkSentAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.digests SET status = 'sent', sent_at = NOW() WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.digests SET status = 'sent', sent_at = NOW() WHERE tenant_id = {0} AND id = {1}",
new object[] { tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.digests WHERE status = 'sent' AND sent_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Digests.Where(d => d.Status == DigestStatus.Sent && d.SentAt < cutoff)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<bool> DeleteByKeyAsync(string tenantId, Guid channelId, string recipient, string digestKey, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.digests WHERE tenant_id = @tenant_id AND channel_id = @channel_id AND recipient = @recipient AND digest_key = @digest_key";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "channel_id", channelId);
AddParameter(cmd, "recipient", recipient);
AddParameter(cmd, "digest_key", digestKey);
},
cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Digests
.Where(d => d.TenantId == tenantId && d.ChannelId == channelId && d.Recipient == recipient && d.DigestKey == digestKey)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static DigestEntity MapDigest(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
ChannelId = reader.GetGuid(2),
Recipient = reader.GetString(3),
DigestKey = reader.GetString(4),
EventCount = reader.GetInt32(5),
Events = reader.GetString(6),
Status = reader.GetString(7),
CollectUntil = reader.GetFieldValue<DateTimeOffset>(8),
SentAt = GetNullableDateTimeOffset(reader, 9),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(11)
};
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,252 +1,156 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
public sealed class EscalationPolicyRepository : RepositoryBase<NotifyDataSource>, IEscalationPolicyRepository
public sealed class EscalationPolicyRepository : IEscalationPolicyRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<EscalationPolicyRepository> _logger;
public EscalationPolicyRepository(NotifyDataSource dataSource, ILogger<EscalationPolicyRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<EscalationPolicyEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, steps, repeat_count, metadata, created_at, updated_at
FROM notify.escalation_policies WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapPolicy, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.EscalationPolicies.AsNoTracking().FirstOrDefaultAsync(p => p.TenantId == tenantId && p.Id == id, cancellationToken).ConfigureAwait(false);
}
public async Task<EscalationPolicyEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, steps, repeat_count, metadata, created_at, updated_at
FROM notify.escalation_policies WHERE tenant_id = @tenant_id AND name = @name
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "name", name); },
MapPolicy, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.EscalationPolicies.AsNoTracking().FirstOrDefaultAsync(p => p.TenantId == tenantId && p.Name == name, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<EscalationPolicyEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, steps, repeat_count, metadata, created_at, updated_at
FROM notify.escalation_policies WHERE tenant_id = @tenant_id ORDER BY name
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapPolicy, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.EscalationPolicies.AsNoTracking().Where(p => p.TenantId == tenantId).OrderBy(p => p.Name).ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<EscalationPolicyEntity> CreateAsync(EscalationPolicyEntity policy, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.escalation_policies (id, tenant_id, name, description, enabled, steps, repeat_count, metadata)
VALUES (@id, @tenant_id, @name, @description, @enabled, @steps::jsonb, @repeat_count, @metadata::jsonb)
RETURNING *
""";
var id = policy.Id == Guid.Empty ? Guid.NewGuid() : policy.Id;
await using var connection = await DataSource.OpenConnectionAsync(policy.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", policy.TenantId);
AddParameter(command, "name", policy.Name);
AddParameter(command, "description", policy.Description);
AddParameter(command, "enabled", policy.Enabled);
AddJsonbParameter(command, "steps", policy.Steps);
AddParameter(command, "repeat_count", policy.RepeatCount);
AddJsonbParameter(command, "metadata", policy.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapPolicy(reader);
await using var connection = await _dataSource.OpenConnectionAsync(policy.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
dbContext.EscalationPolicies.Add(policy);
if (policy.Id == Guid.Empty)
dbContext.Entry(policy).Property(e => e.Id).CurrentValue = Guid.NewGuid();
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return policy;
}
public async Task<bool> UpdateAsync(EscalationPolicyEntity policy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.escalation_policies SET name = @name, description = @description, enabled = @enabled,
steps = @steps::jsonb, repeat_count = @repeat_count, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(policy.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", policy.TenantId);
AddParameter(cmd, "id", policy.Id);
AddParameter(cmd, "name", policy.Name);
AddParameter(cmd, "description", policy.Description);
AddParameter(cmd, "enabled", policy.Enabled);
AddJsonbParameter(cmd, "steps", policy.Steps);
AddParameter(cmd, "repeat_count", policy.RepeatCount);
AddJsonbParameter(cmd, "metadata", policy.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(policy.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var existing = await dbContext.EscalationPolicies.FirstOrDefaultAsync(p => p.TenantId == policy.TenantId && p.Id == policy.Id, cancellationToken).ConfigureAwait(false);
if (existing is null) return false;
dbContext.Entry(existing).CurrentValues.SetValues(policy);
return await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false) > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.escalation_policies WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.EscalationPolicies.Where(p => p.TenantId == tenantId && p.Id == id).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false) > 0;
}
private static EscalationPolicyEntity MapPolicy(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
Description = GetNullableString(reader, 3),
Enabled = reader.GetBoolean(4),
Steps = reader.GetString(5),
RepeatCount = reader.GetInt32(6),
Metadata = reader.GetString(7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(9)
};
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}
public sealed class EscalationStateRepository : RepositoryBase<NotifyDataSource>, IEscalationStateRepository
public sealed class EscalationStateRepository : IEscalationStateRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<EscalationStateRepository> _logger;
public EscalationStateRepository(NotifyDataSource dataSource, ILogger<EscalationStateRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<EscalationStateEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, policy_id, incident_id, correlation_id, current_step, repeat_iteration, status,
started_at, next_escalation_at, acknowledged_at, acknowledged_by, resolved_at, resolved_by, metadata
FROM notify.escalation_states WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapState, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.EscalationStates.AsNoTracking().FirstOrDefaultAsync(s => s.TenantId == tenantId && s.Id == id, cancellationToken).ConfigureAwait(false);
}
public async Task<EscalationStateEntity?> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, policy_id, incident_id, correlation_id, current_step, repeat_iteration, status,
started_at, next_escalation_at, acknowledged_at, acknowledged_by, resolved_at, resolved_by, metadata
FROM notify.escalation_states WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id AND status = 'active'
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "correlation_id", correlationId); },
MapState, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.EscalationStates.AsNoTracking()
.FirstOrDefaultAsync(s => s.TenantId == tenantId && s.CorrelationId == correlationId && s.Status == EscalationStatus.Active, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<EscalationStateEntity>> GetActiveAsync(int limit = 100, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, policy_id, incident_id, correlation_id, current_step, repeat_iteration, status,
started_at, next_escalation_at, acknowledged_at, acknowledged_by, resolved_at, resolved_by, metadata
FROM notify.escalation_states WHERE status = 'active' AND next_escalation_at <= NOW()
ORDER BY next_escalation_at LIMIT @limit
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "limit", limit);
var results = new List<EscalationStateEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
results.Add(MapState(reader));
return results;
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = DateTimeOffset.UtcNow;
return await dbContext.EscalationStates.AsNoTracking()
.Where(s => s.Status == EscalationStatus.Active && s.NextEscalationAt <= now)
.OrderBy(s => s.NextEscalationAt)
.Take(limit)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<EscalationStateEntity> CreateAsync(EscalationStateEntity state, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.escalation_states (id, tenant_id, policy_id, incident_id, correlation_id, current_step, repeat_iteration, status, next_escalation_at, metadata)
VALUES (@id, @tenant_id, @policy_id, @incident_id, @correlation_id, @current_step, @repeat_iteration, @status, @next_escalation_at, @metadata::jsonb)
RETURNING *
""";
var id = state.Id == Guid.Empty ? Guid.NewGuid() : state.Id;
await using var connection = await DataSource.OpenConnectionAsync(state.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", state.TenantId);
AddParameter(command, "policy_id", state.PolicyId);
AddParameter(command, "incident_id", state.IncidentId);
AddParameter(command, "correlation_id", state.CorrelationId);
AddParameter(command, "current_step", state.CurrentStep);
AddParameter(command, "repeat_iteration", state.RepeatIteration);
AddParameter(command, "status", state.Status);
AddParameter(command, "next_escalation_at", state.NextEscalationAt);
AddJsonbParameter(command, "metadata", state.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapState(reader);
await using var connection = await _dataSource.OpenConnectionAsync(state.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
dbContext.EscalationStates.Add(state);
if (state.Id == Guid.Empty)
dbContext.Entry(state).Property(e => e.Id).CurrentValue = Guid.NewGuid();
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return state;
}
public async Task<bool> EscalateAsync(string tenantId, Guid id, int newStep, DateTimeOffset? nextEscalationAt, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.escalation_states SET current_step = @new_step, next_escalation_at = @next_escalation_at
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
""";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "new_step", newStep);
AddParameter(cmd, "next_escalation_at", nextEscalationAt);
}, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.escalation_states SET current_step = {0}, next_escalation_at = {1} WHERE tenant_id = {2} AND id = {3} AND status = 'active'",
new object[] { newStep, (object?)nextEscalationAt ?? DBNull.Value, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> AcknowledgeAsync(string tenantId, Guid id, string acknowledgedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.escalation_states SET status = 'acknowledged', acknowledged_at = NOW(), acknowledged_by = @acknowledged_by
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
""";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "acknowledged_by", acknowledgedBy);
}, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.escalation_states SET status = 'acknowledged', acknowledged_at = NOW(), acknowledged_by = {0} WHERE tenant_id = {1} AND id = {2} AND status = 'active'",
new object[] { acknowledgedBy, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> ResolveAsync(string tenantId, Guid id, string resolvedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.escalation_states SET status = 'resolved', resolved_at = NOW(), resolved_by = @resolved_by
WHERE tenant_id = @tenant_id AND id = @id AND status IN ('active', 'acknowledged')
""";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "resolved_by", resolvedBy);
}, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.escalation_states SET status = 'resolved', resolved_at = NOW(), resolved_by = {0} WHERE tenant_id = {1} AND id = {2} AND status IN ('active', 'acknowledged')",
new object[] { resolvedBy, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static EscalationStateEntity MapState(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
PolicyId = reader.GetGuid(2),
IncidentId = GetNullableGuid(reader, 3),
CorrelationId = reader.GetString(4),
CurrentStep = reader.GetInt32(5),
RepeatIteration = reader.GetInt32(6),
Status = reader.GetString(7),
StartedAt = reader.GetFieldValue<DateTimeOffset>(8),
NextEscalationAt = GetNullableDateTimeOffset(reader, 9),
AcknowledgedAt = GetNullableDateTimeOffset(reader, 10),
AcknowledgedBy = GetNullableString(reader, 11),
ResolvedAt = GetNullableDateTimeOffset(reader, 12),
ResolvedBy = GetNullableString(reader, 13),
Metadata = reader.GetString(14)
};
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,139 +1,114 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
public sealed class InboxRepository : RepositoryBase<NotifyDataSource>, IInboxRepository
public sealed class InboxRepository : IInboxRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<InboxRepository> _logger;
public InboxRepository(NotifyDataSource dataSource, ILogger<InboxRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<InboxEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, title, body, event_type, event_payload, read, archived, action_url, correlation_id, created_at, read_at, archived_at
FROM notify.inbox WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapInbox, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Inbox.AsNoTracking()
.FirstOrDefaultAsync(i => i.TenantId == tenantId && i.Id == id, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<InboxEntity>> GetForUserAsync(string tenantId, Guid userId, bool unreadOnly = false, int limit = 50, int offset = 0, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, user_id, title, body, event_type, event_payload, read, archived, action_url, correlation_id, created_at, read_at, archived_at
FROM notify.inbox WHERE tenant_id = @tenant_id AND user_id = @user_id AND archived = FALSE
""";
if (unreadOnly) sql += " AND read = FALSE";
sql += " ORDER BY created_at DESC LIMIT @limit OFFSET @offset";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "user_id", userId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapInbox, cancellationToken).ConfigureAwait(false);
IQueryable<InboxEntity> query = dbContext.Inbox.AsNoTracking()
.Where(i => i.TenantId == tenantId && i.UserId == userId && !i.Archived);
if (unreadOnly)
query = query.Where(i => !i.Read);
return await query
.OrderByDescending(i => i.CreatedAt)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<int> GetUnreadCountAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = "SELECT COUNT(*) FROM notify.inbox WHERE tenant_id = @tenant_id AND user_id = @user_id AND read = FALSE AND archived = FALSE";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "user_id", userId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Inbox.AsNoTracking()
.CountAsync(i => i.TenantId == tenantId && i.UserId == userId && !i.Read && !i.Archived, cancellationToken).ConfigureAwait(false);
}
public async Task<InboxEntity> CreateAsync(InboxEntity inbox, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.inbox (id, tenant_id, user_id, title, body, event_type, event_payload, action_url, correlation_id)
VALUES (@id, @tenant_id, @user_id, @title, @body, @event_type, @event_payload::jsonb, @action_url, @correlation_id)
RETURNING *
""";
var id = inbox.Id == Guid.Empty ? Guid.NewGuid() : inbox.Id;
await using var connection = await DataSource.OpenConnectionAsync(inbox.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", inbox.TenantId);
AddParameter(command, "user_id", inbox.UserId);
AddParameter(command, "title", inbox.Title);
AddParameter(command, "body", inbox.Body);
AddParameter(command, "event_type", inbox.EventType);
AddJsonbParameter(command, "event_payload", inbox.EventPayload);
AddParameter(command, "action_url", inbox.ActionUrl);
AddParameter(command, "correlation_id", inbox.CorrelationId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapInbox(reader);
await using var connection = await _dataSource.OpenConnectionAsync(inbox.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
dbContext.Inbox.Add(inbox);
if (inbox.Id == Guid.Empty)
dbContext.Entry(inbox).Property(e => e.Id).CurrentValue = Guid.NewGuid();
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return inbox;
}
public async Task<bool> MarkReadAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.inbox SET read = TRUE, read_at = NOW() WHERE tenant_id = @tenant_id AND id = @id AND read = FALSE";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.inbox SET read = TRUE, read_at = NOW() WHERE tenant_id = {0} AND id = {1} AND read = FALSE",
new object[] { tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<int> MarkAllReadAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.inbox SET read = TRUE, read_at = NOW() WHERE tenant_id = @tenant_id AND user_id = @user_id AND read = FALSE";
return await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.inbox SET read = TRUE, read_at = NOW() WHERE tenant_id = {0} AND user_id = {1} AND read = FALSE",
new object[] { tenantId, userId },
cancellationToken).ConfigureAwait(false);
}
public async Task<bool> ArchiveAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.inbox SET archived = TRUE, archived_at = NOW() WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.inbox SET archived = TRUE, archived_at = NOW() WHERE tenant_id = {0} AND id = {1}",
new object[] { tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.inbox WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Inbox.Where(i => i.TenantId == tenantId && i.Id == id)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false) > 0;
}
public async Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.inbox WHERE archived = TRUE AND archived_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Inbox.Where(i => i.Archived && i.ArchivedAt < cutoff)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
private static InboxEntity MapInbox(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
UserId = reader.GetGuid(2),
Title = reader.GetString(3),
Body = GetNullableString(reader, 4),
EventType = reader.GetString(5),
EventPayload = reader.GetString(6),
Read = reader.GetBoolean(7),
Archived = reader.GetBoolean(8),
ActionUrl = GetNullableString(reader, 9),
CorrelationId = GetNullableString(reader, 10),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
ReadAt = GetNullableDateTimeOffset(reader, 12),
ArchivedAt = GetNullableDateTimeOffset(reader, 13)
};
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,167 +1,124 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
public sealed class IncidentRepository : RepositoryBase<NotifyDataSource>, IIncidentRepository
public sealed class IncidentRepository : IIncidentRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<IncidentRepository> _logger;
public IncidentRepository(NotifyDataSource dataSource, ILogger<IncidentRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<IncidentEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, title, description, severity, status, source, correlation_id, assigned_to, escalation_policy_id,
metadata, created_at, acknowledged_at, resolved_at, closed_at, created_by
FROM notify.incidents WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapIncident, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Incidents.AsNoTracking()
.FirstOrDefaultAsync(i => i.TenantId == tenantId && i.Id == id, cancellationToken).ConfigureAwait(false);
}
public async Task<IncidentEntity?> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, title, description, severity, status, source, correlation_id, assigned_to, escalation_policy_id,
metadata, created_at, acknowledged_at, resolved_at, closed_at, created_by
FROM notify.incidents WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "correlation_id", correlationId); },
MapIncident, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Incidents.AsNoTracking()
.FirstOrDefaultAsync(i => i.TenantId == tenantId && i.CorrelationId == correlationId, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<IncidentEntity>> ListAsync(string tenantId, string? status = null, string? severity = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, title, description, severity, status, source, correlation_id, assigned_to, escalation_policy_id,
metadata, created_at, acknowledged_at, resolved_at, closed_at, created_by
FROM notify.incidents WHERE tenant_id = @tenant_id
""";
if (status != null) sql += " AND status = @status";
if (severity != null) sql += " AND severity = @severity";
sql += " ORDER BY created_at DESC LIMIT @limit OFFSET @offset";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (status != null) AddParameter(cmd, "status", status);
if (severity != null) AddParameter(cmd, "severity", severity);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapIncident, cancellationToken).ConfigureAwait(false);
IQueryable<IncidentEntity> query = dbContext.Incidents.AsNoTracking()
.Where(i => i.TenantId == tenantId);
if (status != null)
query = query.Where(i => i.Status == status);
if (severity != null)
query = query.Where(i => i.Severity == severity);
return await query
.OrderByDescending(i => i.CreatedAt)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IncidentEntity> CreateAsync(IncidentEntity incident, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.incidents (id, tenant_id, title, description, severity, status, source, correlation_id, assigned_to, escalation_policy_id, metadata, created_by)
VALUES (@id, @tenant_id, @title, @description, @severity, @status, @source, @correlation_id, @assigned_to, @escalation_policy_id, @metadata::jsonb, @created_by)
RETURNING *
""";
var id = incident.Id == Guid.Empty ? Guid.NewGuid() : incident.Id;
await using var connection = await DataSource.OpenConnectionAsync(incident.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", incident.TenantId);
AddParameter(command, "title", incident.Title);
AddParameter(command, "description", incident.Description);
AddParameter(command, "severity", incident.Severity);
AddParameter(command, "status", incident.Status);
AddParameter(command, "source", incident.Source);
AddParameter(command, "correlation_id", incident.CorrelationId);
AddParameter(command, "assigned_to", incident.AssignedTo);
AddParameter(command, "escalation_policy_id", incident.EscalationPolicyId);
AddJsonbParameter(command, "metadata", incident.Metadata);
AddParameter(command, "created_by", incident.CreatedBy);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapIncident(reader);
await using var connection = await _dataSource.OpenConnectionAsync(incident.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
dbContext.Incidents.Add(incident);
if (incident.Id == Guid.Empty)
dbContext.Entry(incident).Property(e => e.Id).CurrentValue = Guid.NewGuid();
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return incident;
}
public async Task<bool> UpdateAsync(IncidentEntity incident, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.incidents SET title = @title, description = @description, severity = @severity, status = @status,
source = @source, assigned_to = @assigned_to, escalation_policy_id = @escalation_policy_id, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(incident.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", incident.TenantId);
AddParameter(cmd, "id", incident.Id);
AddParameter(cmd, "title", incident.Title);
AddParameter(cmd, "description", incident.Description);
AddParameter(cmd, "severity", incident.Severity);
AddParameter(cmd, "status", incident.Status);
AddParameter(cmd, "source", incident.Source);
AddParameter(cmd, "assigned_to", incident.AssignedTo);
AddParameter(cmd, "escalation_policy_id", incident.EscalationPolicyId);
AddJsonbParameter(cmd, "metadata", incident.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(incident.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var existing = await dbContext.Incidents
.FirstOrDefaultAsync(i => i.TenantId == incident.TenantId && i.Id == incident.Id, cancellationToken).ConfigureAwait(false);
if (existing is null) return false;
dbContext.Entry(existing).CurrentValues.SetValues(incident);
return await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false) > 0;
}
public async Task<bool> AcknowledgeAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.incidents SET status = 'acknowledged', acknowledged_at = NOW() WHERE tenant_id = @tenant_id AND id = @id AND status = 'open'";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.incidents SET status = 'acknowledged', acknowledged_at = NOW() WHERE tenant_id = {0} AND id = {1} AND status = 'open'",
new object[] { tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> ResolveAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.incidents SET status = 'resolved', resolved_at = NOW() WHERE tenant_id = @tenant_id AND id = @id AND status IN ('open', 'acknowledged')";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.incidents SET status = 'resolved', resolved_at = NOW() WHERE tenant_id = {0} AND id = {1} AND status IN ('open', 'acknowledged')",
new object[] { tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> CloseAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.incidents SET status = 'closed', closed_at = NOW() WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.incidents SET status = 'closed', closed_at = NOW() WHERE tenant_id = {0} AND id = {1}",
new object[] { tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> AssignAsync(string tenantId, Guid id, Guid assignedTo, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.incidents SET assigned_to = @assigned_to WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "assigned_to", assignedTo);
}, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.incidents SET assigned_to = {0} WHERE tenant_id = {1} AND id = {2}",
new object[] { assignedTo, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static IncidentEntity MapIncident(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Title = reader.GetString(2),
Description = GetNullableString(reader, 3),
Severity = reader.GetString(4),
Status = reader.GetString(5),
Source = GetNullableString(reader, 6),
CorrelationId = GetNullableString(reader, 7),
AssignedTo = GetNullableGuid(reader, 8),
EscalationPolicyId = GetNullableGuid(reader, 9),
Metadata = reader.GetString(10),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
AcknowledgedAt = GetNullableDateTimeOffset(reader, 12),
ResolvedAt = GetNullableDateTimeOffset(reader, 13),
ClosedAt = GetNullableDateTimeOffset(reader, 14),
CreatedBy = GetNullableString(reader, 15)
};
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,216 +1,98 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="ILocalizationBundleRepository"/>.
/// EF Core implementation of <see cref="ILocalizationBundleRepository"/>.
/// </summary>
public sealed class LocalizationBundleRepository : RepositoryBase<NotifyDataSource>, ILocalizationBundleRepository
public sealed class LocalizationBundleRepository : ILocalizationBundleRepository
{
private bool _tableInitialized;
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<LocalizationBundleRepository> _logger;
public LocalizationBundleRepository(NotifyDataSource dataSource, ILogger<LocalizationBundleRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<LocalizationBundleEntity?> GetByIdAsync(string tenantId, string bundleId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT bundle_id, tenant_id, locale, bundle_key, strings, is_default, parent_locale,
description, metadata, created_by, created_at, updated_by, updated_at
FROM notify.localization_bundles WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "bundle_id", bundleId); },
MapLocalizationBundle, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.LocalizationBundles.AsNoTracking()
.FirstOrDefaultAsync(b => b.TenantId == tenantId && b.BundleId == bundleId, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<LocalizationBundleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT bundle_id, tenant_id, locale, bundle_key, strings, is_default, parent_locale,
description, metadata, created_by, created_at, updated_by, updated_at
FROM notify.localization_bundles WHERE tenant_id = @tenant_id ORDER BY bundle_key, locale
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapLocalizationBundle, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.LocalizationBundles.AsNoTracking()
.Where(b => b.TenantId == tenantId)
.OrderBy(b => b.BundleKey).ThenBy(b => b.Locale)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<LocalizationBundleEntity>> GetByBundleKeyAsync(string tenantId, string bundleKey, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT bundle_id, tenant_id, locale, bundle_key, strings, is_default, parent_locale,
description, metadata, created_by, created_at, updated_by, updated_at
FROM notify.localization_bundles WHERE tenant_id = @tenant_id AND bundle_key = @bundle_key ORDER BY locale
""";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "bundle_key", bundleKey); },
MapLocalizationBundle, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.LocalizationBundles.AsNoTracking()
.Where(b => b.TenantId == tenantId && b.BundleKey == bundleKey)
.OrderBy(b => b.Locale)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<LocalizationBundleEntity?> GetByKeyAndLocaleAsync(string tenantId, string bundleKey, string locale, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT bundle_id, tenant_id, locale, bundle_key, strings, is_default, parent_locale,
description, metadata, created_by, created_at, updated_by, updated_at
FROM notify.localization_bundles
WHERE tenant_id = @tenant_id AND bundle_key = @bundle_key AND LOWER(locale) = LOWER(@locale)
LIMIT 1
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "bundle_key", bundleKey);
AddParameter(cmd, "locale", locale);
},
MapLocalizationBundle, cancellationToken).ConfigureAwait(false);
// Case-insensitive locale match via EF.Functions.ILike or ToLower
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.LocalizationBundles.AsNoTracking()
.FirstOrDefaultAsync(b => b.TenantId == tenantId && b.BundleKey == bundleKey && b.Locale.ToLower() == locale.ToLower(), cancellationToken).ConfigureAwait(false);
}
public async Task<LocalizationBundleEntity?> GetDefaultByKeyAsync(string tenantId, string bundleKey, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT bundle_id, tenant_id, locale, bundle_key, strings, is_default, parent_locale,
description, metadata, created_by, created_at, updated_by, updated_at
FROM notify.localization_bundles
WHERE tenant_id = @tenant_id AND bundle_key = @bundle_key AND is_default = TRUE
LIMIT 1
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "bundle_key", bundleKey); },
MapLocalizationBundle, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.LocalizationBundles.AsNoTracking()
.FirstOrDefaultAsync(b => b.TenantId == tenantId && b.BundleKey == bundleKey && b.IsDefault, cancellationToken).ConfigureAwait(false);
}
public async Task<LocalizationBundleEntity> CreateAsync(LocalizationBundleEntity bundle, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(bundle.TenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO notify.localization_bundles (bundle_id, tenant_id, locale, bundle_key, strings, is_default,
parent_locale, description, metadata, created_by, updated_by)
VALUES (@bundle_id, @tenant_id, @locale, @bundle_key, @strings, @is_default,
@parent_locale, @description, @metadata, @created_by, @updated_by)
RETURNING bundle_id, tenant_id, locale, bundle_key, strings, is_default, parent_locale,
description, metadata, created_by, created_at, updated_by, updated_at
""";
await using var connection = await DataSource.OpenConnectionAsync(bundle.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "bundle_id", bundle.BundleId);
AddParameter(command, "tenant_id", bundle.TenantId);
AddParameter(command, "locale", bundle.Locale);
AddParameter(command, "bundle_key", bundle.BundleKey);
AddJsonbParameter(command, "strings", bundle.Strings);
AddParameter(command, "is_default", bundle.IsDefault);
AddParameter(command, "parent_locale", (object?)bundle.ParentLocale ?? DBNull.Value);
AddParameter(command, "description", (object?)bundle.Description ?? DBNull.Value);
AddJsonbParameter(command, "metadata", bundle.Metadata);
AddParameter(command, "created_by", (object?)bundle.CreatedBy ?? DBNull.Value);
AddParameter(command, "updated_by", (object?)bundle.UpdatedBy ?? DBNull.Value);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapLocalizationBundle(reader);
await using var connection = await _dataSource.OpenConnectionAsync(bundle.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
dbContext.LocalizationBundles.Add(bundle);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return bundle;
}
public async Task<bool> UpdateAsync(LocalizationBundleEntity bundle, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(bundle.TenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
UPDATE notify.localization_bundles
SET locale = @locale, bundle_key = @bundle_key, strings = @strings, is_default = @is_default,
parent_locale = @parent_locale, description = @description, metadata = @metadata, updated_by = @updated_by
WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id
""";
var rows = await ExecuteAsync(bundle.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", bundle.TenantId);
AddParameter(cmd, "bundle_id", bundle.BundleId);
AddParameter(cmd, "locale", bundle.Locale);
AddParameter(cmd, "bundle_key", bundle.BundleKey);
AddJsonbParameter(cmd, "strings", bundle.Strings);
AddParameter(cmd, "is_default", bundle.IsDefault);
AddParameter(cmd, "parent_locale", (object?)bundle.ParentLocale ?? DBNull.Value);
AddParameter(cmd, "description", (object?)bundle.Description ?? DBNull.Value);
AddJsonbParameter(cmd, "metadata", bundle.Metadata);
AddParameter(cmd, "updated_by", (object?)bundle.UpdatedBy ?? DBNull.Value);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(bundle.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var existing = await dbContext.LocalizationBundles
.FirstOrDefaultAsync(b => b.TenantId == bundle.TenantId && b.BundleId == bundle.BundleId, cancellationToken).ConfigureAwait(false);
if (existing is null) return false;
dbContext.Entry(existing).CurrentValues.SetValues(bundle);
return await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false) > 0;
}
public async Task<bool> DeleteAsync(string tenantId, string bundleId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = "DELETE FROM notify.localization_bundles WHERE tenant_id = @tenant_id AND bundle_id = @bundle_id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "bundle_id", bundleId); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.LocalizationBundles
.Where(b => b.TenantId == tenantId && b.BundleId == bundleId)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false) > 0;
}
private static LocalizationBundleEntity MapLocalizationBundle(NpgsqlDataReader reader) => new()
{
BundleId = reader.GetString(0),
TenantId = reader.GetString(1),
Locale = reader.GetString(2),
BundleKey = reader.GetString(3),
Strings = reader.GetString(4),
IsDefault = reader.GetBoolean(5),
ParentLocale = GetNullableString(reader, 6),
Description = GetNullableString(reader, 7),
Metadata = GetNullableString(reader, 8),
CreatedBy = GetNullableString(reader, 9),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10),
UpdatedBy = GetNullableString(reader, 11),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(12)
};
private async Task EnsureTableAsync(string tenantId, CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = """
CREATE TABLE IF NOT EXISTS notify.localization_bundles (
bundle_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
locale TEXT NOT NULL,
bundle_key TEXT NOT NULL,
strings JSONB NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
parent_locale TEXT,
description TEXT,
metadata JSONB,
created_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, bundle_id)
);
CREATE INDEX IF NOT EXISTS idx_localization_bundles_key ON notify.localization_bundles (tenant_id, bundle_key);
CREATE UNIQUE INDEX IF NOT EXISTS idx_localization_bundles_key_locale ON notify.localization_bundles (tenant_id, bundle_key, locale);
CREATE INDEX IF NOT EXISTS idx_localization_bundles_default ON notify.localization_bundles (tenant_id, bundle_key, is_default) WHERE is_default = TRUE;
""";
await ExecuteAsync(tenantId, ddl, _ => { }, cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,54 +1,56 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
public sealed class LockRepository : RepositoryBase<NotifyDataSource>, ILockRepository
public sealed class LockRepository : ILockRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<LockRepository> _logger;
public LockRepository(NotifyDataSource dataSource, ILogger<LockRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
{
const string sql = """
// CTE-based conditional UPSERT with WHERE clause on conflict requires raw SQL.
// This pattern cannot be expressed via EF Core LINQ (ON CONFLICT ... DO UPDATE ... WHERE).
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"""
WITH upsert AS (
INSERT INTO notify.locks (id, tenant_id, resource, owner, expires_at)
VALUES (gen_random_uuid(), @tenant_id, @resource, @owner, NOW() + @ttl)
VALUES (gen_random_uuid(), {0}, {1}, {2}, NOW() + {3})
ON CONFLICT (tenant_id, resource) DO UPDATE SET
owner = EXCLUDED.owner,
expires_at = EXCLUDED.expires_at
WHERE notify.locks.expires_at < NOW() OR notify.locks.owner = EXCLUDED.owner
RETURNING 1
)
SELECT EXISTS(SELECT 1 FROM upsert) AS acquired;
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "resource", resource);
AddParameter(command, "owner", owner);
AddParameter(command, "ttl", ttl);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is bool acquired && acquired;
}
public async Task<bool> ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.locks WHERE tenant_id = @tenant_id AND resource = @resource AND owner = @owner";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "resource", resource);
AddParameter(cmd, "owner", owner);
},
SELECT COUNT(*) FROM upsert
""",
new object[] { tenantId, resource, owner, ttl },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Locks
.Where(l => l.TenantId == tenantId && l.Resource == resource && l.Owner == owner)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false) > 0;
}
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,123 +1,89 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
public sealed class MaintenanceWindowRepository : RepositoryBase<NotifyDataSource>, IMaintenanceWindowRepository
public sealed class MaintenanceWindowRepository : IMaintenanceWindowRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<MaintenanceWindowRepository> _logger;
public MaintenanceWindowRepository(NotifyDataSource dataSource, ILogger<MaintenanceWindowRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<MaintenanceWindowEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, start_at, end_at, suppress_channels, suppress_event_types, created_at, created_by
FROM notify.maintenance_windows WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapWindow, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.MaintenanceWindows.AsNoTracking()
.FirstOrDefaultAsync(w => w.TenantId == tenantId && w.Id == id, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<MaintenanceWindowEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, start_at, end_at, suppress_channels, suppress_event_types, created_at, created_by
FROM notify.maintenance_windows WHERE tenant_id = @tenant_id ORDER BY start_at DESC
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapWindow, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.MaintenanceWindows.AsNoTracking()
.Where(w => w.TenantId == tenantId)
.OrderByDescending(w => w.StartAt)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<MaintenanceWindowEntity>> GetActiveAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, start_at, end_at, suppress_channels, suppress_event_types, created_at, created_by
FROM notify.maintenance_windows WHERE tenant_id = @tenant_id AND start_at <= NOW() AND end_at > NOW()
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapWindow, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = DateTimeOffset.UtcNow;
return await dbContext.MaintenanceWindows.AsNoTracking()
.Where(w => w.TenantId == tenantId && w.StartAt <= now && w.EndAt > now)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<MaintenanceWindowEntity> CreateAsync(MaintenanceWindowEntity window, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.maintenance_windows (id, tenant_id, name, description, start_at, end_at, suppress_channels, suppress_event_types, created_by)
VALUES (@id, @tenant_id, @name, @description, @start_at, @end_at, @suppress_channels, @suppress_event_types, @created_by)
RETURNING *
""";
var id = window.Id == Guid.Empty ? Guid.NewGuid() : window.Id;
await using var connection = await DataSource.OpenConnectionAsync(window.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", window.TenantId);
AddParameter(command, "name", window.Name);
AddParameter(command, "description", window.Description);
AddParameter(command, "start_at", window.StartAt);
AddParameter(command, "end_at", window.EndAt);
AddParameter(command, "suppress_channels", window.SuppressChannels);
AddTextArrayParameter(command, "suppress_event_types", window.SuppressEventTypes ?? []);
AddParameter(command, "created_by", window.CreatedBy);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapWindow(reader);
await using var connection = await _dataSource.OpenConnectionAsync(window.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
dbContext.MaintenanceWindows.Add(window);
if (window.Id == Guid.Empty)
dbContext.Entry(window).Property(e => e.Id).CurrentValue = Guid.NewGuid();
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return window;
}
public async Task<bool> UpdateAsync(MaintenanceWindowEntity window, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.maintenance_windows SET name = @name, description = @description, start_at = @start_at, end_at = @end_at,
suppress_channels = @suppress_channels, suppress_event_types = @suppress_event_types
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(window.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", window.TenantId);
AddParameter(cmd, "id", window.Id);
AddParameter(cmd, "name", window.Name);
AddParameter(cmd, "description", window.Description);
AddParameter(cmd, "start_at", window.StartAt);
AddParameter(cmd, "end_at", window.EndAt);
AddParameter(cmd, "suppress_channels", window.SuppressChannels);
AddTextArrayParameter(cmd, "suppress_event_types", window.SuppressEventTypes ?? []);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(window.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var existing = await dbContext.MaintenanceWindows
.FirstOrDefaultAsync(w => w.TenantId == window.TenantId && w.Id == window.Id, cancellationToken).ConfigureAwait(false);
if (existing is null) return false;
dbContext.Entry(existing).CurrentValues.SetValues(window);
return await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false) > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.maintenance_windows WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.MaintenanceWindows
.Where(w => w.TenantId == tenantId && w.Id == id)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false) > 0;
}
public async Task<int> DeleteExpiredAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.maintenance_windows WHERE end_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.MaintenanceWindows
.Where(w => w.EndAt < cutoff)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
private static MaintenanceWindowEntity MapWindow(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
Description = GetNullableString(reader, 3),
StartAt = reader.GetFieldValue<DateTimeOffset>(4),
EndAt = reader.GetFieldValue<DateTimeOffset>(5),
SuppressChannels = reader.IsDBNull(6) ? null : reader.GetFieldValue<Guid[]>(6),
SuppressEventTypes = reader.IsDBNull(7) ? null : reader.GetFieldValue<string[]>(7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8),
CreatedBy = GetNullableString(reader, 9)
};
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,100 +1,78 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
public sealed class NotifyAuditRepository : RepositoryBase<NotifyDataSource>, INotifyAuditRepository
public sealed class NotifyAuditRepository : INotifyAuditRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<NotifyAuditRepository> _logger;
public NotifyAuditRepository(NotifyDataSource dataSource, ILogger<NotifyAuditRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<long> CreateAsync(NotifyAuditEntity audit, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.audit (tenant_id, user_id, action, resource_type, resource_id, details, correlation_id)
VALUES (@tenant_id, @user_id, @action, @resource_type, @resource_id, @details::jsonb, @correlation_id)
RETURNING id
""";
await using var connection = await DataSource.OpenConnectionAsync(audit.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", audit.TenantId);
AddParameter(command, "user_id", audit.UserId);
AddParameter(command, "action", audit.Action);
AddParameter(command, "resource_type", audit.ResourceType);
AddParameter(command, "resource_id", audit.ResourceId);
AddJsonbParameter(command, "details", audit.Details);
AddParameter(command, "correlation_id", audit.CorrelationId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return (long)result!;
await using var connection = await _dataSource.OpenConnectionAsync(audit.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
dbContext.Audit.Add(audit);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return audit.Id;
}
public async Task<IReadOnlyList<NotifyAuditEntity>> ListAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, details, correlation_id, created_at
FROM notify.audit WHERE tenant_id = @tenant_id
ORDER BY created_at DESC LIMIT @limit OFFSET @offset
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapAudit, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Audit.AsNoTracking()
.Where(a => a.TenantId == tenantId)
.OrderByDescending(a => a.CreatedAt)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<NotifyAuditEntity>> GetByResourceAsync(string tenantId, string resourceType, string? resourceId = null, int limit = 100, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, details, correlation_id, created_at
FROM notify.audit WHERE tenant_id = @tenant_id AND resource_type = @resource_type
""";
if (resourceId != null) sql += " AND resource_id = @resource_id";
sql += " ORDER BY created_at DESC LIMIT @limit";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "resource_type", resourceType);
if (resourceId != null) AddParameter(cmd, "resource_id", resourceId);
AddParameter(cmd, "limit", limit);
}, MapAudit, cancellationToken).ConfigureAwait(false);
IQueryable<NotifyAuditEntity> query = dbContext.Audit.AsNoTracking()
.Where(a => a.TenantId == tenantId && a.ResourceType == resourceType);
if (resourceId != null)
query = query.Where(a => a.ResourceId == resourceId);
return await query
.OrderByDescending(a => a.CreatedAt)
.Take(limit)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<NotifyAuditEntity>> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, details, correlation_id, created_at
FROM notify.audit WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id
ORDER BY created_at
""";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "correlation_id", correlationId); },
MapAudit, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Audit.AsNoTracking()
.Where(a => a.TenantId == tenantId && a.CorrelationId == correlationId)
.OrderBy(a => a.CreatedAt)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.audit WHERE created_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Audit
.Where(a => a.CreatedAt < cutoff)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
private static NotifyAuditEntity MapAudit(NpgsqlDataReader reader) => new()
{
Id = reader.GetInt64(0),
TenantId = reader.GetString(1),
UserId = GetNullableGuid(reader, 2),
Action = reader.GetString(3),
ResourceType = reader.GetString(4),
ResourceId = GetNullableString(reader, 5),
Details = GetNullableString(reader, 6),
CorrelationId = GetNullableString(reader, 7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8)
};
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,116 +1,78 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
public sealed class OnCallScheduleRepository : RepositoryBase<NotifyDataSource>, IOnCallScheduleRepository
public sealed class OnCallScheduleRepository : IOnCallScheduleRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<OnCallScheduleRepository> _logger;
public OnCallScheduleRepository(NotifyDataSource dataSource, ILogger<OnCallScheduleRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<OnCallScheduleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, timezone, rotation_type, participants, overrides, metadata, created_at, updated_at
FROM notify.on_call_schedules WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapSchedule, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.OnCallSchedules.AsNoTracking()
.FirstOrDefaultAsync(s => s.TenantId == tenantId && s.Id == id, cancellationToken).ConfigureAwait(false);
}
public async Task<OnCallScheduleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, timezone, rotation_type, participants, overrides, metadata, created_at, updated_at
FROM notify.on_call_schedules WHERE tenant_id = @tenant_id AND name = @name
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "name", name); },
MapSchedule, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.OnCallSchedules.AsNoTracking()
.FirstOrDefaultAsync(s => s.TenantId == tenantId && s.Name == name, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OnCallScheduleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, timezone, rotation_type, participants, overrides, metadata, created_at, updated_at
FROM notify.on_call_schedules WHERE tenant_id = @tenant_id ORDER BY name
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapSchedule, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.OnCallSchedules.AsNoTracking()
.Where(s => s.TenantId == tenantId)
.OrderBy(s => s.Name)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<OnCallScheduleEntity> CreateAsync(OnCallScheduleEntity schedule, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.on_call_schedules (id, tenant_id, name, description, timezone, rotation_type, participants, overrides, metadata)
VALUES (@id, @tenant_id, @name, @description, @timezone, @rotation_type, @participants::jsonb, @overrides::jsonb, @metadata::jsonb)
RETURNING *
""";
var id = schedule.Id == Guid.Empty ? Guid.NewGuid() : schedule.Id;
await using var connection = await DataSource.OpenConnectionAsync(schedule.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", schedule.TenantId);
AddParameter(command, "name", schedule.Name);
AddParameter(command, "description", schedule.Description);
AddParameter(command, "timezone", schedule.Timezone);
AddParameter(command, "rotation_type", schedule.RotationType);
AddJsonbParameter(command, "participants", schedule.Participants);
AddJsonbParameter(command, "overrides", schedule.Overrides);
AddJsonbParameter(command, "metadata", schedule.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapSchedule(reader);
await using var connection = await _dataSource.OpenConnectionAsync(schedule.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
dbContext.OnCallSchedules.Add(schedule);
if (schedule.Id == Guid.Empty)
dbContext.Entry(schedule).Property(e => e.Id).CurrentValue = Guid.NewGuid();
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return schedule;
}
public async Task<bool> UpdateAsync(OnCallScheduleEntity schedule, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.on_call_schedules SET name = @name, description = @description, timezone = @timezone,
rotation_type = @rotation_type, participants = @participants::jsonb, overrides = @overrides::jsonb, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(schedule.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", schedule.TenantId);
AddParameter(cmd, "id", schedule.Id);
AddParameter(cmd, "name", schedule.Name);
AddParameter(cmd, "description", schedule.Description);
AddParameter(cmd, "timezone", schedule.Timezone);
AddParameter(cmd, "rotation_type", schedule.RotationType);
AddJsonbParameter(cmd, "participants", schedule.Participants);
AddJsonbParameter(cmd, "overrides", schedule.Overrides);
AddJsonbParameter(cmd, "metadata", schedule.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(schedule.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var existing = await dbContext.OnCallSchedules
.FirstOrDefaultAsync(s => s.TenantId == schedule.TenantId && s.Id == schedule.Id, cancellationToken).ConfigureAwait(false);
if (existing is null) return false;
dbContext.Entry(existing).CurrentValues.SetValues(schedule);
return await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false) > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.on_call_schedules WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.OnCallSchedules
.Where(s => s.TenantId == tenantId && s.Id == id)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false) > 0;
}
private static OnCallScheduleEntity MapSchedule(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
Description = GetNullableString(reader, 3),
Timezone = reader.GetString(4),
RotationType = reader.GetString(5),
Participants = reader.GetString(6),
Overrides = reader.GetString(7),
Metadata = reader.GetString(8),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,160 +1,92 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IOperatorOverrideRepository"/>.
/// EF Core implementation of <see cref="IOperatorOverrideRepository"/>.
/// </summary>
public sealed class OperatorOverrideRepository : RepositoryBase<NotifyDataSource>, IOperatorOverrideRepository
public sealed class OperatorOverrideRepository : IOperatorOverrideRepository
{
private bool _tableInitialized;
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<OperatorOverrideRepository> _logger;
public OperatorOverrideRepository(NotifyDataSource dataSource, ILogger<OperatorOverrideRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<OperatorOverrideEntity?> GetByIdAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT override_id, tenant_id, override_type, expires_at, channel_id, rule_id, reason, created_by, created_at
FROM notify.operator_overrides WHERE tenant_id = @tenant_id AND override_id = @override_id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "override_id", overrideId); },
MapOperatorOverride, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.OperatorOverrides.AsNoTracking()
.FirstOrDefaultAsync(o => o.TenantId == tenantId && o.OverrideId == overrideId, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OperatorOverrideEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT override_id, tenant_id, override_type, expires_at, channel_id, rule_id, reason, created_by, created_at
FROM notify.operator_overrides WHERE tenant_id = @tenant_id ORDER BY created_at DESC
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapOperatorOverride, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.OperatorOverrides.AsNoTracking()
.Where(o => o.TenantId == tenantId)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OperatorOverrideEntity>> GetActiveAsync(string tenantId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT override_id, tenant_id, override_type, expires_at, channel_id, rule_id, reason, created_by, created_at
FROM notify.operator_overrides WHERE tenant_id = @tenant_id AND expires_at > NOW() ORDER BY created_at DESC
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapOperatorOverride, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = DateTimeOffset.UtcNow;
return await dbContext.OperatorOverrides.AsNoTracking()
.Where(o => o.TenantId == tenantId && o.ExpiresAt > now)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OperatorOverrideEntity>> GetActiveByTypeAsync(string tenantId, string overrideType, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT override_id, tenant_id, override_type, expires_at, channel_id, rule_id, reason, created_by, created_at
FROM notify.operator_overrides WHERE tenant_id = @tenant_id AND override_type = @override_type AND expires_at > NOW()
ORDER BY created_at DESC
""";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "override_type", overrideType); },
MapOperatorOverride, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = DateTimeOffset.UtcNow;
return await dbContext.OperatorOverrides.AsNoTracking()
.Where(o => o.TenantId == tenantId && o.OverrideType == overrideType && o.ExpiresAt > now)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<OperatorOverrideEntity> CreateAsync(OperatorOverrideEntity override_, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(override_.TenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO notify.operator_overrides (override_id, tenant_id, override_type, expires_at, channel_id, rule_id, reason, created_by)
VALUES (@override_id, @tenant_id, @override_type, @expires_at, @channel_id, @rule_id, @reason, @created_by)
RETURNING override_id, tenant_id, override_type, expires_at, channel_id, rule_id, reason, created_by, created_at
""";
await using var connection = await DataSource.OpenConnectionAsync(override_.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "override_id", override_.OverrideId);
AddParameter(command, "tenant_id", override_.TenantId);
AddParameter(command, "override_type", override_.OverrideType);
AddParameter(command, "expires_at", override_.ExpiresAt);
AddParameter(command, "channel_id", (object?)override_.ChannelId ?? DBNull.Value);
AddParameter(command, "rule_id", (object?)override_.RuleId ?? DBNull.Value);
AddParameter(command, "reason", (object?)override_.Reason ?? DBNull.Value);
AddParameter(command, "created_by", (object?)override_.CreatedBy ?? DBNull.Value);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapOperatorOverride(reader);
await using var connection = await _dataSource.OpenConnectionAsync(override_.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
dbContext.OperatorOverrides.Add(override_);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return override_;
}
public async Task<bool> DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = "DELETE FROM notify.operator_overrides WHERE tenant_id = @tenant_id AND override_id = @override_id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "override_id", overrideId); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.OperatorOverrides
.Where(o => o.TenantId == tenantId && o.OverrideId == overrideId)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false) > 0;
}
public async Task<int> DeleteExpiredAsync(string tenantId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = "DELETE FROM notify.operator_overrides WHERE tenant_id = @tenant_id AND expires_at <= NOW()";
return await ExecuteAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = DateTimeOffset.UtcNow;
return await dbContext.OperatorOverrides
.Where(o => o.TenantId == tenantId && o.ExpiresAt <= now)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
private static OperatorOverrideEntity MapOperatorOverride(NpgsqlDataReader reader) => new()
{
OverrideId = reader.GetString(0),
TenantId = reader.GetString(1),
OverrideType = reader.GetString(2),
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(3),
ChannelId = GetNullableString(reader, 4),
RuleId = GetNullableString(reader, 5),
Reason = GetNullableString(reader, 6),
CreatedBy = GetNullableString(reader, 7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8)
};
private async Task EnsureTableAsync(string tenantId, CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = """
CREATE TABLE IF NOT EXISTS notify.operator_overrides (
override_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
override_type TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
channel_id TEXT,
rule_id TEXT,
reason TEXT,
created_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, override_id)
);
CREATE INDEX IF NOT EXISTS idx_operator_overrides_type ON notify.operator_overrides (tenant_id, override_type);
CREATE INDEX IF NOT EXISTS idx_operator_overrides_expires ON notify.operator_overrides (tenant_id, expires_at);
CREATE INDEX IF NOT EXISTS idx_operator_overrides_active ON notify.operator_overrides (tenant_id, override_type, expires_at) WHERE expires_at > NOW();
""";
await ExecuteAsync(tenantId, ddl, _ => { }, cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,116 +1,79 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
public sealed class QuietHoursRepository : RepositoryBase<NotifyDataSource>, IQuietHoursRepository
public sealed class QuietHoursRepository : IQuietHoursRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<QuietHoursRepository> _logger;
public QuietHoursRepository(NotifyDataSource dataSource, ILogger<QuietHoursRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<QuietHoursEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, channel_id, start_time, end_time, timezone, days_of_week, enabled, created_at, updated_at
FROM notify.quiet_hours WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapQuietHours, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.QuietHours.AsNoTracking()
.FirstOrDefaultAsync(q => q.TenantId == tenantId && q.Id == id, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<QuietHoursEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, channel_id, start_time, end_time, timezone, days_of_week, enabled, created_at, updated_at
FROM notify.quiet_hours WHERE tenant_id = @tenant_id ORDER BY start_time
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapQuietHours, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.QuietHours.AsNoTracking()
.Where(q => q.TenantId == tenantId)
.OrderBy(q => q.StartTime)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<QuietHoursEntity>> GetForUserAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, channel_id, start_time, end_time, timezone, days_of_week, enabled, created_at, updated_at
FROM notify.quiet_hours WHERE tenant_id = @tenant_id AND (user_id IS NULL OR user_id = @user_id) AND enabled = TRUE
""";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
MapQuietHours, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.QuietHours.AsNoTracking()
.Where(q => q.TenantId == tenantId && (q.UserId == null || q.UserId == userId) && q.Enabled)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<QuietHoursEntity> CreateAsync(QuietHoursEntity quietHours, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.quiet_hours (id, tenant_id, user_id, channel_id, start_time, end_time, timezone, days_of_week, enabled)
VALUES (@id, @tenant_id, @user_id, @channel_id, @start_time, @end_time, @timezone, @days_of_week, @enabled)
RETURNING *
""";
var id = quietHours.Id == Guid.Empty ? Guid.NewGuid() : quietHours.Id;
await using var connection = await DataSource.OpenConnectionAsync(quietHours.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", quietHours.TenantId);
AddParameter(command, "user_id", quietHours.UserId);
AddParameter(command, "channel_id", quietHours.ChannelId);
AddParameter(command, "start_time", quietHours.StartTime);
AddParameter(command, "end_time", quietHours.EndTime);
AddParameter(command, "timezone", quietHours.Timezone);
AddParameter(command, "days_of_week", quietHours.DaysOfWeek);
AddParameter(command, "enabled", quietHours.Enabled);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapQuietHours(reader);
await using var connection = await _dataSource.OpenConnectionAsync(quietHours.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
dbContext.QuietHours.Add(quietHours);
if (quietHours.Id == Guid.Empty)
dbContext.Entry(quietHours).Property(e => e.Id).CurrentValue = Guid.NewGuid();
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return quietHours;
}
public async Task<bool> UpdateAsync(QuietHoursEntity quietHours, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.quiet_hours SET user_id = @user_id, channel_id = @channel_id, start_time = @start_time, end_time = @end_time,
timezone = @timezone, days_of_week = @days_of_week, enabled = @enabled
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(quietHours.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", quietHours.TenantId);
AddParameter(cmd, "id", quietHours.Id);
AddParameter(cmd, "user_id", quietHours.UserId);
AddParameter(cmd, "channel_id", quietHours.ChannelId);
AddParameter(cmd, "start_time", quietHours.StartTime);
AddParameter(cmd, "end_time", quietHours.EndTime);
AddParameter(cmd, "timezone", quietHours.Timezone);
AddParameter(cmd, "days_of_week", quietHours.DaysOfWeek);
AddParameter(cmd, "enabled", quietHours.Enabled);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(quietHours.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var existing = await dbContext.QuietHours
.FirstOrDefaultAsync(q => q.TenantId == quietHours.TenantId && q.Id == quietHours.Id, cancellationToken).ConfigureAwait(false);
if (existing is null) return false;
dbContext.Entry(existing).CurrentValues.SetValues(quietHours);
return await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false) > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.quiet_hours WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.QuietHours
.Where(q => q.TenantId == tenantId && q.Id == id)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false) > 0;
}
private static QuietHoursEntity MapQuietHours(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
UserId = GetNullableGuid(reader, 2),
ChannelId = GetNullableGuid(reader, 3),
StartTime = reader.GetFieldValue<TimeOnly>(4),
EndTime = reader.GetFieldValue<TimeOnly>(5),
Timezone = reader.GetString(6),
DaysOfWeek = reader.IsDBNull(7) ? [0, 1, 2, 3, 4, 5, 6] : reader.GetFieldValue<int[]>(7),
Enabled = reader.GetBoolean(8),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,139 +1,118 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
public sealed class RuleRepository : RepositoryBase<NotifyDataSource>, IRuleRepository
public sealed class RuleRepository : IRuleRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<RuleRepository> _logger;
public RuleRepository(NotifyDataSource dataSource, ILogger<RuleRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<RuleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata, created_at, updated_at
FROM notify.rules WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapRule, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Rules
.AsNoTracking()
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Id == id, cancellationToken)
.ConfigureAwait(false);
}
public async Task<RuleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata, created_at, updated_at
FROM notify.rules WHERE tenant_id = @tenant_id AND name = @name
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "name", name); },
MapRule, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Rules
.AsNoTracking()
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Name == name, cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<RuleEntity>> ListAsync(string tenantId, bool? enabled = null, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata, created_at, updated_at
FROM notify.rules WHERE tenant_id = @tenant_id
""";
if (enabled.HasValue) sql += " AND enabled = @enabled";
sql += " ORDER BY priority DESC, name";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (enabled.HasValue) AddParameter(cmd, "enabled", enabled.Value);
}, MapRule, cancellationToken).ConfigureAwait(false);
IQueryable<RuleEntity> query = dbContext.Rules
.AsNoTracking()
.Where(r => r.TenantId == tenantId);
if (enabled.HasValue)
query = query.Where(r => r.Enabled == enabled.Value);
return await query
.OrderByDescending(r => r.Priority).ThenBy(r => r.Name)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<RuleEntity>> GetMatchingRulesAsync(string tenantId, string eventType, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata, created_at, updated_at
FROM notify.rules WHERE tenant_id = @tenant_id AND enabled = TRUE AND @event_type = ANY(event_types)
ORDER BY priority DESC
""";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "event_type", eventType); },
MapRule, cancellationToken).ConfigureAwait(false);
// PostgreSQL ANY(array) requires raw SQL for proper translation
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Rules
.FromSqlRaw(
"SELECT * FROM notify.rules WHERE tenant_id = {0} AND enabled = TRUE AND {1} = ANY(event_types) ORDER BY priority DESC",
tenantId, eventType)
.AsNoTracking()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task<RuleEntity> CreateAsync(RuleEntity rule, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.rules (id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata)
VALUES (@id, @tenant_id, @name, @description, @enabled, @priority, @event_types, @filter::jsonb, @channel_ids, @template_id, @metadata::jsonb)
RETURNING *
""";
var id = rule.Id == Guid.Empty ? Guid.NewGuid() : rule.Id;
await using var connection = await DataSource.OpenConnectionAsync(rule.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", rule.TenantId);
AddParameter(command, "name", rule.Name);
AddParameter(command, "description", rule.Description);
AddParameter(command, "enabled", rule.Enabled);
AddParameter(command, "priority", rule.Priority);
AddTextArrayParameter(command, "event_types", rule.EventTypes);
AddJsonbParameter(command, "filter", rule.Filter);
AddParameter(command, "channel_ids", rule.ChannelIds);
AddParameter(command, "template_id", rule.TemplateId);
AddJsonbParameter(command, "metadata", rule.Metadata);
await using var connection = await _dataSource.OpenConnectionAsync(rule.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapRule(reader);
dbContext.Rules.Add(rule);
if (rule.Id == Guid.Empty)
dbContext.Entry(rule).Property(e => e.Id).CurrentValue = Guid.NewGuid();
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return rule;
}
public async Task<bool> UpdateAsync(RuleEntity rule, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.rules SET name = @name, description = @description, enabled = @enabled, priority = @priority,
event_types = @event_types, filter = @filter::jsonb, channel_ids = @channel_ids, template_id = @template_id, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(rule.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", rule.TenantId);
AddParameter(cmd, "id", rule.Id);
AddParameter(cmd, "name", rule.Name);
AddParameter(cmd, "description", rule.Description);
AddParameter(cmd, "enabled", rule.Enabled);
AddParameter(cmd, "priority", rule.Priority);
AddTextArrayParameter(cmd, "event_types", rule.EventTypes);
AddJsonbParameter(cmd, "filter", rule.Filter);
AddParameter(cmd, "channel_ids", rule.ChannelIds);
AddParameter(cmd, "template_id", rule.TemplateId);
AddJsonbParameter(cmd, "metadata", rule.Metadata);
}, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(rule.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var existing = await dbContext.Rules
.FirstOrDefaultAsync(r => r.TenantId == rule.TenantId && r.Id == rule.Id, cancellationToken)
.ConfigureAwait(false);
if (existing is null)
return false;
dbContext.Entry(existing).CurrentValues.SetValues(rule);
var rows = await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.rules WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var rows = await dbContext.Rules
.Where(r => r.TenantId == tenantId && r.Id == id)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
return rows > 0;
}
private static RuleEntity MapRule(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
Description = GetNullableString(reader, 3),
Enabled = reader.GetBoolean(4),
Priority = reader.GetInt32(5),
EventTypes = reader.IsDBNull(6) ? [] : reader.GetFieldValue<string[]>(6),
Filter = reader.GetString(7),
ChannelIds = reader.IsDBNull(8) ? [] : reader.GetFieldValue<Guid[]>(8),
TemplateId = GetNullableGuid(reader, 9),
Metadata = reader.GetString(10),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(12)
};
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,136 +1,76 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
public sealed class TemplateRepository : RepositoryBase<NotifyDataSource>, ITemplateRepository
public sealed class TemplateRepository : ITemplateRepository
{
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<TemplateRepository> _logger;
public TemplateRepository(NotifyDataSource dataSource, ILogger<TemplateRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<TemplateEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, channel_type::text, subject_template, body_template, locale, metadata, created_at, updated_at
FROM notify.templates WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapTemplate, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Templates.AsNoTracking()
.FirstOrDefaultAsync(t => t.TenantId == tenantId && t.Id == id, cancellationToken).ConfigureAwait(false);
}
public async Task<TemplateEntity?> GetByNameAsync(string tenantId, string name, ChannelType channelType, string locale = "en", CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, channel_type::text, subject_template, body_template, locale, metadata, created_at, updated_at
FROM notify.templates WHERE tenant_id = @tenant_id AND name = @name AND channel_type = @channel_type::notify.channel_type AND locale = @locale
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
AddParameter(cmd, "channel_type", ChannelTypeToString(channelType));
AddParameter(cmd, "locale", locale);
}, MapTemplate, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Templates.AsNoTracking()
.FirstOrDefaultAsync(t => t.TenantId == tenantId && t.Name == name && t.ChannelType == channelType && t.Locale == locale, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<TemplateEntity>> ListAsync(string tenantId, ChannelType? channelType = null, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, name, channel_type::text, subject_template, body_template, locale, metadata, created_at, updated_at
FROM notify.templates WHERE tenant_id = @tenant_id
""";
if (channelType.HasValue) sql += " AND channel_type = @channel_type::notify.channel_type";
sql += " ORDER BY name, locale";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (channelType.HasValue) AddParameter(cmd, "channel_type", ChannelTypeToString(channelType.Value));
}, MapTemplate, cancellationToken).ConfigureAwait(false);
IQueryable<TemplateEntity> query = dbContext.Templates.AsNoTracking().Where(t => t.TenantId == tenantId);
if (channelType.HasValue) query = query.Where(t => t.ChannelType == channelType.Value);
return await query.OrderBy(t => t.Name).ThenBy(t => t.Locale).ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<TemplateEntity> CreateAsync(TemplateEntity template, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.templates (id, tenant_id, name, channel_type, subject_template, body_template, locale, metadata)
VALUES (@id, @tenant_id, @name, @channel_type::notify.channel_type, @subject_template, @body_template, @locale, @metadata::jsonb)
RETURNING id, tenant_id, name, channel_type::text, subject_template, body_template, locale, metadata, created_at, updated_at
""";
var id = template.Id == Guid.Empty ? Guid.NewGuid() : template.Id;
await using var connection = await DataSource.OpenConnectionAsync(template.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", template.TenantId);
AddParameter(command, "name", template.Name);
AddParameter(command, "channel_type", ChannelTypeToString(template.ChannelType));
AddParameter(command, "subject_template", template.SubjectTemplate);
AddParameter(command, "body_template", template.BodyTemplate);
AddParameter(command, "locale", template.Locale);
AddJsonbParameter(command, "metadata", template.Metadata);
await using var connection = await _dataSource.OpenConnectionAsync(template.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapTemplate(reader);
dbContext.Templates.Add(template);
if (template.Id == Guid.Empty)
dbContext.Entry(template).Property(e => e.Id).CurrentValue = Guid.NewGuid();
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return template;
}
public async Task<bool> UpdateAsync(TemplateEntity template, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.templates SET name = @name, channel_type = @channel_type::notify.channel_type,
subject_template = @subject_template, body_template = @body_template, locale = @locale, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(template.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", template.TenantId);
AddParameter(cmd, "id", template.Id);
AddParameter(cmd, "name", template.Name);
AddParameter(cmd, "channel_type", ChannelTypeToString(template.ChannelType));
AddParameter(cmd, "subject_template", template.SubjectTemplate);
AddParameter(cmd, "body_template", template.BodyTemplate);
AddParameter(cmd, "locale", template.Locale);
AddJsonbParameter(cmd, "metadata", template.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(template.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var existing = await dbContext.Templates.FirstOrDefaultAsync(t => t.TenantId == template.TenantId && t.Id == template.Id, cancellationToken).ConfigureAwait(false);
if (existing is null) return false;
dbContext.Entry(existing).CurrentValues.SetValues(template);
return await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false) > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.templates WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.Templates.Where(t => t.TenantId == tenantId && t.Id == id).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false) > 0;
}
private static TemplateEntity MapTemplate(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
ChannelType = ParseChannelType(reader.GetString(3)),
SubjectTemplate = GetNullableString(reader, 4),
BodyTemplate = reader.GetString(5),
Locale = reader.GetString(6),
Metadata = reader.GetString(7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(9)
};
private static string ChannelTypeToString(ChannelType t) => t switch
{
ChannelType.Email => "email", ChannelType.Slack => "slack", ChannelType.Teams => "teams",
ChannelType.Webhook => "webhook", ChannelType.PagerDuty => "pagerduty", ChannelType.OpsGenie => "opsgenie",
_ => throw new ArgumentException($"Unknown: {t}")
};
private static ChannelType ParseChannelType(string s) => s switch
{
"email" => ChannelType.Email, "slack" => ChannelType.Slack, "teams" => ChannelType.Teams,
"webhook" => ChannelType.Webhook, "pagerduty" => ChannelType.PagerDuty, "opsgenie" => ChannelType.OpsGenie,
_ => throw new ArgumentException($"Unknown: {s}")
};
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -1,198 +1,87 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Persistence.EfCore.Context;
using StellaOps.Notify.Persistence.Postgres.Models;
namespace StellaOps.Notify.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IThrottleConfigRepository"/>.
/// EF Core implementation of <see cref="IThrottleConfigRepository"/>.
/// </summary>
public sealed class ThrottleConfigRepository : RepositoryBase<NotifyDataSource>, IThrottleConfigRepository
public sealed class ThrottleConfigRepository : IThrottleConfigRepository
{
private bool _tableInitialized;
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<ThrottleConfigRepository> _logger;
public ThrottleConfigRepository(NotifyDataSource dataSource, ILogger<ThrottleConfigRepository> logger)
: base(dataSource, logger) { }
{
_dataSource = dataSource;
_logger = logger;
}
public async Task<ThrottleConfigEntity?> GetByIdAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT config_id, tenant_id, name, default_window_seconds, max_notifications_per_window, channel_id,
is_default, enabled, description, metadata, created_by, created_at, updated_by, updated_at
FROM notify.throttle_configs WHERE tenant_id = @tenant_id AND config_id = @config_id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "config_id", configId); },
MapThrottleConfig, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.ThrottleConfigs.AsNoTracking()
.FirstOrDefaultAsync(t => t.TenantId == tenantId && t.ConfigId == configId, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<ThrottleConfigEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT config_id, tenant_id, name, default_window_seconds, max_notifications_per_window, channel_id,
is_default, enabled, description, metadata, created_by, created_at, updated_by, updated_at
FROM notify.throttle_configs WHERE tenant_id = @tenant_id ORDER BY name
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapThrottleConfig, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.ThrottleConfigs.AsNoTracking()
.Where(t => t.TenantId == tenantId)
.OrderBy(t => t.Name)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<ThrottleConfigEntity?> GetDefaultAsync(string tenantId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT config_id, tenant_id, name, default_window_seconds, max_notifications_per_window, channel_id,
is_default, enabled, description, metadata, created_by, created_at, updated_by, updated_at
FROM notify.throttle_configs WHERE tenant_id = @tenant_id AND is_default = TRUE LIMIT 1
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapThrottleConfig, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.ThrottleConfigs.AsNoTracking()
.FirstOrDefaultAsync(t => t.TenantId == tenantId && t.IsDefault, cancellationToken).ConfigureAwait(false);
}
public async Task<ThrottleConfigEntity?> GetByChannelAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT config_id, tenant_id, name, default_window_seconds, max_notifications_per_window, channel_id,
is_default, enabled, description, metadata, created_by, created_at, updated_by, updated_at
FROM notify.throttle_configs WHERE tenant_id = @tenant_id AND channel_id = @channel_id AND enabled = TRUE LIMIT 1
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "channel_id", channelId); },
MapThrottleConfig, cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.ThrottleConfigs.AsNoTracking()
.FirstOrDefaultAsync(t => t.TenantId == tenantId && t.ChannelId == channelId && t.Enabled, cancellationToken).ConfigureAwait(false);
}
public async Task<ThrottleConfigEntity> CreateAsync(ThrottleConfigEntity config, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(config.TenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO notify.throttle_configs (config_id, tenant_id, name, default_window_seconds, max_notifications_per_window,
channel_id, is_default, enabled, description, metadata, created_by, updated_by)
VALUES (@config_id, @tenant_id, @name, @default_window_seconds, @max_notifications_per_window,
@channel_id, @is_default, @enabled, @description, @metadata, @created_by, @updated_by)
RETURNING config_id, tenant_id, name, default_window_seconds, max_notifications_per_window, channel_id,
is_default, enabled, description, metadata, created_by, created_at, updated_by, updated_at
""";
await using var connection = await DataSource.OpenConnectionAsync(config.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "config_id", config.ConfigId);
AddParameter(command, "tenant_id", config.TenantId);
AddParameter(command, "name", config.Name);
AddParameter(command, "default_window_seconds", (long)config.DefaultWindow.TotalSeconds);
AddParameter(command, "max_notifications_per_window", (object?)config.MaxNotificationsPerWindow ?? DBNull.Value);
AddParameter(command, "channel_id", (object?)config.ChannelId ?? DBNull.Value);
AddParameter(command, "is_default", config.IsDefault);
AddParameter(command, "enabled", config.Enabled);
AddParameter(command, "description", (object?)config.Description ?? DBNull.Value);
AddJsonbParameter(command, "metadata", config.Metadata);
AddParameter(command, "created_by", (object?)config.CreatedBy ?? DBNull.Value);
AddParameter(command, "updated_by", (object?)config.UpdatedBy ?? DBNull.Value);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapThrottleConfig(reader);
await using var connection = await _dataSource.OpenConnectionAsync(config.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
dbContext.ThrottleConfigs.Add(config);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return config;
}
public async Task<bool> UpdateAsync(ThrottleConfigEntity config, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(config.TenantId, cancellationToken).ConfigureAwait(false);
const string sql = """
UPDATE notify.throttle_configs
SET name = @name, default_window_seconds = @default_window_seconds,
max_notifications_per_window = @max_notifications_per_window, channel_id = @channel_id,
is_default = @is_default, enabled = @enabled, description = @description,
metadata = @metadata, updated_by = @updated_by
WHERE tenant_id = @tenant_id AND config_id = @config_id
""";
var rows = await ExecuteAsync(config.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", config.TenantId);
AddParameter(cmd, "config_id", config.ConfigId);
AddParameter(cmd, "name", config.Name);
AddParameter(cmd, "default_window_seconds", (long)config.DefaultWindow.TotalSeconds);
AddParameter(cmd, "max_notifications_per_window", (object?)config.MaxNotificationsPerWindow ?? DBNull.Value);
AddParameter(cmd, "channel_id", (object?)config.ChannelId ?? DBNull.Value);
AddParameter(cmd, "is_default", config.IsDefault);
AddParameter(cmd, "enabled", config.Enabled);
AddParameter(cmd, "description", (object?)config.Description ?? DBNull.Value);
AddJsonbParameter(cmd, "metadata", config.Metadata);
AddParameter(cmd, "updated_by", (object?)config.UpdatedBy ?? DBNull.Value);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(config.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var existing = await dbContext.ThrottleConfigs
.FirstOrDefaultAsync(t => t.TenantId == config.TenantId && t.ConfigId == config.ConfigId, cancellationToken).ConfigureAwait(false);
if (existing is null) return false;
dbContext.Entry(existing).CurrentValues.SetValues(config);
return await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false) > 0;
}
public async Task<bool> DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(tenantId, cancellationToken).ConfigureAwait(false);
const string sql = "DELETE FROM notify.throttle_configs WHERE tenant_id = @tenant_id AND config_id = @config_id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "config_id", configId); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await dbContext.ThrottleConfigs
.Where(t => t.TenantId == tenantId && t.ConfigId == configId)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false) > 0;
}
private static ThrottleConfigEntity MapThrottleConfig(NpgsqlDataReader reader) => new()
{
ConfigId = reader.GetString(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
DefaultWindow = TimeSpan.FromSeconds(reader.GetInt64(3)),
MaxNotificationsPerWindow = GetNullableInt32(reader, 4),
ChannelId = GetNullableString(reader, 5),
IsDefault = reader.GetBoolean(6),
Enabled = reader.GetBoolean(7),
Description = GetNullableString(reader, 8),
Metadata = GetNullableString(reader, 9),
CreatedBy = GetNullableString(reader, 10),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
UpdatedBy = GetNullableString(reader, 12),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(13)
};
private async Task EnsureTableAsync(string tenantId, CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = """
CREATE TABLE IF NOT EXISTS notify.throttle_configs (
config_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
default_window_seconds BIGINT NOT NULL DEFAULT 300,
max_notifications_per_window INTEGER,
channel_id TEXT,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
description TEXT,
metadata JSONB,
created_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, config_id)
);
CREATE INDEX IF NOT EXISTS idx_throttle_configs_channel ON notify.throttle_configs (tenant_id, channel_id) WHERE channel_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_throttle_configs_default ON notify.throttle_configs (tenant_id, is_default) WHERE is_default = TRUE;
""";
await ExecuteAsync(tenantId, ddl, _ => { }, cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
private static string GetSchemaName() => NotifyDataSource.DefaultSchemaName;
}

View File

@@ -33,4 +33,9 @@
<EmbeddedResource Include="Migrations\**\*.sql" />
</ItemGroup>
<!-- Exclude compiled-model assembly attributes so non-default schemas work via reflection -->
<ItemGroup>
<Compile Remove="EfCore\CompiledModels\NotifyDbContextAssemblyAttributes.cs" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,213 @@
// -----------------------------------------------------------------------------
// TenantIsolationTests.cs
// Module: Notify
// Description: Unit tests verifying tenant isolation behaviour of the unified
// StellaOpsTenantResolver used by the Notify WebService.
// Exercises claim resolution, header fallbacks, conflict detection,
// and full context resolution (actor + project).
// -----------------------------------------------------------------------------
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration.Tenancy;
using Xunit;
namespace StellaOps.Notify.WebService.Tests;
/// <summary>
/// Tenant isolation tests for the Notify module using the unified
/// <see cref="StellaOpsTenantResolver"/>. Pure unit tests -- no Postgres,
/// no WebApplicationFactory.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TenantIsolationTests
{
// ---------------------------------------------------------------
// 1. Missing tenant returns false with "tenant_missing"
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_MissingTenant_ReturnsFalseWithTenantMissing()
{
// Arrange -- no claims, no headers
var ctx = CreateHttpContext();
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeFalse("no tenant source is available");
tenantId.Should().BeEmpty();
error.Should().Be("tenant_missing");
}
// ---------------------------------------------------------------
// 2. Canonical claim resolves tenant
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_CanonicalClaim_ResolvesTenant()
{
// Arrange
var ctx = CreateHttpContext();
ctx.User = PrincipalWithClaims(
new Claim(StellaOpsClaimTypes.Tenant, "acme-corp"));
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeTrue();
tenantId.Should().Be("acme-corp");
error.Should().BeNull();
}
// ---------------------------------------------------------------
// 3. Legacy "tid" claim fallback
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_LegacyTidClaim_FallsBack()
{
// Arrange -- only the legacy "tid" claim, no canonical claim or header
var ctx = CreateHttpContext();
ctx.User = PrincipalWithClaims(
new Claim("tid", "Legacy-Tenant-42"));
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeTrue("legacy tid claim should be accepted as fallback");
tenantId.Should().Be("legacy-tenant-42", "tenant IDs are normalised to lower-case");
error.Should().BeNull();
}
// ---------------------------------------------------------------
// 4. Canonical header resolves tenant
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_CanonicalHeader_ResolvesTenant()
{
// Arrange -- no claims, only the canonical header
var ctx = CreateHttpContext();
ctx.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "header-tenant";
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeTrue();
tenantId.Should().Be("header-tenant");
error.Should().BeNull();
}
// ---------------------------------------------------------------
// 5. Full context resolves actor and project
// ---------------------------------------------------------------
[Fact]
public void TryResolve_FullContext_ResolvesActorAndProject()
{
// Arrange
var ctx = CreateHttpContext();
ctx.User = PrincipalWithClaims(
new Claim(StellaOpsClaimTypes.Tenant, "acme-corp"),
new Claim(StellaOpsClaimTypes.Subject, "user-42"),
new Claim(StellaOpsClaimTypes.Project, "project-alpha"));
// Act
var resolved = StellaOpsTenantResolver.TryResolve(ctx, out var tenantContext, out var error);
// Assert
resolved.Should().BeTrue();
error.Should().BeNull();
tenantContext.Should().NotBeNull();
tenantContext!.TenantId.Should().Be("acme-corp");
tenantContext.ActorId.Should().Be("user-42");
tenantContext.ProjectId.Should().Be("project-alpha");
tenantContext.Source.Should().Be(TenantSource.Claim);
}
// ---------------------------------------------------------------
// 6. Conflicting headers return tenant_conflict
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_ConflictingHeaders_ReturnsTenantConflict()
{
// Arrange -- canonical and legacy headers with different values
var ctx = CreateHttpContext();
ctx.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "tenant-a";
ctx.Request.Headers["X-Stella-Tenant"] = "tenant-b";
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeFalse("conflicting headers must be rejected");
error.Should().Be("tenant_conflict");
}
// ---------------------------------------------------------------
// 7. Claim-header mismatch returns tenant_conflict
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_ClaimHeaderMismatch_ReturnsTenantConflict()
{
// Arrange -- claim says "tenant-claim" but header says "tenant-header"
var ctx = CreateHttpContext();
ctx.User = PrincipalWithClaims(
new Claim(StellaOpsClaimTypes.Tenant, "tenant-claim"));
ctx.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "tenant-header";
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeFalse("claim-header mismatch must be rejected");
error.Should().Be("tenant_conflict");
}
// ---------------------------------------------------------------
// 8. Matching claim and header -- no conflict
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_MatchingClaimAndHeader_NoConflict()
{
// Arrange -- claim and header agree on the same tenant
var ctx = CreateHttpContext();
ctx.User = PrincipalWithClaims(
new Claim(StellaOpsClaimTypes.Tenant, "same-tenant"));
ctx.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "same-tenant";
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeTrue("matching claim and header should not conflict");
tenantId.Should().Be("same-tenant");
error.Should().BeNull();
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
private static DefaultHttpContext CreateHttpContext()
{
var ctx = new DefaultHttpContext();
ctx.Response.Body = new MemoryStream();
return ctx;
}
private static ClaimsPrincipal PrincipalWithClaims(params Claim[] claims)
{
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
}