From 1fdac0463bb91c128c76b612ea6f1e4b1d832c76 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 18 Aug 2026 12:39:01 +0300 Subject: [PATCH 1/5] Alert when a retry group's shared budget runs out Sent once per subscription and group, resolved policy -> group -> subscription override, and delivered through a new native SMTP handler on its own bus queue. --- SW.Bitween.Api/Data/BitweenDbContext.cs | 15 + SW.Bitween.Api/Domain/RetryAlertOverride.cs | 35 + .../Domain/RetryBudgetExhaustedEvent.cs | 42 + SW.Bitween.Api/Domain/RetryGroupUsage.cs | 11 + SW.Bitween.Api/Domain/RetryPolicy.cs | 9 + SW.Bitween.Api/Domain/XchangeNotification.cs | 18 +- .../Domain/XchangeResult/XchangeResult.cs | 36 + .../Resources/RetryPolicies/Create.cs | 4 +- .../Resources/RetryPolicies/Delete.cs | 6 + SW.Bitween.Api/Resources/RetryPolicies/Get.cs | 19 +- .../RetryPolicies/RetryGroupValidation.cs | 19 + .../RetryPolicies/SaveAlertOverride.cs | 82 + .../Resources/RetryPolicies/Update.cs | 10 + .../Resources/RetryPolicies/Usage.cs | 93 +- SW.Bitween.Api/Services/RetryAlertResolver.cs | 84 + SW.Bitween.Api/Services/RetryAlertService.cs | 140 + SW.Bitween.Api/Services/RetryGroupBudget.cs | 40 +- SW.Bitween.Api/Services/XchangeService.cs | 15 + .../Fixtures/BitweenFixture.cs | 4 + .../Tests/RetryAlertServiceTests.cs | 198 ++ .../Tests/RetryPolicyTests.cs | 223 +- ...260817103506_RetryBudgetAlerts.Designer.cs | 1956 ++++++++++++++ .../20260817103506_RetryBudgetAlerts.cs | 116 + .../BitweenDbContextModelSnapshot.cs | 45 +- ...260817103452_RetryBudgetAlerts.Designer.cs | 1953 ++++++++++++++ .../20260817103452_RetryBudgetAlerts.cs | 122 + .../BitweenDbContextModelSnapshot.cs | 45 +- .../JsonMapper/ScribanJsonHelper.cs | 50 +- .../ServiceCollectionExtensions.cs | 4 + .../SmtpHandler/NativeSmtpHandler.cs | 110 + .../SmtpHandler/SmtpHandlerInput.cs | 59 + SW.Bitween.PgSql/BitweenDbContext.cs | 14 + ...260817103433_RetryBudgetAlerts.Designer.cs | 2242 +++++++++++++++++ .../20260817103433_RetryBudgetAlerts.cs | 131 + .../BitweenDbContextModelSnapshot.cs | 55 +- .../Model/AutoRetry/IRetryGroupBudget.cs | 39 +- .../Model/AutoRetry/RetryAlertMode.cs | 39 + SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs | 16 + .../Model/AutoRetry/RetryPolicyEvaluator.cs | 48 +- .../Model/RetryBudgetExhaustedNotification.cs | 39 + SW.Bitween.Sdk/Model/RetryPolicyModel.cs | 87 +- .../NativeSmtpHandlerTests.cs | 101 + .../RetryAlertResolverTests.cs | 162 ++ 43 files changed, 8446 insertions(+), 90 deletions(-) create mode 100644 SW.Bitween.Api/Domain/RetryAlertOverride.cs create mode 100644 SW.Bitween.Api/Domain/RetryBudgetExhaustedEvent.cs create mode 100644 SW.Bitween.Api/Resources/RetryPolicies/SaveAlertOverride.cs create mode 100644 SW.Bitween.Api/Services/RetryAlertResolver.cs create mode 100644 SW.Bitween.Api/Services/RetryAlertService.cs create mode 100644 SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs create mode 100644 SW.Bitween.MsSql/Migrations/20260817103506_RetryBudgetAlerts.Designer.cs create mode 100644 SW.Bitween.MsSql/Migrations/20260817103506_RetryBudgetAlerts.cs create mode 100644 SW.Bitween.MySql/Migrations/20260817103452_RetryBudgetAlerts.Designer.cs create mode 100644 SW.Bitween.MySql/Migrations/20260817103452_RetryBudgetAlerts.cs create mode 100644 SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs create mode 100644 SW.Bitween.NativeAdapters/SmtpHandler/SmtpHandlerInput.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260817103433_RetryBudgetAlerts.Designer.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260817103433_RetryBudgetAlerts.cs create mode 100644 SW.Bitween.Sdk/Model/AutoRetry/RetryAlertMode.cs create mode 100644 SW.Bitween.Sdk/Model/RetryBudgetExhaustedNotification.cs create mode 100644 SW.Bitween.UnitTests/NativeSmtpHandlerTests.cs create mode 100644 SW.Bitween.UnitTests/RetryAlertResolverTests.cs diff --git a/SW.Bitween.Api/Data/BitweenDbContext.cs b/SW.Bitween.Api/Data/BitweenDbContext.cs index 10eec473..612a84a9 100644 --- a/SW.Bitween.Api/Data/BitweenDbContext.cs +++ b/SW.Bitween.Api/Data/BitweenDbContext.cs @@ -228,6 +228,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(p => p.Id).ValueGeneratedOnAdd(); b.Property(p => p.Name).IsRequired().HasMaxLength(200); b.Property(p => p.Groups).StoreAsJson(); + b.Property(p => p.AlertHandlerId).HasMaxLength(200).IsUnicode(false); + b.Property(p => p.AlertHandlerProperties).StoreAsJson(); }); modelBuilder.Entity(b => @@ -245,6 +247,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasKey(p => new { p.SubscriptionId, p.GroupId }); b.Property(p => p.AttemptsUsed); b.Property(p => p.LastAttemptOn); + b.Property(p => p.ExhaustedNotifiedOn); + }); + + modelBuilder.Entity(b => + { + b.ToTable("RetryAlertOverrides"); + b.HasKey(p => new { p.SubscriptionId, p.GroupId }); + b.Property(p => p.AlertMode).HasConversion(); + b.Property(p => p.AlertHandlerId).HasMaxLength(200).IsUnicode(false); + b.Property(p => p.AlertHandlerProperties).StoreAsJson(); }); modelBuilder.Entity(b => @@ -290,6 +302,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(p => p.ResponseContentType).IsUnicode(false).HasMaxLength(200); b.Property(p => p.OutputContentType).IsUnicode(false).HasMaxLength(200); b.Property(p => p.RetryBlockedReason).HasMaxLength(500); + b.Property(p => p.RetryGroupId); + b.Property(p => p.AttemptNumber); + b.HasIndex(p => p.RetryGroupId); b.HasOne().WithOne().HasForeignKey(p => p.Id).OnDelete(DeleteBehavior.Cascade); diff --git a/SW.Bitween.Api/Domain/RetryAlertOverride.cs b/SW.Bitween.Api/Domain/RetryAlertOverride.cs new file mode 100644 index 00000000..61d1b6b9 --- /dev/null +++ b/SW.Bitween.Api/Domain/RetryAlertOverride.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using SW.Bitween.Model; + +namespace SW.Bitween.Domain; + +/// +/// The most specific level of the retry-alert hierarchy: where one subscription's failures in one +/// retry group should be alerted, overriding whatever the group or the policy says. +/// +/// +/// Deliberately its own table rather than columns on . Usage rows are +/// deleted by RetryPolicies/resetusage, so config stored there would be silently discarded +/// every time someone cleared a spent budget. +/// +public class RetryAlertOverride +{ + /// The subscription this override applies to. + public int SubscriptionId { get; set; } + + /// RetryGroup.Id, which survives policy edits, so the override does too. + public Guid GroupId { get; set; } + + /// + /// Whether this level sends, stays silent, or defers upward. A row whose mode is + /// is equivalent to having no row at all. + /// + public RetryAlertMode AlertMode { get; set; } + + /// Adapter that delivers the alert. Required when is Send. + public string AlertHandlerId { get; set; } + + /// That adapter's own settings — api key, recipients, subject. + public IReadOnlyDictionary AlertHandlerProperties { get; set; } +} diff --git a/SW.Bitween.Api/Domain/RetryBudgetExhaustedEvent.cs b/SW.Bitween.Api/Domain/RetryBudgetExhaustedEvent.cs new file mode 100644 index 00000000..b5f7e29f --- /dev/null +++ b/SW.Bitween.Api/Domain/RetryBudgetExhaustedEvent.cs @@ -0,0 +1,42 @@ +using System; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Domain; + +/// +/// Raised the one time a retry group's MaxAttemptsTotal runs out for a subscription, so the +/// configured alert handler can be told that failures matching that group have stopped being retried. +/// +/// +/// +/// Deliberately not an IHasWorkGroup event: it publishes under its own type name and is picked +/// up by a dedicated IConsume<RetryBudgetExhaustedEvent> consumer with its own queue. A +/// slow or broken alert handler therefore cannot delay or fail the ordinary notifier path, which +/// shares the work group's result queue. +/// +/// +/// Carried on rather than published directly, so it only reaches the bus +/// once the failure it describes has actually been committed. +/// +/// +public class RetryBudgetExhaustedEvent : BaseDomainEvent +{ + /// The failure that found the budget empty. + public string XchangeId { get; set; } + + public int SubscriptionId { get; set; } + + public Guid GroupId { get; set; } + + /// The group's name as it was when the budget ran out, in case it is later renamed. + public string GroupName { get; set; } + + /// + /// The ceiling that was reached. Not paired with an "used" count, because at exhaustion the two + /// are the same number — except when the ceiling was lowered below what had already been spent, + /// where the ceiling is still the meaningful figure. + /// + public int MaxAttemptsTotal { get; set; } + + public DateTime OccurredOn { get; set; } +} diff --git a/SW.Bitween.Api/Domain/RetryGroupUsage.cs b/SW.Bitween.Api/Domain/RetryGroupUsage.cs index ec9b271a..850081cb 100644 --- a/SW.Bitween.Api/Domain/RetryGroupUsage.cs +++ b/SW.Bitween.Api/Domain/RetryGroupUsage.cs @@ -24,4 +24,15 @@ public class RetryGroupUsage /// When the last attempt was claimed — the only clue left once a group is exhausted. public DateTime LastAttemptOn { get; set; } + + /// + /// When the exhaustion alert for this integration and group was claimed, or null while + /// the budget still has room. + /// + /// + /// Claiming this is what makes the alert fire exactly once: every failure after the budget runs + /// out would otherwise raise another one. Reset deletes the whole row, which re-arms the alert + /// along with the budget. + /// + public DateTime? ExhaustedNotifiedOn { get; set; } } diff --git a/SW.Bitween.Api/Domain/RetryPolicy.cs b/SW.Bitween.Api/Domain/RetryPolicy.cs index c1793896..c452e6f3 100644 --- a/SW.Bitween.Api/Domain/RetryPolicy.cs +++ b/SW.Bitween.Api/Domain/RetryPolicy.cs @@ -9,6 +9,15 @@ public class RetryPolicy : BaseEntity, IAudited, IRetryPolicy { public string Name { get; set; } public List Groups { get; set; } = []; + + /// + /// Default destination for "retry budget exhausted" alerts, used by every group that does not + /// override it. Null means no alert unless a group or a subscription+group override defines one. + /// + public string AlertHandlerId { get; set; } + + /// That adapter's own settings — api key, recipients, subject. + public IReadOnlyDictionary AlertHandlerProperties { get; set; } public DateTime CreatedOn { get; set; } public string CreatedBy { get; set; } public DateTime? ModifiedOn { get; set; } diff --git a/SW.Bitween.Api/Domain/XchangeNotification.cs b/SW.Bitween.Api/Domain/XchangeNotification.cs index 56a52186..97e8094b 100644 --- a/SW.Bitween.Api/Domain/XchangeNotification.cs +++ b/SW.Bitween.Api/Domain/XchangeNotification.cs @@ -5,9 +5,12 @@ namespace SW.Bitween.Domain { public class XchangeNotification:BaseEntity { + /// Name recorded for rows written by the retry-budget alert rather than a notifier. + public const string RetryBudgetAlertName = "Retry budget alert"; + private XchangeNotification(){} - public XchangeNotification(string xchangeId, int notifierId, string notifierName, string exception = null) + public XchangeNotification(string xchangeId, int? notifierId, string notifierName, string exception = null) { XchangeId = xchangeId; FinishedOn = DateTime.UtcNow; @@ -16,12 +19,21 @@ public XchangeNotification(string xchangeId, int notifierId, string notifierName NotifierId = notifierId; NotifierName = notifierName; } - + + /// + /// Logs an attempt to deliver a "retry budget exhausted" alert. These rows have no + /// because the alert is configured on the retry policy rather than + /// on a notifier — which is also how the send is recognised as already done on a redelivery. + /// + public static XchangeNotification ForRetryBudgetAlert(string xchangeId, string exception = null) => + new(xchangeId, null, RetryBudgetAlertName, exception); + public string XchangeId { get; private set; } public bool Success { get; set; } - public int NotifierId { get; set; } + /// The notifier that produced this row, or null for a retry-budget alert. + public int? NotifierId { get; set; } public string NotifierName { get; set; } diff --git a/SW.Bitween.Api/Domain/XchangeResult/XchangeResult.cs b/SW.Bitween.Api/Domain/XchangeResult/XchangeResult.cs index ca3f0daf..ed666dee 100644 --- a/SW.Bitween.Api/Domain/XchangeResult/XchangeResult.cs +++ b/SW.Bitween.Api/Domain/XchangeResult/XchangeResult.cs @@ -72,7 +72,43 @@ public XchangeResult(string xchangeId,WorkGroup workGroup, XchangeFile outputFil /// Records the policy's refusal so it can be shown alongside the failure. public void SetRetryBlocked(string reason) => RetryBlockedReason = reason; + /// + /// The retry group that matched this failure, or null when no policy applied or none + /// matched. The evaluator works this out and would otherwise discard it, leaving no way to + /// ask which failures a group is responsible for. + /// + public Guid? RetryGroupId { get; private set; } + /// + /// How many times this message had already been attempted when the policy evaluated it + /// (0 on the original run). Stored because deriving it means walking the whole + /// Xchange.RetryFor chain one query at a time. + /// + public int? AttemptNumber { get; private set; } + + /// Records which group owned this failure, and how far into its retries it was. + public void SetRetryEvaluation(Guid groupId, int attemptNumber) + { + RetryGroupId = groupId; + AttemptNumber = attemptNumber; + } + /// + /// Announces that this failure was the one that emptied the group's shared budget. Only ever + /// called by the caller that won the claim, so the event is raised once per exhaustion. + /// + public void RaiseBudgetExhausted(int subscriptionId, Guid groupId, string groupName, + int maxAttemptsTotal) + { + Events.Add(new RetryBudgetExhaustedEvent + { + XchangeId = Id, + SubscriptionId = subscriptionId, + GroupId = groupId, + GroupName = groupName, + MaxAttemptsTotal = maxAttemptsTotal, + OccurredOn = DateTime.UtcNow + }); + } } } diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Create.cs b/SW.Bitween.Api/Resources/RetryPolicies/Create.cs index 688eb768..0f92b3e1 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Create.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Create.cs @@ -25,7 +25,9 @@ public async Task Handle(RetryPolicyCreate model) var entity = new RetryPolicy { Name = model.Name, - Groups = model.Groups ?? [] + Groups = model.Groups ?? [], + AlertHandlerId = model.AlertHandlerId, + AlertHandlerProperties = model.AlertHandlerProperties }; _dbContext.Add(entity); await _dbContext.SaveChangesAsync(); diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs b/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs index 538a617c..0aad6096 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs @@ -36,10 +36,16 @@ public async Task Handle(int key) await _dbContext.DeleteByKeyAsync(key); if (groupIds.Count > 0) + { await _dbContext.Set() .Where(u => groupIds.Contains(u.GroupId)) .ExecuteDeleteAsync(); + await _dbContext.Set() + .Where(o => groupIds.Contains(o.GroupId)) + .ExecuteDeleteAsync(); + } + return null; } } diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Get.cs b/SW.Bitween.Api/Resources/RetryPolicies/Get.cs index 7241dc80..ad517b1e 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Get.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Get.cs @@ -19,14 +19,21 @@ public Get(BitweenDbContext dbContext) public async Task Handle(int key) { - return await _dbContext.Set() + // Materialize first: AlertHandlerProperties is a JSON-converted dictionary, and EF cannot + // translate a further .ToDictionary() over it into SQL inside a projection. + var policy = await _dbContext.Set() .AsNoTracking() .Search("Id", key) - .Select(p => new RetryPolicyUpdate - { - Name = p.Name, - Groups = p.Groups - }) .SingleOrDefaultAsync(); + + if (policy == null) return null; + + return new RetryPolicyUpdate + { + Name = policy.Name, + Groups = policy.Groups, + AlertHandlerId = policy.AlertHandlerId, + AlertHandlerProperties = policy.AlertHandlerProperties?.ToDictionary(kv => kv.Key, kv => kv.Value) + }; } } diff --git a/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs b/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs index e60ef570..787f69dd 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs @@ -32,9 +32,28 @@ public static void EnsureCanFire(IEnumerable groups) throw new SWValidationException("RETRY_GROUP_INCOMPATIBLE_MATCHERS", $"Group '{group.Name}' applies to {resultType} but none of its matchers can be " + $"evaluated against {resultType} content. {SupportedMatchersFor(resultType)}"); + + // An overriding level replaces the one above it rather than merging into it, so a group + // set to Send with no handler would silence the policy's alert instead of redirecting it. + if (group.AlertMode == RetryAlertMode.Send && string.IsNullOrWhiteSpace(group.AlertHandlerId)) + throw new SWValidationException("RETRY_GROUP_ALERT_NO_HANDLER", + $"Group '{group.Name}' is set to send its own budget alert but has no handler. " + + "Choose a handler, or set the alert back to inherit."); } } + /// + /// Rejects an alert override that claims to send but names nothing to send with — the same trap + /// as guards at group level. + /// + public static void EnsureAlertCanSend(RetryAlertMode mode, string handlerId) + { + if (mode == RetryAlertMode.Send && string.IsNullOrWhiteSpace(handlerId)) + throw new SWValidationException("RETRY_ALERT_NO_HANDLER", + "This override is set to send its own budget alert but has no handler. " + + "Choose a handler, or set it back to inherit."); + } + private static string SupportedMatchersFor(XchangeResultType resultType) => resultType switch { XchangeResultType.Error => "Error supports Contains, Regex and Exception type matchers.", diff --git a/SW.Bitween.Api/Resources/RetryPolicies/SaveAlertOverride.cs b/SW.Bitween.Api/Resources/RetryPolicies/SaveAlertOverride.cs new file mode 100644 index 00000000..b02bc4e2 --- /dev/null +++ b/SW.Bitween.Api/Resources/RetryPolicies/SaveAlertOverride.cs @@ -0,0 +1,82 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.RetryPolicies; + +/// +/// Sets, changes or clears where one subscription's alerts go for one group of this policy — the most +/// specific level of the hierarchy. +/// +[HandlerName("savealertoverride")] +public class SaveAlertOverride : ICommandHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public SaveAlertOverride(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(int key, RetryAlertOverrideSave request) + { + _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + RetryGroupValidation.EnsureAlertCanSend(request.AlertMode, request.AlertHandlerId); + + var policy = await _dbContext.Set().AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == key); + if (policy == null) throw new SWNotFoundException(key.ToString()); + + // Scoped to this policy's own groups and subscriptions, so a policy id in the route cannot + // reach an override belonging to a different policy. + if (policy.Groups.All(g => g.Id != request.GroupId)) + throw new SWValidationException("GROUP_NOT_IN_POLICY", + "That group does not belong to this retry policy."); + + var usesPolicy = await _dbContext.Set() + .AnyAsync(s => s.Id == request.SubscriptionId && s.RetryPolicyId == key); + if (!usesPolicy) + throw new SWValidationException("SUBSCRIPTION_NOT_USING_POLICY", + "That subscription does not use this retry policy."); + + var existing = await _dbContext.Set() + .FirstOrDefaultAsync(o => o.SubscriptionId == request.SubscriptionId + && o.GroupId == request.GroupId); + + // Inherit is the absence of an override, so store nothing rather than a row that does nothing + // — otherwise the routing list would have to explain a row that changes no behaviour. + if (request.AlertMode == RetryAlertMode.Inherit) + { + if (existing != null) _dbContext.Remove(existing); + await _dbContext.SaveChangesAsync(); + return null; + } + + if (existing == null) + { + _dbContext.Add(new RetryAlertOverride + { + SubscriptionId = request.SubscriptionId, + GroupId = request.GroupId, + AlertMode = request.AlertMode, + AlertHandlerId = request.AlertHandlerId, + AlertHandlerProperties = request.AlertHandlerProperties + }); + } + else + { + existing.AlertMode = request.AlertMode; + existing.AlertHandlerId = request.AlertHandlerId; + existing.AlertHandlerProperties = request.AlertHandlerProperties; + } + + await _dbContext.SaveChangesAsync(); + return null; + } +} diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Update.cs b/SW.Bitween.Api/Resources/RetryPolicies/Update.cs index 60a8040d..2acf91a7 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Update.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Update.cs @@ -35,13 +35,23 @@ public async Task Handle(int key, RetryPolicyUpdate model) entity.Name = model.Name; entity.Groups = model.Groups ?? []; + entity.AlertHandlerId = model.AlertHandlerId; + entity.AlertHandlerProperties = model.AlertHandlerProperties; await _dbContext.SaveChangesAsync(); if (removedGroupIds.Count > 0) + { await _dbContext.Set() .Where(u => removedGroupIds.Contains(u.GroupId)) .ExecuteDeleteAsync(); + // Alert overrides are keyed by group id for the same reason usage is, so they strand the + // same way when a group disappears. + await _dbContext.Set() + .Where(o => removedGroupIds.Contains(o.GroupId)) + .ExecuteDeleteAsync(); + } + return null; } } diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs b/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs index 5c836380..9f390803 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs @@ -10,9 +10,23 @@ namespace SW.Bitween.Resources.RetryPolicies; /// -/// Reports how much of each group's total budget the integrations using this policy have spent, -/// so an exhausted group is visible instead of just silently declining to retry. +/// Reports the state of every subscription-and-group pair using this policy: how much of the +/// group's total budget that subscription has spent, and where the pair's budget-exhausted alert +/// would go. /// +/// +/// +/// Both halves share the (SubscriptionId, GroupId) key, so they are reported together — the +/// question worth asking about an exhausted budget is whether anyone was told about it, and +/// splitting that across two reports leaves the caller to join them by eye. +/// +/// +/// Starts from the policy's groups rather than from the stored counters, so a subscription that has +/// never failed still gets a row and its alert override stays configurable before the first failure. +/// Counters for groups no longer in the policy are therefore left out, which is what +/// and delete outright. +/// +/// [HandlerName("usage")] public class Usage : ICommandHandler { @@ -44,31 +58,66 @@ public async Task Handle(int key, RetryPolicyUsageRequest request) .Where(u => subscriptionIds.Contains(u.SubscriptionId)) .ToListAsync(); - // Only groups that allow retries have a budget to spend. - var budgets = policy.Groups - .Where(g => g.Budget != null) - .ToDictionary(g => g.Id, g => new { g.Name, g.Budget.MaxAttemptsTotal }); + var overrides = await _dbContext.Set().AsNoTracking() + .Where(o => subscriptionIds.Contains(o.SubscriptionId)) + .ToListAsync(); + + // Only groups that allow retries have a budget to spend — and a group that can never spend + // one can never exhaust it, so it can never alert either. Listing those would invite + // configuring an alert that cannot fire. + var groups = policy.Groups.Where(g => g.Budget != null).ToList(); + + var rows = new List(); + + foreach (var subscription in subscriptions) + foreach (var group in groups) + { + var usage = usages.FirstOrDefault( + u => u.SubscriptionId == subscription.Id && u.GroupId == group.Id); - var names = subscriptions.ToDictionary(s => s.Id, s => s.Name); + var subscriptionOverride = overrides.FirstOrDefault( + o => o.SubscriptionId == subscription.Id && o.GroupId == group.Id); - var rows = usages - .Where(u => budgets.ContainsKey(u.GroupId)) - .Select(u => new RetryGroupUsageRow + var target = RetryAlertResolver.Resolve(subscriptionOverride, group, policy); + + // Mirrors the order the resolver walks, so the reported reason is the level that + // actually decided: an override silences before the group is consulted at all. + var silencedAt = subscriptionOverride?.AlertMode == RetryAlertMode.Silent + ? RetryAlertLevel.SubscriptionGroup + : group.AlertMode == RetryAlertMode.Silent + ? RetryAlertLevel.Group + : (RetryAlertLevel?)null; + + rows.Add(new RetryGroupUsageRow { - SubscriptionId = u.SubscriptionId, - SubscriptionName = names.GetValueOrDefault(u.SubscriptionId), - GroupId = u.GroupId, - GroupName = budgets[u.GroupId].Name, - AttemptsUsed = u.AttemptsUsed, - MaxAttemptsTotal = budgets[u.GroupId].MaxAttemptsTotal, - Exhausted = u.AttemptsUsed >= budgets[u.GroupId].MaxAttemptsTotal, - LastAttemptOn = u.LastAttemptOn - }) - // Exhausted integrations first — those are the ones no longer being retried. + SubscriptionId = subscription.Id, + SubscriptionName = subscription.Name, + GroupId = group.Id, + GroupName = group.Name, + AttemptsUsed = usage?.AttemptsUsed ?? 0, + MaxAttemptsTotal = group.Budget!.MaxAttemptsTotal, + Exhausted = usage != null && usage.AttemptsUsed >= group.Budget.MaxAttemptsTotal, + LastAttemptOn = usage?.LastAttemptOn, + ExhaustedNotifiedOn = usage?.ExhaustedNotifiedOn, + AlertMode = subscriptionOverride?.AlertMode ?? RetryAlertMode.Inherit, + OverrideHandlerId = subscriptionOverride?.AlertHandlerId, + OverrideHandlerProperties = subscriptionOverride?.AlertHandlerProperties + ?.ToDictionary(kv => kv.Key, kv => kv.Value), + ResolvedHandlerId = target?.HandlerId, + ResolvedHandlerProperties = target?.HandlerProperties + ?.ToDictionary(kv => kv.Key, kv => kv.Value), + ResolvedFrom = target?.Level, + SilencedAt = target == null ? silencedAt : null + }); + } + + return rows + // Worst first: stopped retrying, then alerting nowhere, then whatever has spent most. .OrderByDescending(r => r.Exhausted) + .ThenBy(r => r.ResolvedHandlerId != null) .ThenByDescending(r => r.AttemptsUsed) + .ThenBy(r => r.SubscriptionName) + .ThenBy(r => r.GroupName) .ToList(); - - return new List(rows); } } diff --git a/SW.Bitween.Api/Services/RetryAlertResolver.cs b/SW.Bitween.Api/Services/RetryAlertResolver.cs new file mode 100644 index 00000000..3e9a95a7 --- /dev/null +++ b/SW.Bitween.Api/Services/RetryAlertResolver.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using SW.Bitween.Domain; +using SW.Bitween.Model; + +namespace SW.Bitween; + +/// Where a resolved alert should be delivered, and which level of the hierarchy decided it. +public class RetryAlertTarget +{ + public required string HandlerId { get; init; } + public IReadOnlyDictionary HandlerProperties { get; init; } + + /// Which level won — shown in the UI so a surprising destination can be traced. + public required RetryAlertLevel Level { get; init; } +} + +/// +/// Resolves where a group's exhaustion alert goes, walking from the most specific level to the +/// least: the subscription+group override, then the group, then the policy. +/// +/// +/// +/// A level that overrides replaces the level above rather than merging into it, so +/// whichever level wins must carry the handler and every property it needs. That keeps what the UI +/// shows for a level identical to what actually gets sent. +/// +/// +/// Resolved at send time rather than stored, so editing a policy's default immediately affects +/// everything still inheriting it. +/// +/// +public static class RetryAlertResolver +{ + /// + /// Returns the destination for one subscription's alert in one group, or null when no + /// level configures one or a level explicitly silences it. + /// + /// The subscription+group override, or null if none exists. + /// The matched group. null when the group no longer exists in the policy. + /// + /// The named policy, or null when the subscription uses an inline + /// — those have no policy row, so only the group and the + /// override levels can configure an alert. + /// + public static RetryAlertTarget Resolve(RetryAlertOverride subscriptionOverride, RetryGroup group, + RetryPolicy policy) + { + switch (subscriptionOverride?.AlertMode) + { + case RetryAlertMode.Silent: + return null; + case RetryAlertMode.Send when !string.IsNullOrWhiteSpace(subscriptionOverride.AlertHandlerId): + return new RetryAlertTarget + { + HandlerId = subscriptionOverride.AlertHandlerId, + HandlerProperties = subscriptionOverride.AlertHandlerProperties, + Level = RetryAlertLevel.SubscriptionGroup + }; + } + + switch (group?.AlertMode) + { + case RetryAlertMode.Silent: + return null; + case RetryAlertMode.Send when !string.IsNullOrWhiteSpace(group.AlertHandlerId): + return new RetryAlertTarget + { + HandlerId = group.AlertHandlerId, + HandlerProperties = group.AlertHandlerProperties, + Level = RetryAlertLevel.Group + }; + } + + if (!string.IsNullOrWhiteSpace(policy?.AlertHandlerId)) + return new RetryAlertTarget + { + HandlerId = policy.AlertHandlerId, + HandlerProperties = policy.AlertHandlerProperties, + Level = RetryAlertLevel.Policy + }; + + return null; + } +} diff --git a/SW.Bitween.Api/Services/RetryAlertService.cs b/SW.Bitween.Api/Services/RetryAlertService.cs new file mode 100644 index 00000000..d6f6182c --- /dev/null +++ b/SW.Bitween.Api/Services/RetryAlertService.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using SW.Bitween.Domain; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween; + +/// +/// Delivers "retry budget exhausted" alerts, on its own queue. +/// +/// +/// Deliberately a separate consumer rather than another branch inside XchangeService's +/// result handling: alerts go through a customer-configured adapter that may be slow or broken, and +/// on the shared result queue that would hold up — or fail — the ordinary notifiers for the same +/// exchange. Its own means its own queue, and the two fail apart. +/// +public class RetryAlertService( + BitweenDbContext dbContext, + NativeAdapterDiscoveryService nativeAdapterDiscovery, + IServiceProvider serviceProvider, + ILogger logger) : IConsume +{ + public async Task Process(RetryBudgetExhaustedEvent message) + { + // The bus is at-least-once, and the exhaustion is stamped on the exchange rather than on + // the send, so a redelivery would otherwise email the same alert twice. The log row is the + // record that it already went out. + var alreadySent = await dbContext.Set() + .AnyAsync(n => n.XchangeId == message.XchangeId && n.NotifierId == null); + if (alreadySent) return; + + var subscription = await dbContext.Set() + .Include(s => s.RetryPolicy) + .FirstOrDefaultAsync(s => s.Id == message.SubscriptionId); + if (subscription == null) return; + + // An inline custom policy has no policy row, so only the group and override levels of the + // hierarchy can configure an alert for it. + IRetryPolicy policy = subscription.CustomRetryPolicy ?? (IRetryPolicy)subscription.RetryPolicy; + var group = policy?.Groups?.FirstOrDefault(g => g.Id == message.GroupId); + + var subscriptionOverride = await dbContext.Set() + .FirstOrDefaultAsync(o => o.SubscriptionId == message.SubscriptionId + && o.GroupId == message.GroupId); + + var target = RetryAlertResolver.Resolve(subscriptionOverride, group, subscription.RetryPolicy); + if (target == null) return; + + var notification = await BuildNotification(message, subscription); + await Send(target, notification, message.XchangeId); + } + + private async Task BuildNotification( + RetryBudgetExhaustedEvent message, Subscription subscription) + { + var context = await (from xchange in dbContext.Set().AsNoTracking() + where xchange.Id == message.XchangeId + join document in dbContext.Set() on xchange.DocumentId equals document.Id + join result in dbContext.Set() on xchange.Id equals result.Id into xr + from result in xr.DefaultIfEmpty() + select new + { + document.Name, + xchange.CorrelationId, + result.Exception, + result.RetryBlockedReason + }) + .FirstOrDefaultAsync(); + + return new RetryBudgetExhaustedNotification + { + XchangeId = message.XchangeId, + SubscriptionId = message.SubscriptionId, + SubscriptionName = subscription.Name, + DocumentName = context?.Name, + CorrelationId = context?.CorrelationId, + PolicyName = subscription.RetryPolicy?.Name, + GroupName = message.GroupName, + MaxAttemptsTotal = message.MaxAttemptsTotal, + BlockedReason = context?.RetryBlockedReason, + Exception = context?.Exception, + OccurredOn = message.OccurredOn + }; + } + + /// + /// Invokes the resolved handler and records the attempt either way. + /// + /// + /// A throw is logged rather than propagated: rethrowing would send the message to the bus's + /// error queue and, once redelivered, the guard above would suppress the retry anyway. Recording + /// the failure is what lets someone answer "did the alert actually go out?". + /// + private async Task Send(RetryAlertTarget target, RetryBudgetExhaustedNotification notification, + string xchangeId) + { + var handlerProperties = new Dictionary( + target.HandlerProperties ?? new Dictionary()) + { + ["xchangeid"] = xchangeId + }; + + var payload = new XchangeFile(JsonConvert.SerializeObject(notification), xchangeId); + + try + { + if (target.HandlerId.StartsWith(NativeAdapterDiscoveryService.NativePrefix, + StringComparison.OrdinalIgnoreCase)) + { + var handler = nativeAdapterDiscovery.GetNativeHandler(target.HandlerId, handlerProperties); + await handler.Handle(payload); + } + else + { + var serverless = serviceProvider.GetRequiredService(); + await serverless.StartAsync(target.HandlerId, notification.CorrelationId ?? xchangeId, + handlerProperties); + await serverless.InvokeAsync(nameof(IInfolinkHandler.Handle), payload); + } + + dbContext.Add(XchangeNotification.ForRetryBudgetAlert(xchangeId)); + } + catch (Exception ex) + { + logger.LogError(ex, + "Retry budget alert for xchange {XchangeId} could not be delivered through {HandlerId}.", + xchangeId, target.HandlerId); + dbContext.Add(XchangeNotification.ForRetryBudgetAlert(xchangeId, ex.ToString())); + } + + await dbContext.SaveChangesAsync(); + } +} diff --git a/SW.Bitween.Api/Services/RetryGroupBudget.cs b/SW.Bitween.Api/Services/RetryGroupBudget.cs index d35e849d..049122e1 100644 --- a/SW.Bitween.Api/Services/RetryGroupBudget.cs +++ b/SW.Bitween.Api/Services/RetryGroupBudget.cs @@ -34,17 +34,19 @@ public class RetryGroupBudget( /// inside SaveChangesAsync. /// /// - public async Task TryConsume(Guid groupId, int maxAttemptsTotal) + public async Task TryConsume(Guid groupId, int maxAttemptsTotal) { - if (maxAttemptsTotal <= 0) return false; + // A group configured to allow no retries at all has no budget to exhaust, so it never + // alerts — otherwise every single failure under it would raise one. + if (maxAttemptsTotal <= 0) return RetryBudgetClaim.Denied; - if (await TryIncrement(dbContext, groupId, maxAttemptsTotal)) return true; + if (await TryIncrement(dbContext, groupId, maxAttemptsTotal)) return RetryBudgetClaim.Allowed; // Nothing was updated: either the ceiling is reached, or this integration and group have // never failed before and so have no row yet. var exists = await dbContext.Set() .AnyAsync(u => u.SubscriptionId == subscriptionId && u.GroupId == groupId); - if (exists) return false; + if (exists) return await ClaimExhaustionAlert(groupId, maxAttemptsTotal); // Create that first row on its own context so it commits independently of whatever the // caller still has pending. Losing this race is harmless: the primary key rejects the @@ -63,14 +65,40 @@ public async Task TryConsume(Guid groupId, int maxAttemptsTotal) try { await isolated.SaveChangesAsync(); - return true; + return RetryBudgetClaim.Allowed; } catch (DbUpdateException) { - return await TryIncrement(dbContext, groupId, maxAttemptsTotal); + // The winner of the insert race already holds a row, so this is the ordinary + // increment path again — including the case where their row is already full. + return await TryIncrement(dbContext, groupId, maxAttemptsTotal) + ? RetryBudgetClaim.Allowed + : await ClaimExhaustionAlert(groupId, maxAttemptsTotal); } } + /// + /// Takes responsibility for alerting that this integration's budget for the group is spent. + /// + /// + /// One conditional UPDATE for the same reason the increment is one: several instances can + /// discover the empty budget at the same moment, and a read-then-write would let each of them + /// decide it was the first. Exactly one caller updates a row, so exactly one alert is raised — + /// and because Reset deletes the row outright, clearing a budget re-arms the alert with it. + /// + private async Task ClaimExhaustionAlert(Guid groupId, int maxAttemptsTotal) + { + var claimed = await dbContext.Set() + .Where(u => u.SubscriptionId == subscriptionId + && u.GroupId == groupId + && u.AttemptsUsed >= maxAttemptsTotal + && u.ExhaustedNotifiedOn == null) + .ExecuteUpdateAsync(s => s + .SetProperty(u => u.ExhaustedNotifiedOn, _ => DateTime.UtcNow)) > 0; + + return claimed ? RetryBudgetClaim.DeniedAndJustExhausted : RetryBudgetClaim.Denied; + } + private async Task TryIncrement(BitweenDbContext db, Guid groupId, int maxAttemptsTotal) => await db.Set() .Where(u => u.SubscriptionId == subscriptionId diff --git a/SW.Bitween.Api/Services/XchangeService.cs b/SW.Bitween.Api/Services/XchangeService.cs index 34a1cc98..b859e2f4 100644 --- a/SW.Bitween.Api/Services/XchangeService.cs +++ b/SW.Bitween.Api/Services/XchangeService.cs @@ -484,6 +484,11 @@ private async Task TryScheduleAutoRetry(Xchange xchange, XchangeResultType resul var attemptIndex = await CountRetryChainDepth(xchange); var decision = await evaluator.Evaluate(resultType, content, attemptIndex); + // Which group owned this failure, so the group's retries can later be listed without + // re-deriving the match, and how deep the chain already was without walking it again. + if (decision.MatchedGroup is not null) + xchangeResult.SetRetryEvaluation(decision.MatchedGroup.Id, attemptIndex); + if (decision.ShouldRetry) _dbContext.Add(new DelayedRetry { @@ -494,6 +499,16 @@ private async Task TryScheduleAutoRetry(Xchange xchange, XchangeResultType resul // A policy applied but refused. Recorded so an exhausted budget is distinguishable // from an error no group was ever configured to catch. xchangeResult.SetRetryBlocked(decision.Reason); + + // Raised on the result rather than published here, so the alert only reaches the bus once + // this failure is committed. Its own event type means its own queue and its own consumer, + // keeping a slow alert handler away from the ordinary notifier path. + if (decision.BudgetJustExhausted) + xchangeResult.RaiseBudgetExhausted( + xchange.SubscriptionId.Value, + decision.MatchedGroup!.Id, + decision.MatchedGroup.Name, + decision.MatchedGroup.Budget!.MaxAttemptsTotal); } private async Task CountRetryChainDepth(Xchange xchange) diff --git a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs index 33824ad2..be0a292b 100644 --- a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs +++ b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs @@ -11,6 +11,7 @@ using SW.Bitween.Domain; using SW.Bitween.IntegrationTests.Adapters; using SW.Bitween.NativeAdapters; +using SW.Bitween.NativeAdapters.SmtpHandler; using SW.Bitween.PgSql; using SW.Bus; using SW.CloudFiles.Extensions; @@ -92,6 +93,8 @@ public async Task InitializeAsync() services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddScoped(); @@ -100,6 +103,7 @@ public async Task InitializeAsync() services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); }) .Build(); diff --git a/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs new file mode 100644 index 00000000..418dcf76 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// Sends a real "retry budget exhausted" alert through to a local +/// MailHog instance and reads it back over MailHog's own API — the one part of the feature no unit +/// test can prove, because it depends on an actual SMTP handshake succeeding. +/// +/// +/// Requires MailHog running locally: docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog. +/// Skips itself when MailHog is not reachable, so it never fails a normal test run. +/// +[Collection("Bitween")] +public class RetryAlertServiceTests +{ + private const string MailHogApi = "http://localhost:8025/api/v2"; + private readonly BitweenFixture _fixture; + + public RetryAlertServiceTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + private static async Task MailHogIsReachable() + { + try + { + using var http = new HttpClient(); + var response = await http.GetAsync($"{MailHogApi}/messages"); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + // Deleting is only exposed on MailHog's v1 API — the v2 route 404s and would silently leave + // messages behind, making the assertions depend on leftovers from the previous run. + private static async Task ClearMailHog() + { + using var http = new HttpClient(); + var response = await http.DeleteAsync("http://localhost:8025/api/v1/messages"); + response.EnsureSuccessStatusCode(); + } + + private static async Task LatestMailHogMessage() + { + using var http = new HttpClient(); + var json = await http.GetStringAsync($"{MailHogApi}/messages"); + using var doc = JsonDocument.Parse(json); + var items = doc.RootElement.GetProperty("items").Clone(); + return items.GetArrayLength() > 0 ? items[0] : null; + } + + private static async Task MailHogTotal() + { + using var http = new HttpClient(); + var json = await http.GetStringAsync($"{MailHogApi}/messages"); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("total").GetInt32(); + } + + [Fact] + public async Task Exhausted_budget_alert_arrives_in_MailHog_with_the_group_and_subscription_named() + { + if (!await MailHogIsReachable()) + return; // Environment doesn't have MailHog running — nothing to verify against. + + await ClearMailHog(); + + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var alertService = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7201, "MailHog Alert Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + // A group whose own alert config points at MailHog directly — the narrowest level, so the + // resolver has nothing to fall through to and the test proves that level specifically. + var groupId = Guid.NewGuid(); + var policy = new RetryPolicy + { + Name = "MailHog Alert Policy", + Groups = + [ + new RetryGroup + { + Id = groupId, + Name = "FRT charges cannot be found", + Priority = 10, + AppliesTo = [XchangeResultType.Error], + Matchers = [new ContainsMatcher { Value = "timeout" }], + Budget = new RetryBudget + { + MaxAttemptsPerError = 1, + MaxAttemptsTotal = 1, + DelayStrategy = new FixedDelayStrategy { DelayMs = 1000 } + }, + AlertMode = RetryAlertMode.Send, + AlertHandlerId = "NativeSmtpHandler", + AlertHandlerProperties = new Dictionary + { + ["Host"] = "localhost", + ["Port"] = "1025", + ["UseTls"] = "false", + ["From"] = "bitween-alerts@example.com", + ["To"] = "ops@example.com", + ["Subject"] = "Retries stopped for {{ SubscriptionName }}", + ["Body"] = "{{ GroupName }} used all {{ MaxAttemptsTotal }} retries." + } + } + ] + }; + db.Set().Add(policy); + await db.SaveChangesAsync(); + + var sub = new Subscription("MailHog Alert Sub", doc.Id); + db.Set().Add(sub); + await db.SaveChangesAsync(); + sub.SetRetryPolicy(policy.Id, null); + await db.SaveChangesAsync(); + + var xchange = await scope.ServiceProvider.GetRequiredService() + .CreateXchange(sub, new XchangeFile("{}")); + await db.SaveChangesAsync(); + + // Reproduces exactly what TryScheduleAutoRetry does: evaluate against the real budget table, + // and when it reports exhaustion, raise the event the same way XchangeResult does in + // production. RetryAlertService.Process is then invoked directly rather than over the bus — + // this suite calls handlers directly throughout (see RetryJobTests, DelayedRetriesTests) + // rather than relying on live message transport, which is SW.Bus's own concern, not this + // feature's. + var evaluator = new RetryPolicyEvaluator(policy, + new RetryGroupBudget(db, scope.ServiceProvider, sub.Id)); + + // Total is 1, so the first message (a different "parcel" failing the same way) spends the + // whole budget and is itself allowed to retry — exhaustion only shows up for the next one. + var firstMessage = await evaluator.Evaluate(XchangeResultType.Error, + "System.TimeoutException: contains timeout", 0); + Assert.True(firstMessage.ShouldRetry); + + var decision = await evaluator.Evaluate(XchangeResultType.Error, + "System.TimeoutException: contains timeout", 0); + Assert.False(decision.ShouldRetry); + Assert.True(decision.BudgetJustExhausted); + + var xchangeResult = new XchangeResult(xchange.Id, null, null, exception: "System.TimeoutException: contains timeout"); + xchangeResult.RaiseBudgetExhausted(sub.Id, groupId, decision.MatchedGroup!.Name, + decision.MatchedGroup.Budget!.MaxAttemptsTotal); + + // SaveChangesAsync dispatches and clears Events (see BitweenDbContext), same as it does in + // production, so the event has to be captured before saving rather than read back after. + // The constructor raises its own XchangeResultCreatedEvent alongside it. + var raisedEvent = Assert.Single(xchangeResult.Events.OfType()); + + db.Add(xchangeResult); + await db.SaveChangesAsync(); + + await alertService.Process(raisedEvent); + + var message = await LatestMailHogMessage(); + Assert.NotNull(message); + + var subject = message!.Value.GetProperty("Content").GetProperty("Headers") + .GetProperty("Subject")[0].GetString(); + Assert.Equal("Retries stopped for MailHog Alert Sub", subject); + + var body = message.Value.GetProperty("Content").GetProperty("Body").GetString(); + Assert.Contains("FRT charges cannot be found used all 1 retries", body); + + var loggedNotification = await db.Set().AsNoTracking() + .SingleAsync(n => n.XchangeId == xchange.Id); + Assert.True(loggedNotification.Success); + Assert.Equal(XchangeNotification.RetryBudgetAlertName, loggedNotification.NotifierName); + + // Redelivery of the same event must not double-send — same guard the real bus retry path + // relies on. Compared against the count after the first send rather than an absolute + // number, so a stray message could never make this pass by accident. + var totalAfterFirstSend = await MailHogTotal(); + await alertService.Process(raisedEvent); + Assert.Equal(totalAfterFirstSend, await MailHogTotal()); + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs index 0acecd6e..262cbb33 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs @@ -454,7 +454,8 @@ public async Task Concurrent_claims_never_exceed_the_group_total() return await new RetryGroupBudget(db, scope.ServiceProvider, sub.Id).TryConsume(groupId, cap); }); - var granted = (await Task.WhenAll(tasks)).Count(allowed => allowed); + var claims = await Task.WhenAll(tasks); + var granted = claims.Count(claim => claim.Granted); Assert.Equal(cap, granted); @@ -505,10 +506,66 @@ public async Task Usage_reports_spent_budget_and_reset_clears_it() GroupId = groupId }); - Assert.Empty((List)await new Usage(db, ctx).Handle(policyId, new RetryPolicyUsageRequest())); + // The pair keeps its row — every subscription-and-group pair gets one so an alert override + // stays configurable before the first failure — but with nothing spent against the ceiling. + var afterReset = Assert.Single( + (List)await new Usage(db, ctx).Handle(policyId, new RetryPolicyUsageRequest())); + Assert.Equal(0, afterReset.AttemptsUsed); + Assert.False(afterReset.Exhausted); + Assert.Null(afterReset.LastAttemptOn); // And the group can retry again. - Assert.True(await new RetryGroupBudget(db, scope.ServiceProvider, sub.Id).TryConsume(groupId, 10)); + Assert.True((await new RetryGroupBudget(db, scope.ServiceProvider, sub.Id).TryConsume(groupId, 10)).Granted); + } + + [Fact] + public async Task Usage_lists_never_failed_pairs_and_skips_groups_that_cannot_exhaust() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7011, "Never Failed Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + var model = SimplePolicy("Never Failed Policy"); + model.AlertHandlerId = "NativeSmtpHandler"; + + // A Block group carries no budget, and the evaluator refuses before it ever claims one, so + // it can never exhaust and never alert. Reporting it would invite configuring an alert that + // cannot fire. + model.Groups.Add(new RetryGroup + { + Name = "Never retry", + Priority = 20, + Action = RetryAction.Block, + AppliesTo = [XchangeResultType.Error], + Matchers = [new ContainsMatcher { Value = "fatal" }] + }); + + var policyId = (int)await new Create(db, ctx).Handle(model); + + var sub = new Subscription("Never Failed Sub", doc.Id); + db.Set().Add(sub); + await db.SaveChangesAsync(); + sub.SetRetryPolicy(policyId, null); + await db.SaveChangesAsync(); + + var rows = (List)await new Usage(db, ctx) + .Handle(policyId, new RetryPolicyUsageRequest()); + + // One row, not two: the pair is reported even though nothing has ever failed — otherwise its + // alert override would be unreachable until after the first failure — while the Block group + // is left out entirely. + var row = Assert.Single(rows); + Assert.Equal("Timeout", row.GroupName); + Assert.Equal(0, row.AttemptsUsed); + Assert.Equal(10, row.MaxAttemptsTotal); + Assert.False(row.Exhausted); + Assert.Null(row.LastAttemptOn); + Assert.Equal("NativeSmtpHandler", row.ResolvedHandlerId); + Assert.Equal(RetryAlertLevel.Policy, row.ResolvedFrom); } [Fact] @@ -539,7 +596,11 @@ public async Task Reset_does_not_touch_counters_of_another_policy() // Resetting everything under one policy must leave the other policy's counters alone. await new ResetUsage(db, ctx).Handle(mineId, new RetryPolicyResetUsage()); - Assert.Single((List)await new Usage(db, ctx).Handle(otherId, new RetryPolicyUsageRequest())); + // A row now exists for every pair whether or not it has failed, so assert the spent counter + // itself survived — row count alone would pass even if the reset had wrongly cleared it. + var otherRow = Assert.Single( + (List)await new Usage(db, ctx).Handle(otherId, new RetryPolicyUsageRequest())); + Assert.Equal(1, otherRow.AttemptsUsed); } [Fact] @@ -649,4 +710,158 @@ public async Task Test_reports_no_match_when_no_group_applies() Assert.Null(response.Attempts[0].MatchedGroupName); } + // ─── Exhaustion alert claim ───────────────────────────────────────────────── + + [Fact] + public async Task Exhausting_a_budget_claims_the_alert_exactly_once() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7101, "Alert Claim Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + var sub = new Subscription("Alert Claim Sub", doc.Id); + db.Set().Add(sub); + await db.SaveChangesAsync(); + + var groupId = Guid.NewGuid(); + var budget = new RetryGroupBudget(db, scope.ServiceProvider, sub.Id); + + // Spending the budget never alerts — nothing has been refused yet. + for (var i = 0; i < 3; i++) + { + var spending = await budget.TryConsume(groupId, 3); + Assert.True(spending.Granted); + Assert.False(spending.JustExhausted); + } + + // The first refusal owns the alert. + var first = await budget.TryConsume(groupId, 3); + Assert.False(first.Granted); + Assert.True(first.JustExhausted); + + // Every refusal after it stays quiet, however many failures arrive. + var second = await budget.TryConsume(groupId, 3); + Assert.False(second.Granted); + Assert.False(second.JustExhausted); + + var usage = await db.Set().AsNoTracking() + .SingleAsync(u => u.SubscriptionId == sub.Id && u.GroupId == groupId); + Assert.NotNull(usage.ExhaustedNotifiedOn); + } + + [Fact] + public async Task Concurrent_refusals_claim_the_alert_only_once() + { + await using var setupScope = _fixture.CreateScope(); + var setupDb = setupScope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7102, "Alert Race Doc"); + setupDb.Set().Add(doc); + await setupDb.SaveChangesAsync(); + + var sub = new Subscription("Alert Race Sub", doc.Id); + setupDb.Set().Add(sub); + await setupDb.SaveChangesAsync(); + + var groupId = Guid.NewGuid(); + await new RetryGroupBudget(setupDb, setupScope.ServiceProvider, sub.Id).TryConsume(groupId, 1); + + // Several instances can discover the empty budget in the same instant; a read-then-write + // would let each of them decide it was the first and send its own email. + var tasks = Enumerable.Range(0, 12).Select(async _ => + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await new RetryGroupBudget(db, scope.ServiceProvider, sub.Id).TryConsume(groupId, 1); + }); + + var claims = await Task.WhenAll(tasks); + + Assert.Equal(1, claims.Count(c => c.JustExhausted)); + Assert.DoesNotContain(claims, c => c.Granted); + } + + [Fact] + public async Task Resetting_usage_re_arms_the_exhaustion_alert() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7103, "Alert Rearm Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + var policyId = (int)await new Create(db, ctx).Handle(SimplePolicy("Alert Rearm Policy")); + var saved = await db.Set().AsNoTracking().SingleAsync(p => p.Id == policyId); + var groupId = saved.Groups[0].Id; + + var sub = new Subscription("Alert Rearm Sub", doc.Id); + db.Set().Add(sub); + await db.SaveChangesAsync(); + sub.SetRetryPolicy(policyId, null); + await db.SaveChangesAsync(); + + var budget = new RetryGroupBudget(db, scope.ServiceProvider, sub.Id); + for (var i = 0; i < 10; i++) await budget.TryConsume(groupId, 10); + Assert.True((await budget.TryConsume(groupId, 10)).JustExhausted); + + await new ResetUsage(db, ctx).Handle(policyId, new RetryPolicyResetUsage + { + SubscriptionId = sub.Id, + GroupId = groupId + }); + + // Reset deletes the row, so the budget and its alert come back together. + for (var i = 0; i < 10; i++) await budget.TryConsume(groupId, 10); + Assert.True((await budget.TryConsume(groupId, 10)).JustExhausted); + } + + // ─── Alert config validation ─────────────────────────────────────────────── + + [Fact] + public async Task Cannot_save_a_group_that_sends_its_own_alert_without_a_handler() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var model = SimplePolicy("Alert Validation Policy"); + model.Groups = + [ + new RetryGroup + { + Name = model.Groups[0].Name, + Priority = model.Groups[0].Priority, + AppliesTo = model.Groups[0].AppliesTo, + Matchers = model.Groups[0].Matchers, + Budget = model.Groups[0].Budget, + AlertMode = RetryAlertMode.Send + } + ]; + + await Assert.ThrowsAsync(() => new Create(db, ctx).Handle(model)); + } + + [Fact] + public async Task Policy_alert_handler_round_trips() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var model = SimplePolicy("Alert Handler Policy"); + model.AlertHandlerId = "native.smtp"; + model.AlertHandlerProperties = new Dictionary { ["to"] = "ops@example.com" }; + + var policyId = (int)await new Create(db, ctx).Handle(model); + var loaded = (RetryPolicyUpdate)await new Get(db).Handle(policyId); + + Assert.Equal("native.smtp", loaded.AlertHandlerId); + Assert.Equal("ops@example.com", loaded.AlertHandlerProperties["to"]); + } + } diff --git a/SW.Bitween.MsSql/Migrations/20260817103506_RetryBudgetAlerts.Designer.cs b/SW.Bitween.MsSql/Migrations/20260817103506_RetryBudgetAlerts.Designer.cs new file mode 100644 index 00000000..a504f677 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260817103506_RetryBudgetAlerts.Designer.cs @@ -0,0 +1,1956 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SW.Bitween.MsSql; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20260817103506_RetryBudgetAlerts")] + partial class RetryBudgetAlerts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.Property("Disabled") + .HasColumnType("bit"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("LockoutEnd") + .HasColumnType("datetime2"); + + b.Property("LoginMethods") + .HasColumnType("tinyint"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("[Email] IS NOT NULL"); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("LoginMethod") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("On") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("BusEnabled") + .HasColumnType("bit"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("bit"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasFilter("[BusMessageTypeName] IS NOT NULL"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("StateAfter") + .HasColumnType("nvarchar(max)"); + + b.Property("StateBefore") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DocumentId"); + + b.ToTable("DocumentTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Values") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("bit"); + + b.Property("RunOnFailedResult") + .HasColumnType("bit"); + + b.Property("RunOnSubscriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("bit"); + + b.Property("Data") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .HasColumnType("nvarchar(max)"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("AlertMode") + .HasColumnType("tinyint"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryAlertOverrides", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptsUsed") + .HasColumnType("int"); + + b.Property("ExhaustedNotifiedOn") + .HasColumnType("datetime2"); + + b.Property("LastAttemptOn") + .HasColumnType("datetime2"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryGroupUsages", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Groups") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime2"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentFilter") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("IsRunning") + .HasColumnType("bit"); + + b.Property("LastException") + .HasColumnType("nvarchar(max)"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedOn") + .HasColumnType("datetime2"); + + b.Property("ReceiveOn") + .HasColumnType("datetime2"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("Temporary") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("tinyint"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[Code] IS NOT NULL"); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("StateAfter") + .HasColumnType("nvarchar(max)"); + + b.Property("StateBefore") + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("SubscriptionTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Options") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime2"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DeliveredOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredOn"); + + b.ToTable("XchangeDeliveries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("nvarchar(max)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("PropertiesRaw") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("PropertiesRaw"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AttemptNumber") + .HasColumnType("int"); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("OutputBad") + .HasColumnType("bit"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("bit"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RetryGroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("RetryGroupId"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("bit"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("nvarchar(max)") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("nvarchar(max)") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime2") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("bit") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("varbinary(max)") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("varbinary(max)") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("nvarchar(450)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bit") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bit") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bit") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bit") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("nvarchar(450)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("nvarchar(450)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bit") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bit") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("nvarchar(450)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("nvarchar(450)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("nvarchar(450)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("nvarchar(450)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("varbinary(max)") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("nvarchar(450)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", "dbo"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("bit"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/20260817103506_RetryBudgetAlerts.cs b/SW.Bitween.MsSql/Migrations/20260817103506_RetryBudgetAlerts.cs new file mode 100644 index 00000000..0e9985b4 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260817103506_RetryBudgetAlerts.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class RetryBudgetAlerts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AttemptNumber", + table: "XchangeResults", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "RetryGroupId", + table: "XchangeResults", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AlterColumn( + name: "NotifierId", + table: "XchangeNotifications", + type: "int", + nullable: true, + oldClrType: typeof(int), + oldType: "int"); + + migrationBuilder.AddColumn( + name: "AlertHandlerId", + table: "RetryPolicies", + type: "varchar(200)", + unicode: false, + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "AlertHandlerProperties", + table: "RetryPolicies", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "ExhaustedNotifiedOn", + table: "RetryGroupUsages", + type: "datetime2", + nullable: true); + + migrationBuilder.CreateTable( + name: "RetryAlertOverrides", + columns: table => new + { + SubscriptionId = table.Column(type: "int", nullable: false), + GroupId = table.Column(type: "uniqueidentifier", nullable: false), + AlertMode = table.Column(type: "tinyint", nullable: false), + AlertHandlerId = table.Column(type: "varchar(200)", unicode: false, maxLength: 200, nullable: true), + AlertHandlerProperties = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RetryAlertOverrides", x => new { x.SubscriptionId, x.GroupId }); + }); + + migrationBuilder.CreateIndex( + name: "IX_XchangeResults_RetryGroupId", + table: "XchangeResults", + column: "RetryGroupId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RetryAlertOverrides"); + + migrationBuilder.DropIndex( + name: "IX_XchangeResults_RetryGroupId", + table: "XchangeResults"); + + migrationBuilder.DropColumn( + name: "AttemptNumber", + table: "XchangeResults"); + + migrationBuilder.DropColumn( + name: "RetryGroupId", + table: "XchangeResults"); + + migrationBuilder.DropColumn( + name: "AlertHandlerId", + table: "RetryPolicies"); + + migrationBuilder.DropColumn( + name: "AlertHandlerProperties", + table: "RetryPolicies"); + + migrationBuilder.DropColumn( + name: "ExhaustedNotifiedOn", + table: "RetryGroupUsages"); + + migrationBuilder.AlterColumn( + name: "NotifierId", + table: "XchangeNotifications", + type: "int", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "int", + oldNullable: true); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs index 6a1ccc3a..84d75fcb 100644 --- a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -502,6 +502,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("AlertMode") + .HasColumnType("tinyint"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryAlertOverrides", (string)null); + }); + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => { b.Property("SubscriptionId") @@ -513,6 +537,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AttemptsUsed") .HasColumnType("int"); + b.Property("ExhaustedNotifiedOn") + .HasColumnType("datetime2"); + b.Property("LastAttemptOn") .HasColumnType("datetime2"); @@ -529,6 +556,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("nvarchar(max)"); + b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); @@ -918,7 +953,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FinishedOn") .HasColumnType("datetime2"); - b.Property("NotifierId") + b.Property("NotifierId") .HasColumnType("int"); b.Property("NotifierName") @@ -969,6 +1004,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnicode(false) .HasColumnType("varchar(50)"); + b.Property("AttemptNumber") + .HasColumnType("int"); + b.Property("Exception") .HasColumnType("nvarchar(max)"); @@ -1022,11 +1060,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(500) .HasColumnType("nvarchar(500)"); + b.Property("RetryGroupId") + .HasColumnType("uniqueidentifier"); + b.Property("Success") .HasColumnType("bit"); b.HasKey("Id"); + b.HasIndex("RetryGroupId"); + b.ToTable("XchangeResults", (string)null); }); diff --git a/SW.Bitween.MySql/Migrations/20260817103452_RetryBudgetAlerts.Designer.cs b/SW.Bitween.MySql/Migrations/20260817103452_RetryBudgetAlerts.Designer.cs new file mode 100644 index 00000000..087fa89c --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260817103452_RetryBudgetAlerts.Designer.cs @@ -0,0 +1,1953 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SW.Bitween.MySql; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20260817103452_RetryBudgetAlerts")] + partial class RetryBudgetAlerts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Deleted") + .HasColumnType("tinyint(1)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("EmailProvider") + .HasColumnType("tinyint unsigned"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("LockoutEnd") + .HasColumnType("datetime(6)"); + + b.Property("LoginMethods") + .HasColumnType("tinyint unsigned"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Accounts", (string)null); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("LoginMethod") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("On") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("On"); + + b.ToTable("DelayedRetries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("BusEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("tinyint(1)"); + + b.Property("DocumentFormat") + .HasColumnType("int"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("BusMessageTypeName") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Documents", (string)null); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("StateAfter") + .HasColumnType("longtext"); + + b.Property("StateBefore") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DocumentId"); + + b.ToTable("DocumentTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UrlName") + .IsUnique(); + + b.ToTable("ApiGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("ApiGatewayPartners", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("BusGateways", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BusGatewayId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("BusGatewayRoutes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Values") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RunOnBadResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnFailedResult") + .HasColumnType("tinyint(1)"); + + b.Property("RunOnSubscriptions") + .HasColumnType("longtext"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Notifiers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("tinyint(1)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("FileName") + .HasColumnType("longtext"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdapterProperties") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Partners", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("longtext"); + + b.Property("AlertMode") + .HasColumnType("tinyint unsigned"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryAlertOverrides", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("AttemptsUsed") + .HasColumnType("int"); + + b.Property("ExhaustedNotifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("LastAttemptOn") + .HasColumnType("datetime(6)"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryGroupUsages", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("longtext"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Groups") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.ToTable("RetryPolicies", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationForId") + .HasColumnType("int"); + + b.Property("AggregationTarget") + .HasColumnType("tinyint unsigned"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("longtext"); + + b.Property("DocumentFilter") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.Property("LastException") + .HasColumnType("longtext"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedOn") + .HasColumnType("datetime(6)"); + + b.Property("ReceiveOn") + .HasColumnType("datetime(6)"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ReceiverProperties") + .HasColumnType("longtext"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("Temporary") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ValidatorProperties") + .HasColumnType("longtext"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("ResponseSubscriptionId"); + + b.HasIndex("RetryPolicyId"); + + b.HasIndex("WorkGroupId"); + + b.ToTable("Subscriptions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("varchar(255)"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SubscriptionCategory"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Code") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("StateAfter") + .HasColumnType("longtext"); + + b.Property("StateBefore") + .HasColumnType("longtext"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("SubscriptionTrail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Options") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CorrelationId") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("InputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("InputSize") + .HasColumnType("int"); + + b.Property("MapperId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("MapperProperties") + .HasColumnType("longtext"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryFor") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InputHash"); + + b.HasIndex("RetryFor"); + + b.HasIndex("StartedOn"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Xchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AggregatedOn") + .HasColumnType("datetime(6)"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AggregationXchangeId"); + + b.ToTable("XchangeAggregations", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("DeliveredOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredOn"); + + b.ToTable("XchangeDeliveries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("NotifierId") + .HasColumnType("int"); + + b.Property("NotifierName") + .HasColumnType("longtext"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeNotifications", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Hits") + .HasMaxLength(2000) + .IsUnicode(false) + .HasColumnType("varchar(2000)"); + + b.Property("Properties") + .HasColumnType("longtext"); + + b.Property("PropertiesRaw") + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("PropertiesRaw"); + + b.ToTable("XchangePromotedProperties", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("AttemptNumber") + .HasColumnType("int"); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("OutputBad") + .HasColumnType("tinyint(1)"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OutputHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OutputSize") + .HasColumnType("int"); + + b.Property("ResponseBad") + .HasColumnType("tinyint(1)"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ResponseSize") + .HasColumnType("int"); + + b.Property("ResponseXchangeId") + .HasColumnType("longtext"); + + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RetryGroupId") + .HasColumnType("char(36)"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("RetryGroupId"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("tinyint(1)"); + + b.ToTable((string)null); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("longtext") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("longtext") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("datetime(6)") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("tinyint(1)") + .HasColumnName("success"); + + b.HasKey("Id"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("longblob") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_blob_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("longblob") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("QRTZ_calendars", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_cron_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("varchar(200)") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_QRTZ_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_QRTZ_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_QRTZ_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_QRTZ_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_QRTZ_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_QRTZ_ft_trig_nm_gp"); + + b.ToTable("QRTZ_fired_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("tinyint(1)") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("tinyint(1)") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("tinyint(1)") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("tinyint(1)") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("QRTZ_job_details", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("varchar(200)") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("QRTZ_locks", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("QRTZ_paused_trigger_grps", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("varchar(200)") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("QRTZ_scheduler_state", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("tinyint(1)") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("int") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("int") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("varchar(200)") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("varchar(200)") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simprop_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("QRTZ_simple_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("varchar(200)") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("varchar(200)") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("varchar(200)") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("varchar(200)") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("longblob") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("int") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("varchar(200)") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("QRTZ_triggers", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b1.HasKey("PartnerId", "Id"); + + b1.HasIndex("Key") + .IsUnique(); + + b1.ToTable("PartnerApiCredentials", (string)null); + + b1.WithOwner() + .HasForeignKey("PartnerId"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_AggFor"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_Subscriptions_RespSub"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("int"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("tinyint(1)"); + + b1.Property("On") + .HasColumnType("bigint"); + + b1.Property("Recurrence") + .HasColumnType("tinyint unsigned"); + + b1.HasKey("SubscriptionId", "Id"); + + b1.ToTable("SubscriptionSchedules", (string)null); + + b1.WithOwner() + .HasForeignKey("SubscriptionId"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.MySql/Migrations/20260817103452_RetryBudgetAlerts.cs b/SW.Bitween.MySql/Migrations/20260817103452_RetryBudgetAlerts.cs new file mode 100644 index 00000000..aeee014a --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260817103452_RetryBudgetAlerts.cs @@ -0,0 +1,122 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class RetryBudgetAlerts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AttemptNumber", + table: "XchangeResults", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "RetryGroupId", + table: "XchangeResults", + type: "char(36)", + nullable: true, + collation: "ascii_general_ci"); + + migrationBuilder.AlterColumn( + name: "NotifierId", + table: "XchangeNotifications", + type: "int", + nullable: true, + oldClrType: typeof(int), + oldType: "int"); + + migrationBuilder.AddColumn( + name: "AlertHandlerId", + table: "RetryPolicies", + type: "varchar(200)", + unicode: false, + maxLength: 200, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "AlertHandlerProperties", + table: "RetryPolicies", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "ExhaustedNotifiedOn", + table: "RetryGroupUsages", + type: "datetime(6)", + nullable: true); + + migrationBuilder.CreateTable( + name: "RetryAlertOverrides", + columns: table => new + { + SubscriptionId = table.Column(type: "int", nullable: false), + GroupId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + AlertMode = table.Column(type: "tinyint unsigned", nullable: false), + AlertHandlerId = table.Column(type: "varchar(200)", unicode: false, maxLength: 200, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + AlertHandlerProperties = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_RetryAlertOverrides", x => new { x.SubscriptionId, x.GroupId }); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_XchangeResults_RetryGroupId", + table: "XchangeResults", + column: "RetryGroupId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RetryAlertOverrides"); + + migrationBuilder.DropIndex( + name: "IX_XchangeResults_RetryGroupId", + table: "XchangeResults"); + + migrationBuilder.DropColumn( + name: "AttemptNumber", + table: "XchangeResults"); + + migrationBuilder.DropColumn( + name: "RetryGroupId", + table: "XchangeResults"); + + migrationBuilder.DropColumn( + name: "AlertHandlerId", + table: "RetryPolicies"); + + migrationBuilder.DropColumn( + name: "AlertHandlerProperties", + table: "RetryPolicies"); + + migrationBuilder.DropColumn( + name: "ExhaustedNotifiedOn", + table: "RetryGroupUsages"); + + migrationBuilder.AlterColumn( + name: "NotifierId", + table: "XchangeNotifications", + type: "int", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "int", + oldNullable: true); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs index 86644341..7f7faa45 100644 --- a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs @@ -500,6 +500,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("longtext"); + + b.Property("AlertMode") + .HasColumnType("tinyint unsigned"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryAlertOverrides", (string)null); + }); + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => { b.Property("SubscriptionId") @@ -511,6 +535,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AttemptsUsed") .HasColumnType("int"); + b.Property("ExhaustedNotifiedOn") + .HasColumnType("datetime(6)"); + b.Property("LastAttemptOn") .HasColumnType("datetime(6)"); @@ -527,6 +554,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + b.Property("AlertHandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AlertHandlerProperties") + .HasColumnType("longtext"); + b.Property("CreatedBy") .HasColumnType("longtext"); @@ -915,7 +950,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FinishedOn") .HasColumnType("datetime(6)"); - b.Property("NotifierId") + b.Property("NotifierId") .HasColumnType("int"); b.Property("NotifierName") @@ -966,6 +1001,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnicode(false) .HasColumnType("varchar(50)"); + b.Property("AttemptNumber") + .HasColumnType("int"); + b.Property("Exception") .HasColumnType("longtext"); @@ -1019,11 +1057,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(500) .HasColumnType("varchar(500)"); + b.Property("RetryGroupId") + .HasColumnType("char(36)"); + b.Property("Success") .HasColumnType("tinyint(1)"); b.HasKey("Id"); + b.HasIndex("RetryGroupId"); + b.ToTable("XchangeResults", (string)null); }); diff --git a/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs b/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs index eb369695..b535a8db 100644 --- a/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs +++ b/SW.Bitween.NativeAdapters/JsonMapper/ScribanJsonHelper.cs @@ -13,6 +13,37 @@ public static class ScribanJsonHelper /// Renders a Scriban template against the provided input JSON and returns the mapped output JSON. /// ` public static string Render(string scribanTemplate, string inputJson) + { + var rendered = RenderText(scribanTemplate, inputJson); + + // 6. Strip trailing commas that may appear after the last field/element + rendered = Regex.Replace(rendered, @",(\s*[}\]])", "$1"); + + // 7. Parse rendered output — root may be an object OR an array + JToken renderedToken; + try + { + renderedToken = JToken.Parse(rendered); + } + catch (JsonException ex) + { + throw new InvalidOperationException($"Template produced invalid JSON: {ex.Message}\n\nRendered:\n{rendered}"); + } + + // 8. Expand dotted keys into nested objects recursively at all depths + return ExpandDottedKeys(renderedToken).ToString(Formatting.Indented); + } + + /// + /// Renders a Scriban template against the provided input JSON and returns the text as-is, + /// without requiring the result to be JSON. + /// + /// + /// For templates whose output is prose rather than a payload — an email subject or body, say. + /// builds on this and adds the JSON validation and dotted-key expansion + /// that a mapper needs and a sentence does not. + /// + public static string RenderText(string scribanTemplate, string inputJson) { // 1. Parse input JSON — handle both root object and root array var rootToken = JToken.Parse(inputJson); @@ -71,24 +102,7 @@ public static string Render(string scribanTemplate, string inputJson) throw new InvalidOperationException($"Template parse error: {errors}"); } - var rendered = template.Render(context); - - // 6. Strip trailing commas that may appear after the last field/element - rendered = Regex.Replace(rendered, @",(\s*[}\]])", "$1"); - - // 7. Parse rendered output — root may be an object OR an array - JToken renderedToken; - try - { - renderedToken = JToken.Parse(rendered); - } - catch (JsonException ex) - { - throw new InvalidOperationException($"Template produced invalid JSON: {ex.Message}\n\nRendered:\n{rendered}"); - } - - // 8. Expand dotted keys into nested objects recursively at all depths - return ExpandDottedKeys(renderedToken).ToString(Formatting.Indented); + return template.Render(context); } // ─── Helpers ────────────────────────────────────────────────────────────── diff --git a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs index 5f0c882a..1b7f3670 100644 --- a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs +++ b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using SW.Bitween.NativeAdapters.RebexPop3Receiver; using SW.Bitween.NativeAdapters.S3Receiver; using SW.Bitween.NativeAdapters.S3UploadHandler; +using SW.Bitween.NativeAdapters.SmtpHandler; namespace SW.Bitween.NativeAdapters; @@ -42,6 +43,9 @@ public static void AddNativeAdapters(this IServiceCollection serviceCollection, serviceCollection.AddScoped(); serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); serviceCollection.AddScoped(); diff --git a/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs b/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs new file mode 100644 index 00000000..46cc96df --- /dev/null +++ b/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs @@ -0,0 +1,110 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; +using MimeKit.Text; +using Newtonsoft.Json.Linq; +using SW.Bitween.NativeAdapters.JsonMapper; +using SW.PrimitiveTypes; + +namespace SW.Bitween.NativeAdapters.SmtpHandler; + +/// +/// Sends the payload as an email, with the subject and body written as templates over it. +/// +/// +/// Built for cases where the recipient is a person rather than a system — a retry budget running +/// out, a notifier on a failed exchange — which is why the subject and body are templated instead +/// of the payload being emailed raw. A JSON blob in an inbox tells nobody anything. +/// +public class NativeSmtpHandler : INativeInfolinkHandler +{ + private SmtpHandlerInput _options = new(); + + public string Name => "NativeSmtpHandler"; + + public Type StartupValuesType => typeof(SmtpHandlerInput); + + public void InitializeStartupValues(IDictionary settings) + { + _options = settings.ConvertTo(); + } + + public async Task Handle(XchangeFile xchangeFile) + { + var subject = Fill(_options.Subject, xchangeFile.Data); + var body = Fill(_options.Body, xchangeFile.Data); + + var message = new MimeMessage(); + message.From.Add(new MailboxAddress(_options.FromName ?? string.Empty, _options.From)); + message.Subject = subject; + message.Body = new TextPart(_options.IsHtml ? TextFormat.Html : TextFormat.Plain) { Text = body }; + + AddAddresses(message.To, _options.To); + AddAddresses(message.Cc, _options.Cc); + AddAddresses(message.Bcc, _options.Bcc); + + if (message.To.Count == 0 && message.Cc.Count == 0 && message.Bcc.Count == 0) + throw new InvalidOperationException("No recipients were configured for the SMTP handler."); + + using var client = new SmtpClient(); + + // Auto picks STARTTLS or implicit SSL from the port, which is what makes one adapter work + // against 587 and 465 without asking the client which handshake their provider uses. + await client.ConnectAsync(_options.Host, _options.Port, + _options.UseTls ? SecureSocketOptions.Auto : SecureSocketOptions.None); + + // A relay that accepts unauthenticated mail from inside the network is a normal setup, so + // only authenticate when a password was actually supplied. + if (!string.IsNullOrWhiteSpace(_options.Password)) + await client.AuthenticateAsync( + string.IsNullOrWhiteSpace(_options.Username) ? _options.From : _options.Username, + _options.Password); + + await client.SendAsync(message); + await client.DisconnectAsync(true); + + return new XchangeFile(subject, xchangeFile.Filename); + } + + private static void AddAddresses(InternetAddressList list, string? addresses) + { + if (string.IsNullOrWhiteSpace(addresses)) return; + + foreach (var address in addresses.Split(',', StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries)) + list.Add(MailboxAddress.Parse(address)); + } + + /// + /// Renders a template against the payload, or returns it unchanged when the payload is not JSON. + /// + /// + /// Non-JSON payloads are normal for a pipeline handler — a CSV or a flat file on its way out — + /// and those have no fields to substitute. A broken template still throws, so a typo in a + /// placeholder is not quietly emailed as literal text. + /// + internal static string Fill(string template, string payload) + { + if (string.IsNullOrEmpty(template) || !LooksLikeJson(payload)) return template; + + return ScribanJsonHelper.RenderText(template, payload); + } + + private static bool LooksLikeJson(string payload) + { + if (string.IsNullOrWhiteSpace(payload)) return false; + + var trimmed = payload.TrimStart(); + if (trimmed[0] is not ('{' or '[')) return false; + + try + { + JToken.Parse(payload); + return true; + } + catch + { + return false; + } + } +} diff --git a/SW.Bitween.NativeAdapters/SmtpHandler/SmtpHandlerInput.cs b/SW.Bitween.NativeAdapters/SmtpHandler/SmtpHandlerInput.cs new file mode 100644 index 00000000..b190827b --- /dev/null +++ b/SW.Bitween.NativeAdapters/SmtpHandler/SmtpHandlerInput.cs @@ -0,0 +1,59 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace SW.Bitween.NativeAdapters.SmtpHandler; + +public class SmtpHandlerInput +{ + [Required] + [Description("SMTP server hostname.")] + public string Host { get; set; } = string.Empty; + + [DefaultValue(587)] + [Description("SMTP server port. 587 for STARTTLS, 465 for implicit SSL, 25 for an unencrypted relay.")] + public int Port { get; set; } = 587; + + [Description("SMTP username. Leave empty to authenticate as the From address, or for a relay that needs no credentials.")] + public string? Username { get; set; } + + [Secure] + [Description("SMTP password. Leave empty for a relay that needs no credentials.")] + public string? Password { get; set; } + + [DefaultValue(true)] + [Description("Encrypt the connection, choosing STARTTLS or SSL to match the port. Turn off only for an internal relay with no TLS.")] + public bool UseTls { get; set; } = true; + + [Required] + [Description("Address the message is sent from.")] + public string From { get; set; } = string.Empty; + + [Description("Display name shown beside the From address, e.g. Bitween Alerts.")] + public string? FromName { get; set; } + + [Required] + [Description("Recipients, separated by commas.")] + public string To { get; set; } = string.Empty; + + [Description("Carbon-copy recipients, separated by commas.")] + public string? Cc { get; set; } + + [Description("Blind carbon-copy recipients, separated by commas.")] + public string? Bcc { get; set; } + + [Required] + [Description( + "Subject line. Placeholders in the incoming payload are substituted, e.g. " + + "'Retries stopped for {{ SubscriptionName }}'.")] + public string Subject { get; set; } = string.Empty; + + [Required] + [Description( + "Message body. Uses the same template syntax as the JSON mapper, so payload fields can be " + + "referenced directly, e.g. '{{ GroupName }} used all {{ MaxAttemptsTotal }} retries.'")] + public string Body { get; set; } = string.Empty; + + [DefaultValue(true)] + [Description("Send the body as HTML. Turn off to send it as plain text.")] + public bool IsHtml { get; set; } = true; +} diff --git a/SW.Bitween.PgSql/BitweenDbContext.cs b/SW.Bitween.PgSql/BitweenDbContext.cs index c1998ad3..20a2bcdf 100644 --- a/SW.Bitween.PgSql/BitweenDbContext.cs +++ b/SW.Bitween.PgSql/BitweenDbContext.cs @@ -263,6 +263,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(p => p.ResponseContentType).HasMaxLength(200); b.Property(p => p.OutputContentType).HasMaxLength(200); b.Property(p => p.RetryBlockedReason).HasMaxLength(500); + b.Property(p => p.RetryGroupId); + b.Property(p => p.AttemptNumber); + b.HasIndex(p => p.RetryGroupId); b.HasOne().WithOne().HasForeignKey(p => p.Id).OnDelete(DeleteBehavior.Cascade); }); @@ -361,6 +364,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasKey(p => p.Id); b.Property(p => p.Id).ValueGeneratedOnAdd(); b.Property(p => p.Name).IsRequired().HasMaxLength(200); + b.Property(p => p.AlertHandlerId).HasMaxLength(200); + b.Property(p => p.AlertHandlerProperties).StoreAsJson(); b.Property(p => p.Groups).HasConversion( groups => JsonSerializer.Serialize(groups, _polymorphicOpts), json => JsonSerializer.Deserialize>(json, _polymorphicOpts)!, @@ -394,6 +399,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasKey(p => new { p.SubscriptionId, p.GroupId }); b.Property(p => p.AttemptsUsed); b.Property(p => p.LastAttemptOn); + b.Property(p => p.ExhaustedNotifiedOn); + }); + + modelBuilder.Entity(b => + { + b.HasKey(p => new { p.SubscriptionId, p.GroupId }); + b.Property(p => p.AlertMode).HasConversion(); + b.Property(p => p.AlertHandlerId).HasMaxLength(200); + b.Property(p => p.AlertHandlerProperties).StoreAsJson(); }); modelBuilder.UseSchedulerPostgreSql(Schema); diff --git a/SW.Bitween.PgSql/Migrations/20260817103433_RetryBudgetAlerts.Designer.cs b/SW.Bitween.PgSql/Migrations/20260817103433_RetryBudgetAlerts.Designer.cs new file mode 100644 index 00000000..f8b2d158 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260817103433_RetryBudgetAlerts.Designer.cs @@ -0,0 +1,2242 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SW.Bitween.Model; +using SW.Bitween.PgSql; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20260817103433_RetryBudgetAlerts")] + partial class RetryBudgetAlerts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("infolink") + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("Disabled") + .HasColumnType("boolean") + .HasColumnName("disabled"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("EmailProvider") + .HasColumnType("smallint") + .HasColumnName("email_provider"); + + b.Property("FailedLoginCount") + .HasColumnType("integer") + .HasColumnName("failed_login_count"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("LoginMethods") + .HasColumnType("smallint") + .HasColumnName("login_methods"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Password") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("character varying(500)") + .HasColumnName("password"); + + b.Property("Phone") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("phone"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.ToTable("Accounts", "infolink"); + + b.HasData( + new + { + Id = 9999, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + LoginMethods = (byte)2, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("LoginMethod") + .HasColumnType("smallint") + .HasColumnName("login_method"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_refresh_tokens_account_id"); + + b.ToTable("RefreshTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DelayedRetry", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("On") + .HasColumnType("timestamp with time zone") + .HasColumnName("on"); + + b.HasKey("Id") + .HasName("pk_delayed_retry"); + + b.HasIndex("On") + .HasDatabaseName("ix_delayed_retry_on"); + + b.ToTable("delayed_retry", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Document", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.Property("BusEnabled") + .HasColumnType("boolean") + .HasColumnName("bus_enabled"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bus_message_type_name"); + + b.Property("DisregardsUnfilteredMessages") + .HasColumnType("boolean") + .HasColumnName("disregards_unfiltered_messages"); + + b.Property("DocumentFormat") + .HasColumnType("integer") + .HasColumnName("document_format"); + + b.Property("DuplicateInterval") + .HasColumnType("integer") + .HasColumnName("duplicate_interval"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PromotedProperties") + .HasColumnType("jsonb") + .HasColumnName("promoted_properties"); + + b.HasKey("Id") + .HasName("pk_document"); + + b.HasIndex("BusMessageTypeName") + .IsUnique() + .HasDatabaseName("ix_document_bus_message_type_name"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_document_name"); + + b.ToTable("document", "infolink"); + + b.HasData( + new + { + Id = 10001, + BusEnabled = false, + DocumentFormat = 0, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Code") + .HasColumnType("integer") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("StateAfter") + .HasColumnType("text") + .HasColumnName("state_after"); + + b.Property("StateBefore") + .HasColumnType("text") + .HasColumnName("state_before"); + + b.HasKey("Id") + .HasName("pk_document_trail"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_document_trail_created_on"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_document_trail_document_id"); + + b.ToTable("document_trail", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UrlName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("url_name"); + + b.HasKey("Id") + .HasName("pk_api_gateway"); + + b.HasIndex("UrlName") + .IsUnique() + .HasDatabaseName("ix_api_gateway_url_name"); + + b.ToTable("api_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.Property("ApiGatewayId") + .HasColumnType("integer") + .HasColumnName("api_gateway_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("ApiGatewayId", "PartnerId", "SubscriptionId") + .HasName("pk_api_gateway_partner"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_api_gateway_partner_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_api_gateway_partner_subscription_id"); + + b.ToTable("api_gateway_partner", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_bus_gateway"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_bus_gateway_document_id"); + + b.ToTable("bus_gateway", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusGatewayId") + .HasColumnType("integer") + .HasColumnName("bus_gateway_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_bus_gateway_route"); + + b.HasIndex("BusGatewayId") + .HasDatabaseName("ix_bus_gateway_route_bus_gateway_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_bus_gateway_route_partner_id"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_bus_gateway_route_subscription_id"); + + b.ToTable("bus_gateway_route", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.GlobalAdapterValuesSet", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property>("Values") + .HasColumnType("jsonb") + .HasColumnName("values"); + + b.HasKey("Id") + .HasName("pk_global_adapter_values_set"); + + b.ToTable("global_adapter_values_set", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Notifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RunOnBadResult") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_result"); + + b.Property("RunOnFailedResult") + .HasColumnType("boolean") + .HasColumnName("run_on_failed_result"); + + b.Property("RunOnSubscriptions") + .HasColumnType("integer[]") + .HasColumnName("run_on_subscriptions"); + + b.Property("RunOnSuccessfulResult") + .HasColumnType("boolean") + .HasColumnName("run_on_successful_result"); + + b.HasKey("Id") + .HasName("pk_notifier"); + + b.ToTable("notifier", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OnHoldXchange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BadData") + .HasColumnType("boolean") + .HasColumnName("bad_data"); + + b.Property("Data") + .HasColumnType("text") + .HasColumnName("data"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_on_hold_xchange"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_on_hold_xchange_subscription_id"); + + b.ToTable("on_hold_xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property>("AdapterProperties") + .HasColumnType("jsonb") + .HasColumnName("adapter_properties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_partner"); + + b.ToTable("partner", "infolink"); + + b.HasData( + new + { + Id = 1, + Name = "SYSTEM" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("AlertMode") + .HasColumnType("smallint") + .HasColumnName("alert_mode"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_alert_override"); + + b.ToTable("retry_alert_override", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AttemptsUsed") + .HasColumnType("integer") + .HasColumnName("attempts_used"); + + b.Property("ExhaustedNotifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("exhausted_notified_on"); + + b.Property("LastAttemptOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_on"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_group_usage"); + + b.ToTable("retry_group_usage", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Groups") + .HasColumnType("text") + .HasColumnName("groups"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_retry_policy"); + + b.ToTable("retry_policy", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AggregateOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregate_on"); + + b.Property("AggregationForId") + .HasColumnType("integer") + .HasColumnName("aggregation_for_id"); + + b.Property("AggregationTarget") + .HasColumnType("smallint") + .HasColumnName("aggregation_target"); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasColumnName("category_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CustomRetryPolicy") + .HasColumnType("text") + .HasColumnName("custom_retry_policy"); + + b.Property>("DocumentFilter") + .HasColumnType("jsonb") + .HasColumnName("document_filter"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property>("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property>("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("PausedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_on"); + + b.Property("ReceiveOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("receive_on"); + + b.Property("ReceiverId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("receiver_id"); + + b.Property>("ReceiverProperties") + .HasColumnType("jsonb") + .HasColumnName("receiver_properties"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryPolicyId") + .HasColumnType("integer") + .HasColumnName("retry_policy_id"); + + b.Property("Temporary") + .HasColumnType("boolean") + .HasColumnName("temporary"); + + b.Property("Type") + .HasColumnType("smallint") + .HasColumnName("type"); + + b.Property("ValidatorId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("validator_id"); + + b.Property>("ValidatorProperties") + .HasColumnType("jsonb") + .HasColumnName("validator_properties"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_subscription"); + + b.HasIndex("AggregationForId") + .HasDatabaseName("ix_subscription_aggregation_for_id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_subscription_category_id"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_subscription_document_id"); + + b.HasIndex("PartnerId") + .HasDatabaseName("ix_subscription_partner_id"); + + b.HasIndex("ResponseSubscriptionId") + .HasDatabaseName("ix_subscription_response_subscription_id"); + + b.HasIndex("RetryPolicyId") + .HasDatabaseName("ix_subscription_retry_policy_id"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_subscription_work_group_id"); + + b.ToTable("subscription", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_subscription_category"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_subscription_category_code"); + + b.ToTable("subscription_category", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Code") + .HasColumnType("integer") + .HasColumnName("code"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("StateAfter") + .HasColumnType("text") + .HasColumnName("state_after"); + + b.Property("StateBefore") + .HasColumnType("text") + .HasColumnName("state_before"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_subscription_trail"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_subscription_trail_created_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_subscription_trail_subscription_id"); + + b.ToTable("subscription_trail", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.WorkGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusMessageName") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("bus_message_name"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Options") + .HasColumnType("jsonb") + .HasColumnName("options"); + + b.HasKey("Id") + .HasName("pk_work_group"); + + b.ToTable("work_group", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property>("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("InputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_content_type"); + + b.Property("InputHash") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("input_hash"); + + b.Property("InputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("input_name"); + + b.Property("InputSize") + .HasColumnType("integer") + .HasColumnName("input_size"); + + b.Property("MapperId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mapper_id"); + + b.Property>("MapperProperties") + .HasColumnType("jsonb") + .HasColumnName("mapper_properties"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("response_message_type_name"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("integer") + .HasColumnName("response_subscription_id"); + + b.Property("RetryFor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_for"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_xchange"); + + b.HasIndex("DocumentId") + .HasDatabaseName("ix_xchange_document_id"); + + b.HasIndex("InputHash") + .HasDatabaseName("ix_xchange_input_hash"); + + b.HasIndex("RetryFor") + .HasDatabaseName("ix_xchange_retry_for"); + + b.HasIndex("StartedOn") + .HasDatabaseName("ix_xchange_started_on"); + + b.HasIndex("SubscriptionId") + .HasDatabaseName("ix_xchange_subscription_id"); + + b.ToTable("xchange", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AggregatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("aggregated_on"); + + b.Property("AggregationXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregation_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_aggregation"); + + b.HasIndex("AggregationXchangeId") + .HasDatabaseName("ix_xchange_aggregation_aggregation_xchange_id"); + + b.ToTable("xchange_aggregation", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("DeliveredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered_on"); + + b.HasKey("Id") + .HasName("pk_xchange_delivery"); + + b.HasIndex("DeliveredOn") + .HasDatabaseName("ix_xchange_delivery_delivered_on"); + + b.ToTable("xchange_delivery", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("NotifierId") + .HasColumnType("integer") + .HasColumnName("notifier_id"); + + b.Property("NotifierName") + .HasColumnType("text") + .HasColumnName("notifier_name"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_notification"); + + b.ToTable("xchange_notification", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("Hits") + .HasColumnType("integer[]") + .HasColumnName("hits"); + + b.Property>("Properties") + .HasColumnType("jsonb") + .HasColumnName("properties"); + + b.Property("PropertiesRaw") + .HasColumnType("text") + .HasColumnName("properties_raw"); + + b.HasKey("Id") + .HasName("pk_xchange_promoted_properties"); + + b.HasIndex("PropertiesRaw") + .HasDatabaseName("ix_xchange_promoted_properties_properties_raw"); + + b.ToTable("xchange_promoted_properties", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("OutputBad") + .HasColumnType("boolean") + .HasColumnName("output_bad"); + + b.Property("OutputContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_content_type"); + + b.Property("OutputHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("output_hash"); + + b.Property("OutputName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("output_name"); + + b.Property("OutputSize") + .HasColumnType("integer") + .HasColumnName("output_size"); + + b.Property("ResponseBad") + .HasColumnType("boolean") + .HasColumnName("response_bad"); + + b.Property("ResponseContentType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_content_type"); + + b.Property("ResponseHash") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("response_hash"); + + b.Property("ResponseName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("response_name"); + + b.Property("ResponseSize") + .HasColumnType("integer") + .HasColumnName("response_size"); + + b.Property("ResponseXchangeId") + .HasColumnType("text") + .HasColumnName("response_xchange_id"); + + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("retry_blocked_reason"); + + b.Property("RetryGroupId") + .HasColumnType("uuid") + .HasColumnName("retry_group_id"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + b.HasIndex("RetryGroupId") + .HasDatabaseName("ix_xchange_result_retry_group_id"); + + b.ToTable("xchange_result", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.RunFlagUpdater+RunningResult", b => + { + b.Property("IsRunning") + .HasColumnType("boolean") + .HasColumnName("is_running"); + + b.ToTable("running_result", "infolink"); + + b.ToView(null, (string)null); + }); + + modelBuilder.Entity("SW.Scheduler.JobExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("EndTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time_utc"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FireInstanceId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("fire_instance_id"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobTypeName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_type_name"); + + b.Property("Node") + .IsRequired() + .HasColumnType("text") + .HasColumnName("node"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time_utc"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_job_executions"); + + b.HasIndex("FireInstanceId") + .IsUnique() + .HasDatabaseName("idx_je_fire_instance_id"); + + b.HasIndex("StartTimeUtc") + .HasDatabaseName("idx_je_start_time"); + + b.HasIndex("Success") + .HasDatabaseName("idx_je_success"); + + b.HasIndex("JobGroup", "JobName", "StartTimeUtc") + .HasDatabaseName("idx_je_group_name_start"); + + b.ToTable("job_executions", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_blob_triggers"); + + b.ToTable("qrtz_blob_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName") + .HasName("pk_qrtz_calendars"); + + b.ToTable("qrtz_calendars", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_cron_triggers"); + + b.ToTable("qrtz_cron_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId") + .HasName("pk_qrtz_fired_triggers"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup") + .HasName("pk_qrtz_job_details"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_j_req_recovery"); + + b.ToTable("qrtz_job_details", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName") + .HasName("pk_qrtz_locks"); + + b.ToTable("qrtz_locks", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup") + .HasName("pk_qrtz_paused_trigger_grps"); + + b.ToTable("qrtz_paused_trigger_grps", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName") + .HasName("pk_qrtz_scheduler_state"); + + b.ToTable("qrtz_scheduler_state", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simprop_triggers"); + + b.ToTable("qrtz_simprop_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_simple_triggers"); + + b.ToTable("qrtz_simple_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("integer") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup") + .HasName("pk_qrtz_triggers"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup") + .HasDatabaseName("ix_qrtz_triggers_sched_name_job_name_job_group"); + + b.ToTable("qrtz_triggers", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.RefreshToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DocumentTrail", b => + { + b.HasOne("SW.Bitween.Domain.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_document_trail_document_document_id"); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGatewayPartner", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.ApiGateway", "ApiGateway") + .WithMany("Partners") + .HasForeignKey("ApiGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_api_gateway_api_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_api_gateway_partner_subscription_subscription_id"); + + b.Navigation("ApiGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGatewayRoute", b => + { + b.HasOne("SW.Bitween.Domain.Gateway.BusGateway", "BusGateway") + .WithMany("Routes") + .HasForeignKey("BusGatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_bus_gateway_bus_gateway_id"); + + b.HasOne("SW.Bitween.Domain.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_bus_gateway_route_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_route_subscription_subscription_id"); + + b.Navigation("BusGateway"); + + b.Navigation("Partner"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.OwnsMany("SW.Bitween.Domain.ApiCredential", "ApiCredentials", b1 => + { + b1.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b1.HasKey("PartnerId", "Id") + .HasName("pk_partner_api_credential"); + + b1.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_partner_api_credential_key"); + + b1.ToTable("partner_api_credential", "infolink"); + + b1.WithOwner() + .HasForeignKey("PartnerId") + .HasConstraintName("fk_partner_api_credential_partner_partner_id"); + + b1.HasData( + new + { + PartnerId = 1, + Id = 1, + Key = "7facc758283844b49cc4ffd26a75b1de", + Name = "default" + }); + }); + + b.Navigation("ApiCredentials"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Subscription", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("AggregationForId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_aggregation_for"); + + b.HasOne("SW.Bitween.Domain.SubscriptionCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_subscription_subscription_category_category_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_subscription_document_document_id"); + + b.HasOne("SW.Bitween.Domain.Partner", null) + .WithMany("Subscriptions") + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_partner_partner_id"); + + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("ResponseSubscriptionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_response_subscriber"); + + b.HasOne("SW.Bitween.Domain.RetryPolicy", "RetryPolicy") + .WithMany() + .HasForeignKey("RetryPolicyId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_subscription_retry_policy_retry_policy_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", "WorkGroup") + .WithMany() + .HasForeignKey("WorkGroupId") + .HasConstraintName("fk_subscription_work_group_work_group_id"); + + b.OwnsMany("SW.Bitween.Domain.Schedule", "Schedules", b1 => + { + b1.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Backwards") + .HasColumnType("boolean") + .HasColumnName("backwards"); + + b1.Property("On") + .HasColumnType("bigint") + .HasColumnName("on"); + + b1.Property("Recurrence") + .HasColumnType("smallint") + .HasColumnName("recurrence"); + + b1.HasKey("SubscriptionId", "Id") + .HasName("pk_subscription_schedule"); + + b1.ToTable("subscription_schedule", "infolink"); + + b1.WithOwner() + .HasForeignKey("SubscriptionId") + .HasConstraintName("fk_subscription_schedule_subscription_subscription_id"); + }); + + b.Navigation("Category"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionTrail", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subscription_trail_subscription_subscription_id"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Xchange", b => + { + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_xchange_document_document_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeAggregation", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeAggregation", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_aggregation_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeDelivery", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeDelivery", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_delivery_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangePromotedProperties", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_promoted_properties_xchange_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeResult", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeResult", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_result_xchange_id"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzBlobTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_blob_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzCronTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_cron_triggers_qrtz_triggers_sched_name_trigger_name_tr"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimplePropertyTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simprop_triggers_qrtz_triggers_sched_name_trigger_name"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzSimpleTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_simple_triggers_qrtz_triggers_sched_name_trigger_name_"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.HasOne("SW.Scheduler.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qrtz_triggers_qrtz_job_details_sched_name_job_name_job_group"); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Navigation("Partners"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.BusGateway", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Partner", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("SW.Scheduler.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20260817103433_RetryBudgetAlerts.cs b/SW.Bitween.PgSql/Migrations/20260817103433_RetryBudgetAlerts.cs new file mode 100644 index 00000000..c1a52246 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260817103433_RetryBudgetAlerts.cs @@ -0,0 +1,131 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + public partial class RetryBudgetAlerts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "attempt_number", + schema: "infolink", + table: "xchange_result", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "retry_group_id", + schema: "infolink", + table: "xchange_result", + type: "uuid", + nullable: true); + + migrationBuilder.AlterColumn( + name: "notifier_id", + schema: "infolink", + table: "xchange_notification", + type: "integer", + nullable: true, + oldClrType: typeof(int), + oldType: "integer"); + + migrationBuilder.AddColumn( + name: "alert_handler_id", + schema: "infolink", + table: "retry_policy", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "alert_handler_properties", + schema: "infolink", + table: "retry_policy", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "exhausted_notified_on", + schema: "infolink", + table: "retry_group_usage", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.CreateTable( + name: "retry_alert_override", + schema: "infolink", + columns: table => new + { + subscription_id = table.Column(type: "integer", nullable: false), + group_id = table.Column(type: "uuid", nullable: false), + alert_mode = table.Column(type: "smallint", nullable: false), + alert_handler_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + alert_handler_properties = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_retry_alert_override", x => new { x.subscription_id, x.group_id }); + }); + + migrationBuilder.CreateIndex( + name: "ix_xchange_result_retry_group_id", + schema: "infolink", + table: "xchange_result", + column: "retry_group_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "retry_alert_override", + schema: "infolink"); + + migrationBuilder.DropIndex( + name: "ix_xchange_result_retry_group_id", + schema: "infolink", + table: "xchange_result"); + + migrationBuilder.DropColumn( + name: "attempt_number", + schema: "infolink", + table: "xchange_result"); + + migrationBuilder.DropColumn( + name: "retry_group_id", + schema: "infolink", + table: "xchange_result"); + + migrationBuilder.DropColumn( + name: "alert_handler_id", + schema: "infolink", + table: "retry_policy"); + + migrationBuilder.DropColumn( + name: "alert_handler_properties", + schema: "infolink", + table: "retry_policy"); + + migrationBuilder.DropColumn( + name: "exhausted_notified_on", + schema: "infolink", + table: "retry_group_usage"); + + migrationBuilder.AlterColumn( + name: "notifier_id", + schema: "infolink", + table: "xchange_notification", + type: "integer", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs index 06188323..9ef35b2f 100644 --- a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -611,6 +611,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => + { + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + + b.Property("AlertMode") + .HasColumnType("smallint") + .HasColumnName("alert_mode"); + + b.HasKey("SubscriptionId", "GroupId") + .HasName("pk_retry_alert_override"); + + b.ToTable("retry_alert_override", "infolink"); + }); + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => { b.Property("SubscriptionId") @@ -625,6 +654,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("attempts_used"); + b.Property("ExhaustedNotifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("exhausted_notified_on"); + b.Property("LastAttemptOn") .HasColumnType("timestamp with time zone") .HasColumnName("last_attempt_on"); @@ -644,6 +677,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("AlertHandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("alert_handler_id"); + + b.Property("AlertHandlerProperties") + .HasColumnType("text") + .HasColumnName("alert_handler_properties"); + b.Property("CreatedBy") .HasColumnType("text") .HasColumnName("created_by"); @@ -1122,7 +1164,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("finished_on"); - b.Property("NotifierId") + b.Property("NotifierId") .HasColumnType("integer") .HasColumnName("notifier_id"); @@ -1181,6 +1223,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(50)") .HasColumnName("id"); + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + b.Property("Exception") .HasColumnType("text") .HasColumnName("exception"); @@ -1244,6 +1290,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(500)") .HasColumnName("retry_blocked_reason"); + b.Property("RetryGroupId") + .HasColumnType("uuid") + .HasColumnName("retry_group_id"); + b.Property("Success") .HasColumnType("boolean") .HasColumnName("success"); @@ -1251,6 +1301,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_xchange_result"); + b.HasIndex("RetryGroupId") + .HasDatabaseName("ix_xchange_result_retry_group_id"); + b.ToTable("xchange_result", "infolink"); }); diff --git a/SW.Bitween.Sdk/Model/AutoRetry/IRetryGroupBudget.cs b/SW.Bitween.Sdk/Model/AutoRetry/IRetryGroupBudget.cs index eddbbeef..06a9a22a 100644 --- a/SW.Bitween.Sdk/Model/AutoRetry/IRetryGroupBudget.cs +++ b/SW.Bitween.Sdk/Model/AutoRetry/IRetryGroupBudget.cs @@ -4,6 +4,29 @@ namespace SW.Bitween.Model; +/// +/// The outcome of asking a 's shared total budget for one attempt. +/// +/// +/// true when a slot was claimed and a retry may be scheduled. +/// +/// +/// true only for the single caller that first found the budget spent, so an +/// exhaustion alert is raised once rather than on every failure that follows. +/// Always false when is true. +/// +public readonly record struct RetryBudgetClaim(bool Granted, bool JustExhausted) +{ + /// A slot was claimed. + public static RetryBudgetClaim Allowed => new(true, false); + + /// No slot available, and someone else has already taken responsibility for alerting. + public static RetryBudgetClaim Denied => new(false, false); + + /// No slot available, and this caller owns the alert for it. + public static RetryBudgetClaim DeniedAndJustExhausted => new(false, true); +} + /// /// Tracks how much of a 's /// has already been spent. @@ -17,11 +40,7 @@ namespace SW.Bitween.Model; public interface IRetryGroupBudget { /// Claims one attempt from the group's total budget. - /// - /// true when a slot was claimed; false when the total is already spent - /// and no further retry may be scheduled for this group. - /// - Task TryConsume(Guid groupId, int maxAttemptsTotal); + Task TryConsume(Guid groupId, int maxAttemptsTotal); } /// @@ -33,12 +52,16 @@ public class InMemoryRetryGroupBudget : IRetryGroupBudget private readonly Dictionary _used = new(); /// - public Task TryConsume(Guid groupId, int maxAttemptsTotal) + /// + /// Never reports : simulating a policy must not + /// send anyone an alert. + /// + public Task TryConsume(Guid groupId, int maxAttemptsTotal) { var used = _used.GetValueOrDefault(groupId, 0); - if (used >= maxAttemptsTotal) return Task.FromResult(false); + if (used >= maxAttemptsTotal) return Task.FromResult(RetryBudgetClaim.Denied); _used[groupId] = used + 1; - return Task.FromResult(true); + return Task.FromResult(RetryBudgetClaim.Allowed); } } diff --git a/SW.Bitween.Sdk/Model/AutoRetry/RetryAlertMode.cs b/SW.Bitween.Sdk/Model/AutoRetry/RetryAlertMode.cs new file mode 100644 index 00000000..bc4d2113 --- /dev/null +++ b/SW.Bitween.Sdk/Model/AutoRetry/RetryAlertMode.cs @@ -0,0 +1,39 @@ +namespace SW.Bitween.Model; + +/// +/// Whether a level of the alert hierarchy defines its own destination for +/// "retry budget exhausted" alerts, or defers to the level above it. +/// +/// +/// The hierarchy is resolved per failing subscription and group, most specific first: +/// the subscription+group override, then the group, then the policy. An overriding level +/// replaces the level above rather than merging into it, so the handler and +/// every property it needs must be present on whichever level wins. +/// +public enum RetryAlertMode +{ + /// Defer to the level above. The default, so existing policies keep behaving as before. + Inherit, + + /// Send through this level's own handler, ignoring anything configured above it. + Send, + + /// Send nothing, and stop the walk — an alert configured above is deliberately suppressed here. + Silent, +} + +/// +/// Which level of the hierarchy decided where an alert goes. Surfaced in the management UI so a +/// destination that looks wrong can be traced to the level that set it. +/// +public enum RetryAlertLevel +{ + /// An override for this one subscription and group. + SubscriptionGroup, + + /// The group's own setting, applying to every subscription using the policy. + Group, + + /// The policy default. + Policy, +} diff --git a/SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs b/SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs index 65c057bc..787e0b4d 100644 --- a/SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs +++ b/SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs @@ -59,6 +59,22 @@ public class RetryGroup /// Optional free-text notes visible in the management UI. public string? Notes { get; init; } + + /// + /// Whether this group defines its own destination for budget-exhausted alerts, suppresses the + /// policy's, or defers to it. Defaults to so groups saved + /// before alerts existed keep using the policy's setting. + /// + public RetryAlertMode AlertMode { get; init; } = RetryAlertMode.Inherit; + + /// + /// Adapter that delivers this group's alert. Required when is + /// , ignored otherwise. + /// + public string? AlertHandlerId { get; init; } + + /// That adapter's own settings — api key, recipients, subject. + public Dictionary? AlertHandlerProperties { get; init; } } /// diff --git a/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs b/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs index efdf77c4..29250b09 100644 --- a/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs +++ b/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs @@ -54,22 +54,24 @@ public async Task Evaluate( return RetryDecision.Block("No matching group (default block)"); if (group.Action == RetryAction.Block) - return RetryDecision.Block($"Group '{group.Name}' explicitly blocks this error"); + return RetryDecision.Block($"Group '{group.Name}' explicitly blocks this error", group); var budget = group.Budget!; if (attemptIndexForThisMessage >= budget.MaxAttemptsPerError) return RetryDecision.Block( - $"Per-message cap reached ({budget.MaxAttemptsPerError}) in group '{group.Name}'"); + $"Per-message cap reached ({budget.MaxAttemptsPerError}) in group '{group.Name}'", group); // Claimed last so a message already stopped by its own per-message cap doesn't // eat a slot out of the shared total. - if (!await groupBudget.TryConsume(group.Id, budget.MaxAttemptsTotal)) + var claim = await groupBudget.TryConsume(group.Id, budget.MaxAttemptsTotal); + if (!claim.Granted) return RetryDecision.Block( - $"Group total cap reached ({budget.MaxAttemptsTotal}) for group '{group.Name}'"); + $"Group total cap reached ({budget.MaxAttemptsTotal}) for group '{group.Name}'", group, + claim.JustExhausted); var delay = budget.DelayStrategy.GetDelay(attemptIndexForThisMessage); - return RetryDecision.Allow(delay, group.Name); + return RetryDecision.Allow(delay, group); } private RetryGroup? FindMatchingGroup(XchangeResultType resultType, string content) @@ -100,22 +102,44 @@ public class RetryDecision /// Human-readable explanation of the decision, useful for audit/debug logs. public string Reason { get; private init; } = ""; - /// Name of the that matched, or null when blocked. - public string? MatchedGroupName { get; private init; } + /// + /// The group that matched this failure, or null when none did. Set on blocked + /// decisions too — internal callers (attempt tracking, exhaustion alerts) need to know which + /// group refused a failure, not only which group allowed one. + /// + public RetryGroup? MatchedGroup { get; private init; } + + /// + /// Name of the that allowed a retry, or null when blocked — + /// including when a group matched but refused. A refusal by a matched group and a failure no + /// group was ever configured to catch should look the same to a caller that only cares whether + /// something is retrying, which is what is for instead. + /// + public string? MatchedGroupName => ShouldRetry ? MatchedGroup?.Name : null; + + /// + /// true when this decision was the one that found the group's shared total spent for + /// the first time, meaning the caller owes an exhaustion alert. Never true twice for + /// the same subscription and group until the budget is reset. + /// + public bool BudgetJustExhausted { get; private init; } /// Creates an Allow decision — a retry will be scheduled after . - public static RetryDecision Allow(TimeSpan delay, string groupName) => new() + public static RetryDecision Allow(TimeSpan delay, RetryGroup group) => new() { ShouldRetry = true, Delay = delay, - MatchedGroupName = groupName, - Reason = $"Allowed by group '{groupName}'" + MatchedGroup = group, + Reason = $"Allowed by group '{group.Name}'" }; /// Creates a Block decision — no retry will be scheduled. - public static RetryDecision Block(string reason) => new() + public static RetryDecision Block(string reason, RetryGroup? group = null, + bool budgetJustExhausted = false) => new() { ShouldRetry = false, - Reason = reason + Reason = reason, + MatchedGroup = group, + BudgetJustExhausted = budgetJustExhausted }; } diff --git a/SW.Bitween.Sdk/Model/RetryBudgetExhaustedNotification.cs b/SW.Bitween.Sdk/Model/RetryBudgetExhaustedNotification.cs new file mode 100644 index 00000000..7e68f8f7 --- /dev/null +++ b/SW.Bitween.Sdk/Model/RetryBudgetExhaustedNotification.cs @@ -0,0 +1,39 @@ +using System; + +namespace SW.Bitween.Model; + +/// +/// The JSON handed to an alert handler when a retry group's shared budget runs out for one +/// subscription, meaning failures matching that group have stopped being retried. +/// +/// +/// Sent once per subscription and group, and not again until the budget is reset — unlike +/// , which is sent per exchange. +/// +public class RetryBudgetExhaustedNotification +{ + /// The failure that found the budget empty. + public string XchangeId { get; set; } + + public int SubscriptionId { get; set; } + public string SubscriptionName { get; set; } + public string DocumentName { get; set; } + public string CorrelationId { get; set; } + + /// Null when the subscription uses an inline policy rather than a named one. + public string PolicyName { get; set; } + + /// The group whose budget is spent — the condition that has stopped being retried. + public string GroupName { get; set; } + + /// The ceiling that was reached. + public int MaxAttemptsTotal { get; set; } + + /// The policy's own words for why this failure was refused. + public string BlockedReason { get; set; } + + /// The failure text of the exchange that hit the empty budget. + public string Exception { get; set; } + + public DateTime OccurredOn { get; set; } +} diff --git a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs index 1a2a0296..4efbf260 100644 --- a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs +++ b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs @@ -7,6 +7,15 @@ public class RetryPolicyCreate { public required string Name { get; set; } public List Groups { get; set; } = []; + + /// + /// Default destination for budget-exhausted alerts, inherited by every group that does not + /// override it. Null means no alert unless a group or a subscription+group override sets one. + /// + public string? AlertHandlerId { get; set; } + + /// That adapter's own settings — api key, recipients, subject. + public Dictionary? AlertHandlerProperties { get; set; } } public class RetryPolicyUpdate : RetryPolicyCreate { } @@ -19,25 +28,89 @@ public class RetryPolicyRow } /// -/// How much of a group's one integration has spent. -/// The total is tracked per integration, so a policy shared by several yields one row each. +/// The whole state of one subscription-and-group pair under a policy: how much of the group's +/// that subscription has spent, and where the pair's +/// budget-exhausted alert goes. /// +/// +/// +/// Both halves are keyed by the same (SubscriptionId, GroupId) pair, which is why they +/// travel together rather than in two reports the reader has to join by eye: the question asked +/// when a budget runs out is "did anyone get told?", and that needs both. +/// +/// +/// A row exists for every pair, including subscriptions that have never failed — an alert override +/// has to be configurable before the first failure, not after. Those rows carry the group's ceiling +/// with nothing spent against it, and a null . +/// +/// public class RetryGroupUsageRow { public int SubscriptionId { get; set; } public string SubscriptionName { get; set; } public Guid GroupId { get; set; } - - /// Null when the group has since been renamed away or removed from the policy. public string GroupName { get; set; } public int AttemptsUsed { get; set; } public int MaxAttemptsTotal { get; set; } - /// True when the budget is spent and this integration will get no further retries. + /// True when the budget is spent and this subscription will get no further retries. public bool Exhausted { get; set; } - public DateTime LastAttemptOn { get; set; } + /// + /// Null when this pair has never failed, which is also how a caller knows there is no counter + /// to reset for it. + /// + public DateTime? LastAttemptOn { get; set; } + + /// + /// When the exhaustion alert was raised, or null if the budget still has room — or ran out + /// before alerts existed. + /// + public DateTime? ExhaustedNotifiedOn { get; set; } + + /// This pair's own override mode. Inherit when no override row exists. + public RetryAlertMode AlertMode { get; set; } + + /// The override's handler, when it defines one. Not the resolved handler. + public string? OverrideHandlerId { get; set; } + + /// That override's own settings. + public Dictionary? OverrideHandlerProperties { get; set; } + + /// Where the alert actually goes, or null when nothing sends for this pair. + public string? ResolvedHandlerId { get; set; } + + /// + /// The winning level's own settings. Carried so that overriding an inherited alert can start + /// from what it currently sends: an override replaces rather than merges, so a handler copied + /// without its properties would save an override that fails at send time. + /// + public Dictionary? ResolvedHandlerProperties { get; set; } + + /// Which level supplied , or null when nothing sends. + public RetryAlertLevel? ResolvedFrom { get; set; } + + /// + /// Which level deliberately switched this pair's alert off, when one did. Resolution returns + /// nothing in that case exactly as it does when no level ever configured an alert, and the two + /// need telling apart: one is a decision, the other is an oversight. + /// + public RetryAlertLevel? SilencedAt { get; set; } +} + +/// +/// Creates, changes or clears the alert override for one subscription and group. Sending +/// removes the override rather than storing a row that does +/// nothing. +/// +public class RetryAlertOverrideSave +{ + public int SubscriptionId { get; set; } + public Guid GroupId { get; set; } + public RetryAlertMode AlertMode { get; set; } + public string? AlertHandlerId { get; set; } + public Dictionary? AlertHandlerProperties { get; set; } } /// Empty request body — the policy is identified by the route key. @@ -47,7 +120,7 @@ public class RetryPolicyUsageRequest /// /// Clears spent budget so a group starts retrying again. Omit both fields to reset every -/// integration and group of the policy. +/// subscription and group of the policy. /// public class RetryPolicyResetUsage { diff --git a/SW.Bitween.UnitTests/NativeSmtpHandlerTests.cs b/SW.Bitween.UnitTests/NativeSmtpHandlerTests.cs new file mode 100644 index 00000000..a747fc75 --- /dev/null +++ b/SW.Bitween.UnitTests/NativeSmtpHandlerTests.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using SW.Bitween.Model; +using SW.Bitween.NativeAdapters; +using SW.Bitween.NativeAdapters.SmtpHandler; + +namespace SW.Bitween.UnitTests; + +[TestClass] +public class NativeSmtpHandlerTests +{ + // ─── Subject and body templating ──────────────────────────────────────────── + + [TestMethod] + public void Fill_SubstitutesPayloadFields() + { + var payload = JsonConvert.SerializeObject(new {GroupName = "timeouts", MaxAttemptsTotal = 8}); + + var result = NativeSmtpHandler.Fill("{{ GroupName }} used all {{ MaxAttemptsTotal }} retries", payload); + + Assert.AreEqual("timeouts used all 8 retries", result); + } + + [TestMethod] + public void Fill_RendersTheRealAlertPayload() + { + // The shape RetryAlertService actually sends, serialised the same way (PascalCase). + var payload = JsonConvert.SerializeObject(new RetryBudgetExhaustedNotification + { + SubscriptionName = "QA - ShipaDelivery - CreateOrder", + GroupName = "FRT charges cannot be found", + MaxAttemptsTotal = 8 + }); + + var result = NativeSmtpHandler.Fill( + "Retries stopped for {{ SubscriptionName }}: {{ GroupName }} ({{ MaxAttemptsTotal }})", payload); + + Assert.AreEqual( + "Retries stopped for QA - ShipaDelivery - CreateOrder: FRT charges cannot be found (8)", result); + } + + [TestMethod] + public void Fill_LeavesTemplateAloneForNonJsonPayload() + { + // Normal for a pipeline handler shipping a flat file — there are no fields to substitute. + Assert.AreEqual("Nightly export", NativeSmtpHandler.Fill("Nightly export", "id,name\n1,alpha")); + } + + [TestMethod] + public void Fill_LeavesTemplateAloneForEmptyPayload() + { + Assert.AreEqual("Nightly export", NativeSmtpHandler.Fill("Nightly export", "")); + } + + [TestMethod] + public void Fill_MissingFieldRendersEmptyRatherThanThePlaceholder() + { + var result = NativeSmtpHandler.Fill("Group: {{ GroupName }}", "{\"Other\":1}"); + + Assert.AreEqual("Group: ", result); + } + + // ─── Startup values ───────────────────────────────────────────────────────── + + [TestMethod] + public void StartupValues_ParseNumbersAndFlags() + { + var input = new Dictionary + { + ["Host"] = "smtp.example.com", + ["Port"] = "465", + ["UseTls"] = "true", + ["IsHtml"] = "false", + ["From"] = "alerts@example.com", + ["To"] = "ops@example.com" + }.ConvertTo(); + + Assert.AreEqual("smtp.example.com", input.Host); + Assert.AreEqual(465, input.Port); + Assert.IsTrue(input.UseTls); + Assert.IsFalse(input.IsHtml); + } + + [TestMethod] + public void StartupValues_KeepDefaultsWhenOmitted() + { + var input = new Dictionary + { + ["Host"] = "smtp.example.com", + ["From"] = "alerts@example.com", + ["To"] = "ops@example.com" + }.ConvertTo(); + + // The common provider setup should need no port or TLS choice at all. + Assert.AreEqual(587, input.Port); + Assert.IsTrue(input.UseTls); + Assert.IsTrue(input.IsHtml); + Assert.IsNull(input.Password); + } +} diff --git a/SW.Bitween.UnitTests/RetryAlertResolverTests.cs b/SW.Bitween.UnitTests/RetryAlertResolverTests.cs new file mode 100644 index 00000000..c2a2a29d --- /dev/null +++ b/SW.Bitween.UnitTests/RetryAlertResolverTests.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Domain; +using SW.Bitween.Model; + +namespace SW.Bitween.UnitTests; + +[TestClass] +public class RetryAlertResolverTests +{ + // ─── Helpers ──────────────────────────────────────────────────────────────── + + private static RetryGroup Group(RetryAlertMode mode = RetryAlertMode.Inherit, string handler = null) => + new() + { + Name = "timeouts", + AppliesTo = [XchangeResultType.Error], + AlertMode = mode, + AlertHandlerId = handler, + AlertHandlerProperties = handler == null ? null : new Dictionary { ["to"] = "group@x" } + }; + + private static RetryPolicy Policy(string handler = null) => new() + { + Name = "policy", + AlertHandlerId = handler, + AlertHandlerProperties = handler == null ? null : new Dictionary { ["to"] = "policy@x" } + }; + + private static RetryAlertOverride Override(RetryAlertMode mode, string handler = null) => new() + { + SubscriptionId = 1, + AlertMode = mode, + AlertHandlerId = handler, + AlertHandlerProperties = handler == null ? null : new Dictionary { ["to"] = "sub@x" } + }; + + // ─── Nothing configured ───────────────────────────────────────────────────── + + [TestMethod] + public void NoLevelConfigured_ResolvesToNothing() + { + Assert.IsNull(RetryAlertResolver.Resolve(null, Group(), Policy())); + } + + // ─── Policy level ─────────────────────────────────────────────────────────── + + [TestMethod] + public void PolicyOnly_ResolvesToPolicy() + { + var target = RetryAlertResolver.Resolve(null, Group(), Policy("native.smtp")); + + Assert.IsNotNull(target); + Assert.AreEqual("native.smtp", target.HandlerId); + Assert.AreEqual(RetryAlertLevel.Policy, target.Level); + Assert.AreEqual("policy@x", target.HandlerProperties["to"]); + } + + // ─── Group level ──────────────────────────────────────────────────────────── + + [TestMethod] + public void GroupSend_ReplacesPolicyEntirely() + { + var target = RetryAlertResolver.Resolve(null, + Group(RetryAlertMode.Send, "native.teams"), Policy("native.smtp")); + + Assert.AreEqual("native.teams", target.HandlerId); + Assert.AreEqual(RetryAlertLevel.Group, target.Level); + // Replace, not merge: nothing of the policy's own properties survives. + Assert.AreEqual("group@x", target.HandlerProperties["to"]); + } + + [TestMethod] + public void GroupSilent_SuppressesPolicyAlert() + { + Assert.IsNull(RetryAlertResolver.Resolve(null, + Group(RetryAlertMode.Silent), Policy("native.smtp"))); + } + + [TestMethod] + public void GroupInherit_FallsThroughToPolicy() + { + var target = RetryAlertResolver.Resolve(null, + Group(RetryAlertMode.Inherit), Policy("native.smtp")); + + Assert.AreEqual(RetryAlertLevel.Policy, target.Level); + } + + // ─── Subscription + group level ───────────────────────────────────────────── + + [TestMethod] + public void SubscriptionOverrideSend_WinsOverGroupAndPolicy() + { + var target = RetryAlertResolver.Resolve( + Override(RetryAlertMode.Send, "native.webhook"), + Group(RetryAlertMode.Send, "native.teams"), + Policy("native.smtp")); + + Assert.AreEqual("native.webhook", target.HandlerId); + Assert.AreEqual(RetryAlertLevel.SubscriptionGroup, target.Level); + Assert.AreEqual("sub@x", target.HandlerProperties["to"]); + } + + [TestMethod] + public void SubscriptionOverrideSilent_SuppressesEverythingAbove() + { + Assert.IsNull(RetryAlertResolver.Resolve( + Override(RetryAlertMode.Silent), + Group(RetryAlertMode.Send, "native.teams"), + Policy("native.smtp"))); + } + + [TestMethod] + public void SubscriptionOverrideInherit_FallsThroughToGroup() + { + var target = RetryAlertResolver.Resolve( + Override(RetryAlertMode.Inherit), + Group(RetryAlertMode.Send, "native.teams"), + Policy("native.smtp")); + + Assert.AreEqual(RetryAlertLevel.Group, target.Level); + } + + // ─── Edge cases ───────────────────────────────────────────────────────────── + + [TestMethod] + public void InlineCustomPolicy_HasNoPolicyLevel_ButGroupStillSends() + { + // A subscription with a CustomRetryPolicy has no policy row at all. + var target = RetryAlertResolver.Resolve(null, Group(RetryAlertMode.Send, "native.teams"), null); + + Assert.AreEqual(RetryAlertLevel.Group, target.Level); + } + + [TestMethod] + public void InlineCustomPolicy_WithInheritingGroup_ResolvesToNothing() + { + Assert.IsNull(RetryAlertResolver.Resolve(null, Group(), null)); + } + + [TestMethod] + public void MissingGroup_StillFallsBackToPolicy() + { + // The group was removed from the policy between the failure and the send. + var target = RetryAlertResolver.Resolve(null, null, Policy("native.smtp")); + + Assert.AreEqual(RetryAlertLevel.Policy, target.Level); + } + + [TestMethod] + public void SendWithNoHandler_FallsThroughRatherThanSilencing() + { + // Validation rejects this on save, so it only exists on rows written before that guard. + // Falling through is more useful than silently sending nothing. + var target = RetryAlertResolver.Resolve( + Override(RetryAlertMode.Send), + Group(RetryAlertMode.Send), + Policy("native.smtp")); + + Assert.AreEqual(RetryAlertLevel.Policy, target.Level); + } +} From 0b9295dedae1550523f672d44d0954f66302fb7f Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 18 Aug 2026 13:33:34 +0300 Subject: [PATCH 2/5] List the failures behind a retry group's spent budget --- .../Resources/RetryPolicies/Attempts.cs | 88 +++++++++++++++ .../Tests/RetryPolicyTests.cs | 100 ++++++++++++++++++ SW.Bitween.Sdk/Model/RetryPolicyModel.cs | 49 +++++++++ 3 files changed, 237 insertions(+) create mode 100644 SW.Bitween.Api/Resources/RetryPolicies/Attempts.cs diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Attempts.cs b/SW.Bitween.Api/Resources/RetryPolicies/Attempts.cs new file mode 100644 index 00000000..fe7d8ba0 --- /dev/null +++ b/SW.Bitween.Api/Resources/RetryPolicies/Attempts.cs @@ -0,0 +1,88 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.RetryPolicies; + +/// +/// Lists the failures one group caught for one subscription — what a row of +/// spent its budget on. +/// +/// +/// +/// Separate from and asked for one pair at a time, because a policy with fifty +/// subscriptions would otherwise pay for fifty of these joins to answer a question about one row. +/// +/// +/// Only failures carrying a group id appear, so nothing recorded before the group was stamped onto +/// results is listed. A pair whose counter is well spent can therefore come back empty, which is +/// why is the count of what is listable rather than the +/// counter's own value. +/// +/// +[HandlerName("attempts")] +public class Attempts : ICommandHandler +{ + /// + /// Enough to show what keeps failing without turning one table row into a page. The caller is + /// told the total, so a short list never reads as the whole story. + /// + private const int Limit = 10; + + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public Attempts(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(int key, RetryGroupAttemptsRequest request) + { + _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + + // The pair has to belong to the policy in the route: that is what the caller asked about, + // and it keeps this from becoming a way to read any subscription's failures through any + // policy id. + var belongs = await _dbContext.Set().AsNoTracking() + .AnyAsync(s => s.Id == request.SubscriptionId && s.RetryPolicyId == key); + if (!belongs) throw new SWNotFoundException($"{key}/{request.SubscriptionId}"); + + var query = from result in _dbContext.Set() + join xchange in _dbContext.Set() on result.Id equals xchange.Id + join pending in _dbContext.Set() on result.Id equals pending.Id into scheduled + from pending in scheduled.DefaultIfEmpty() + where xchange.SubscriptionId == request.SubscriptionId + && result.RetryGroupId == request.GroupId + select new RetryGroupAttemptRow + { + XchangeId = result.Id, + AttemptNumber = result.AttemptNumber, + FailedOn = result.FinishedOn, + Exception = result.Exception, + // A row survives here only until its retry runs, which is what separates a failure + // still being worked on from one that has been given up. + RetryPending = pending != null, + RetryBlockedReason = result.RetryBlockedReason + }; + + query = query.AsNoTracking(); + + return new RetryGroupAttempts + { + Total = await query.CountAsync(), + // Pending first, so the ones still moving cannot be pushed out of the list by a long + // history of failures that are already over. + Attempts = await query + .OrderByDescending(r => r.RetryPending) + .ThenByDescending(r => r.FailedOn) + .Take(Limit) + .ToListAsync() + }; + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs index 262cbb33..4579add6 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs @@ -639,6 +639,106 @@ public async Task Removing_a_group_clears_its_spent_budget() Assert.False(await db.Set().AnyAsync(u => u.GroupId == groupId)); } + // ─── Attempts drill-down ──────────────────────────────────────────────────── + + [Fact] + public async Task Attempts_lists_only_this_pairs_stamped_failures_pending_first() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7012, "Attempts Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + var policyId = (int)await new Create(db, ctx).Handle(SimplePolicy("Attempts Policy")); + var groupId = (await db.Set().AsNoTracking() + .SingleAsync(p => p.Id == policyId)).Groups[0].Id; + + var sub = new Subscription("Attempts Sub", doc.Id); + var otherSub = new Subscription("Attempts Other Sub", doc.Id); + db.Set().AddRange(sub, otherSub); + await db.SaveChangesAsync(); + sub.SetRetryPolicy(policyId, null); + otherSub.SetRetryPolicy(policyId, null); + await db.SaveChangesAsync(); + + // Still being worked on: a scheduled retry is outstanding for it. + var pending = new Xchange(sub, new XchangeFile("{}")); + var pendingResult = new XchangeResult(pending.Id, null, null, exception: "first timeout"); + pendingResult.SetRetryEvaluation(groupId, 0); + + // Given up on, and the reason recorded. + var stopped = new Xchange(sub, new XchangeFile("{}")); + var stoppedResult = new XchangeResult(stopped.Id, null, null, exception: "second timeout"); + stoppedResult.SetRetryEvaluation(groupId, 1); + stoppedResult.SetRetryBlocked("Group 'Timeout' has used all 10 of its total attempts"); + + // Carries no group: this is what every failure recorded before the group was stamped onto + // results looks like, and it has no pair to be listed under. + var unstamped = new Xchange(sub, new XchangeFile("{}")); + var unstampedResult = new XchangeResult(unstamped.Id, null, null, exception: "older timeout"); + + // Same policy and same group, different subscription — a row of its own, not this one's. + var otherPair = new Xchange(otherSub, new XchangeFile("{}")); + var otherPairResult = new XchangeResult(otherPair.Id, null, null, exception: "someone else's timeout"); + otherPairResult.SetRetryEvaluation(groupId, 0); + + db.Set().AddRange(pending, stopped, unstamped, otherPair); + db.Set().AddRange(pendingResult, stoppedResult, unstampedResult, otherPairResult); + db.Set().Add(new DelayedRetry { Id = pending.Id, On = DateTime.UtcNow.AddMinutes(5) }); + await db.SaveChangesAsync(); + + var result = (RetryGroupAttempts)await new Attempts(db, ctx).Handle(policyId, + new RetryGroupAttemptsRequest { SubscriptionId = sub.Id, GroupId = groupId }); + + // Two, not four: the unstamped failure and the other subscription's are both out. + Assert.Equal(2, result.Total); + Assert.Equal(2, result.Attempts.Count); + + // Pending leads, so a long history of finished failures can never push the one still moving + // out of a capped list. + Assert.Equal(pending.Id, result.Attempts[0].XchangeId); + Assert.True(result.Attempts[0].RetryPending); + Assert.Equal(0, result.Attempts[0].AttemptNumber); + Assert.Equal("first timeout", result.Attempts[0].Exception); + Assert.Null(result.Attempts[0].RetryBlockedReason); + + Assert.Equal(stopped.Id, result.Attempts[1].XchangeId); + Assert.False(result.Attempts[1].RetryPending); + Assert.Equal(1, result.Attempts[1].AttemptNumber); + Assert.Contains("used all 10", result.Attempts[1].RetryBlockedReason); + } + + [Fact] + public async Task Attempts_rejects_a_subscription_that_does_not_use_the_policy() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7013, "Attempts Scope Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + var mineId = (int)await new Create(db, ctx).Handle(SimplePolicy("Attempts Scope Mine")); + var theirsId = (int)await new Create(db, ctx).Handle(SimplePolicy("Attempts Scope Theirs")); + var theirGroupId = (await db.Set().AsNoTracking() + .SingleAsync(p => p.Id == theirsId)).Groups[0].Id; + + var theirSub = new Subscription("Attempts Scope Their Sub", doc.Id); + db.Set().Add(theirSub); + await db.SaveChangesAsync(); + theirSub.SetRetryPolicy(theirsId, null); + await db.SaveChangesAsync(); + + // Asking one policy for another policy's subscription must fail rather than quietly answer: + // the route key is what the caller was authorised against. + await Assert.ThrowsAsync(() => new Attempts(db, ctx).Handle(mineId, + new RetryGroupAttemptsRequest { SubscriptionId = theirSub.Id, GroupId = theirGroupId })); + } + // ─── Test / dry-run endpoint ──────────────────────────────────────────────── [Fact] diff --git a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs index 4efbf260..184a81c1 100644 --- a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs +++ b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs @@ -99,6 +99,55 @@ public class RetryGroupUsageRow public RetryAlertLevel? SilencedAt { get; set; } } +/// Asks for one subscription-and-group pair's most recent failures. +public class RetryGroupAttemptsRequest +{ + public int SubscriptionId { get; set; } + public Guid GroupId { get; set; } +} + +/// +/// The failures one group caught for one subscription: what the spent budget on a +/// was actually spent on. +/// +/// +/// Failures are kept for good, while the budget counter is reset, so counts +/// every failure this group has ever caught for this subscription and not the counter's value. +/// Failures recorded before a group was stamped onto them are not counted at all. +/// +public class RetryGroupAttempts +{ + /// How many failures exist, of which carries the latest few. + public int Total { get; set; } + + public List Attempts { get; set; } = []; +} + +public class RetryGroupAttemptRow +{ + /// The failed exchange, so the full input, output and error can be opened. + public string XchangeId { get; set; } + + /// + /// How deep the retry chain was, 0 being the original delivery. Null for failures recorded + /// before the number was stored. + /// + public int? AttemptNumber { get; set; } + + public DateTime FailedOn { get; set; } + + public string Exception { get; set; } + + /// + /// True while another attempt is still scheduled for this failure. The one thing here that is + /// not history: it stops being true the moment the retry runs. + /// + public bool RetryPending { get; set; } + + /// Why no further attempt was scheduled, when the policy refused one. + public string RetryBlockedReason { get; set; } +} + /// /// Creates, changes or clears the alert override for one subscription and group. Sending /// removes the override rather than storing a row that does From 651a69d62a60947e7c7855f3d34f83eeea81a50c Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 18 Aug 2026 14:26:15 +0300 Subject: [PATCH 3/5] Fix budget alert lost after a failed send, and review findings --- .../Resources/RetryPolicies/Attempts.cs | 15 ++- .../Resources/RetryPolicies/Usage.cs | 20 ++-- SW.Bitween.Api/Services/RetryAlertService.cs | 16 +-- .../Tests/RetryAlertServiceTests.cs | 101 +++++++++++++++++- .../Tests/RetryPolicyTests.cs | 11 +- .../20260817103506_RetryBudgetAlerts.cs | 6 ++ .../20260817103452_RetryBudgetAlerts.cs | 6 ++ .../20260817103433_RetryBudgetAlerts.cs | 6 ++ 8 files changed, 158 insertions(+), 23 deletions(-) diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Attempts.cs b/SW.Bitween.Api/Resources/RetryPolicies/Attempts.cs index fe7d8ba0..f16e7cd4 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Attempts.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Attempts.cs @@ -46,9 +46,18 @@ public async Task Handle(int key, RetryGroupAttemptsRequest request) { _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); - // The pair has to belong to the policy in the route: that is what the caller asked about, - // and it keeps this from becoming a way to read any subscription's failures through any - // policy id. + // Both halves of the pair have to belong to the policy in the route. For the subscription + // that keeps this from becoming a way to read any subscription's failures through any + // policy id; for the group it is about the answer being readable — an unknown group would + // otherwise report zero failures, which is indistinguishable from a group that genuinely + // has none. + var policy = await _dbContext.Set().AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == key); + if (policy == null) throw new SWNotFoundException(key.ToString()); + + if (policy.Groups.All(g => g.Id != request.GroupId)) + throw new SWNotFoundException($"{key}/{request.GroupId}"); + var belongs = await _dbContext.Set().AsNoTracking() .AnyAsync(s => s.Id == request.SubscriptionId && s.RetryPolicyId == key); if (!belongs) throw new SWNotFoundException($"{key}/{request.SubscriptionId}"); diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs b/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs index 9f390803..46f82aae 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs @@ -64,19 +64,25 @@ public async Task Handle(int key, RetryPolicyUsageRequest request) // Only groups that allow retries have a budget to spend — and a group that can never spend // one can never exhaust it, so it can never alert either. Listing those would invite - // configuring an alert that cannot fire. - var groups = policy.Groups.Where(g => g.Budget != null).ToList(); + // configuring an alert that cannot fire. A ceiling of zero counts as "never": TryConsume + // denies it outright rather than claiming and exhausting it. + var groups = policy.Groups + .Where(g => g.Budget is { MaxAttemptsTotal: > 0 }) + .ToList(); var rows = new List(); + // Keyed once rather than scanned per pair: both lists are already keyed by exactly this + // pair, and a policy shared by many subscriptions turns the scan into the cost of the + // whole request. + var usageByPair = usages.ToDictionary(u => (u.SubscriptionId, u.GroupId)); + var overrideByPair = overrides.ToDictionary(o => (o.SubscriptionId, o.GroupId)); + foreach (var subscription in subscriptions) foreach (var group in groups) { - var usage = usages.FirstOrDefault( - u => u.SubscriptionId == subscription.Id && u.GroupId == group.Id); - - var subscriptionOverride = overrides.FirstOrDefault( - o => o.SubscriptionId == subscription.Id && o.GroupId == group.Id); + usageByPair.TryGetValue((subscription.Id, group.Id), out var usage); + overrideByPair.TryGetValue((subscription.Id, group.Id), out var subscriptionOverride); var target = RetryAlertResolver.Resolve(subscriptionOverride, group, policy); diff --git a/SW.Bitween.Api/Services/RetryAlertService.cs b/SW.Bitween.Api/Services/RetryAlertService.cs index d6f6182c..3b59515b 100644 --- a/SW.Bitween.Api/Services/RetryAlertService.cs +++ b/SW.Bitween.Api/Services/RetryAlertService.cs @@ -30,10 +30,14 @@ public class RetryAlertService( public async Task Process(RetryBudgetExhaustedEvent message) { // The bus is at-least-once, and the exhaustion is stamped on the exchange rather than on - // the send, so a redelivery would otherwise email the same alert twice. The log row is the - // record that it already went out. + // the send, so a redelivery would otherwise email the same alert twice. A *successful* log + // row is the record that it already went out — matching any alert row would let one failed + // send stand in for a delivery and silence every later attempt. The name is checked too, so + // a row written by some other path can never be mistaken for this alert. var alreadySent = await dbContext.Set() - .AnyAsync(n => n.XchangeId == message.XchangeId && n.NotifierId == null); + .AnyAsync(n => n.XchangeId == message.XchangeId + && n.NotifierName == XchangeNotification.RetryBudgetAlertName + && n.Success); if (alreadySent) return; var subscription = await dbContext.Set() @@ -94,9 +98,9 @@ from result in xr.DefaultIfEmpty() /// Invokes the resolved handler and records the attempt either way. /// /// - /// A throw is logged rather than propagated: rethrowing would send the message to the bus's - /// error queue and, once redelivered, the guard above would suppress the retry anyway. Recording - /// the failure is what lets someone answer "did the alert actually go out?". + /// A throw is logged rather than propagated, and the failure is recorded so someone can answer + /// "did the alert actually go out?". Because the guard above only counts a successful row, a + /// failed send leaves the way open for a redelivery to try again rather than closing it. /// private async Task Send(RetryAlertTarget target, RetryBudgetExhaustedNotification notification, string xchangeId) diff --git a/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs index 418dcf76..31c4388d 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs @@ -27,6 +27,10 @@ namespace SW.Bitween.IntegrationTests.Tests; public class RetryAlertServiceTests { private const string MailHogApi = "http://localhost:8025/api/v2"; + + // MailHog is local and answers instantly or not at all, so the default 100 seconds only ever + // means "this optional test hangs the run". + private static readonly TimeSpan MailHogTimeout = TimeSpan.FromSeconds(5); private readonly BitweenFixture _fixture; public RetryAlertServiceTests(BitweenFixture fixture) @@ -38,7 +42,7 @@ private static async Task MailHogIsReachable() { try { - using var http = new HttpClient(); + using var http = new HttpClient { Timeout = MailHogTimeout }; var response = await http.GetAsync($"{MailHogApi}/messages"); return response.IsSuccessStatusCode; } @@ -52,14 +56,14 @@ private static async Task MailHogIsReachable() // messages behind, making the assertions depend on leftovers from the previous run. private static async Task ClearMailHog() { - using var http = new HttpClient(); + using var http = new HttpClient { Timeout = MailHogTimeout }; var response = await http.DeleteAsync("http://localhost:8025/api/v1/messages"); response.EnsureSuccessStatusCode(); } private static async Task LatestMailHogMessage() { - using var http = new HttpClient(); + using var http = new HttpClient { Timeout = MailHogTimeout }; var json = await http.GetStringAsync($"{MailHogApi}/messages"); using var doc = JsonDocument.Parse(json); var items = doc.RootElement.GetProperty("items").Clone(); @@ -68,7 +72,7 @@ private static async Task ClearMailHog() private static async Task MailHogTotal() { - using var http = new HttpClient(); + using var http = new HttpClient { Timeout = MailHogTimeout }; var json = await http.GetStringAsync($"{MailHogApi}/messages"); using var doc = JsonDocument.Parse(json); return doc.RootElement.GetProperty("total").GetInt32(); @@ -195,4 +199,93 @@ public async Task Exhausted_budget_alert_arrives_in_MailHog_with_the_group_and_s await alertService.Process(raisedEvent); Assert.Equal(totalAfterFirstSend, await MailHogTotal()); } + + [Fact] + public async Task A_failed_send_does_not_stop_a_later_delivery() + { + if (!await MailHogIsReachable()) + return; // Environment doesn't have MailHog running — nothing to verify against. + + await ClearMailHog(); + + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var alertService = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7202, "Failed Send Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + var groupId = Guid.NewGuid(); + var policy = new RetryPolicy + { + Name = "Failed Send Policy", + Groups = + [ + new RetryGroup + { + Id = groupId, + Name = "Timeout", + Priority = 10, + AppliesTo = [XchangeResultType.Error], + Matchers = [new ContainsMatcher { Value = "timeout" }], + Budget = new RetryBudget + { + MaxAttemptsPerError = 1, + MaxAttemptsTotal = 1, + DelayStrategy = new FixedDelayStrategy { DelayMs = 1000 } + }, + AlertMode = RetryAlertMode.Send, + AlertHandlerId = "NativeSmtpHandler", + AlertHandlerProperties = new Dictionary + { + ["Host"] = "localhost", + ["Port"] = "1025", + ["UseTls"] = "false", + ["From"] = "bitween-alerts@example.com", + ["To"] = "ops@example.com", + ["Subject"] = "Retries stopped for {{ SubscriptionName }}", + ["Body"] = "{{ GroupName }} used all {{ MaxAttemptsTotal }} retries." + } + } + ] + }; + db.Set().Add(policy); + await db.SaveChangesAsync(); + + var sub = new Subscription("Failed Send Sub", doc.Id); + db.Set().Add(sub); + await db.SaveChangesAsync(); + sub.SetRetryPolicy(policy.Id, null); + await db.SaveChangesAsync(); + + var xchange = await scope.ServiceProvider.GetRequiredService() + .CreateXchange(sub, new XchangeFile("{}")); + await db.SaveChangesAsync(); + + // Stands in for a first attempt that threw — a dropped connection, a refused relay. Written + // directly because what matters is the row it leaves behind, not how the send failed. + db.Add(XchangeNotification.ForRetryBudgetAlert(xchange.Id, "System.Net.Sockets.SocketException: refused")); + await db.SaveChangesAsync(); + + var xchangeResult = new XchangeResult(xchange.Id, null, null, exception: "timeout"); + xchangeResult.RaiseBudgetExhausted(sub.Id, groupId, "Timeout", 1); + var raisedEvent = Assert.Single(xchangeResult.Events.OfType()); + db.Add(xchangeResult); + await db.SaveChangesAsync(); + + // The recoverable failure must not read as "already delivered": a transient error would + // otherwise silence the alert for good, which is the opposite of what a retry system owes. + await alertService.Process(raisedEvent); + + Assert.Equal(1, await MailHogTotal()); + Assert.True(await db.Set() + .AnyAsync(n => n.XchangeId == xchange.Id + && n.NotifierName == XchangeNotification.RetryBudgetAlertName + && n.Success)); + + // And now that one did get through, the guard has to hold: no third row, no second email. + await alertService.Process(raisedEvent); + Assert.Equal(1, await MailHogTotal()); + } } diff --git a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs index 4579add6..26b8f928 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs @@ -867,7 +867,12 @@ public async Task Concurrent_refusals_claim_the_alert_only_once() await setupDb.SaveChangesAsync(); var groupId = Guid.NewGuid(); - await new RetryGroupBudget(setupDb, setupScope.ServiceProvider, sub.Id).TryConsume(groupId, 1); + + // Spends the only attempt, so every racer below meets an empty budget. Asserted, or a + // failure here would surface as a confusing claim count further down. + var setupClaim = await new RetryGroupBudget(setupDb, setupScope.ServiceProvider, sub.Id) + .TryConsume(groupId, 1); + Assert.True(setupClaim.Granted); // Several instances can discover the empty budget in the same instant; a read-then-write // would let each of them decide it was the first and send its own email. @@ -954,13 +959,13 @@ public async Task Policy_alert_handler_round_trips() var ctx = scope.ServiceProvider.GetRequiredService(); var model = SimplePolicy("Alert Handler Policy"); - model.AlertHandlerId = "native.smtp"; + model.AlertHandlerId = "NativeSmtpHandler"; model.AlertHandlerProperties = new Dictionary { ["to"] = "ops@example.com" }; var policyId = (int)await new Create(db, ctx).Handle(model); var loaded = (RetryPolicyUpdate)await new Get(db).Handle(policyId); - Assert.Equal("native.smtp", loaded.AlertHandlerId); + Assert.Equal("NativeSmtpHandler", loaded.AlertHandlerId); Assert.Equal("ops@example.com", loaded.AlertHandlerProperties["to"]); } diff --git a/SW.Bitween.MsSql/Migrations/20260817103506_RetryBudgetAlerts.cs b/SW.Bitween.MsSql/Migrations/20260817103506_RetryBudgetAlerts.cs index 0e9985b4..e6d3d633 100644 --- a/SW.Bitween.MsSql/Migrations/20260817103506_RetryBudgetAlerts.cs +++ b/SW.Bitween.MsSql/Migrations/20260817103506_RetryBudgetAlerts.cs @@ -102,6 +102,12 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "ExhaustedNotifiedOn", table: "RetryGroupUsages"); + // These rows are the alert's own delivery log, and they are the reason the column was + // made nullable. Rolling the feature back leaves nowhere to put them, and the column + // cannot go back to NOT NULL while they are here, so they go with the feature. + migrationBuilder.Sql( + "DELETE FROM [XchangeNotifications] WHERE [NotifierId] IS NULL;"); + migrationBuilder.AlterColumn( name: "NotifierId", table: "XchangeNotifications", diff --git a/SW.Bitween.MySql/Migrations/20260817103452_RetryBudgetAlerts.cs b/SW.Bitween.MySql/Migrations/20260817103452_RetryBudgetAlerts.cs index aeee014a..278a5f02 100644 --- a/SW.Bitween.MySql/Migrations/20260817103452_RetryBudgetAlerts.cs +++ b/SW.Bitween.MySql/Migrations/20260817103452_RetryBudgetAlerts.cs @@ -108,6 +108,12 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "ExhaustedNotifiedOn", table: "RetryGroupUsages"); + // These rows are the alert's own delivery log, and they are the reason the column was + // made nullable. Rolling the feature back leaves nowhere to put them, and the column + // cannot go back to NOT NULL while they are here, so they go with the feature. + migrationBuilder.Sql( + "DELETE FROM `XchangeNotifications` WHERE `NotifierId` IS NULL;"); + migrationBuilder.AlterColumn( name: "NotifierId", table: "XchangeNotifications", diff --git a/SW.Bitween.PgSql/Migrations/20260817103433_RetryBudgetAlerts.cs b/SW.Bitween.PgSql/Migrations/20260817103433_RetryBudgetAlerts.cs index c1a52246..ed6b8724 100644 --- a/SW.Bitween.PgSql/Migrations/20260817103433_RetryBudgetAlerts.cs +++ b/SW.Bitween.PgSql/Migrations/20260817103433_RetryBudgetAlerts.cs @@ -116,6 +116,12 @@ protected override void Down(MigrationBuilder migrationBuilder) schema: "infolink", table: "retry_group_usage"); + // These rows are the alert's own delivery log, and they are the reason the column was + // made nullable. Rolling the feature back leaves nowhere to put them, and the column + // cannot go back to NOT NULL while they are here, so they go with the feature. + migrationBuilder.Sql( + "DELETE FROM infolink.xchange_notification WHERE notifier_id IS NULL;"); + migrationBuilder.AlterColumn( name: "notifier_id", schema: "infolink", From f78ea1ddedd191897b3d9e246bce610e8c478cf0 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 18 Aug 2026 14:33:24 +0300 Subject: [PATCH 4/5] Require TLS before SMTP auth, and clean up removed groups atomically --- .../Resources/RetryPolicies/Delete.cs | 5 +++ .../Resources/RetryPolicies/Update.cs | 7 ++++ .../Tests/RetryAlertServiceTests.cs | 32 +++++++++++++++++++ .../SmtpHandler/NativeSmtpHandler.cs | 21 +++++++++--- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs b/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs index 0aad6096..23e2b340 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs @@ -33,6 +33,10 @@ public async Task Handle(int key) var policy = await _dbContext.FindAsync(key); var groupIds = policy.Groups.Select(g => g.Id).ToList(); + // One change, one commit — same reasoning as Update: a half-done delete leaves rows keyed + // by groups that no longer exist anywhere, which nothing can then reach. + await using var transaction = await _dbContext.Database.BeginTransactionAsync(); + await _dbContext.DeleteByKeyAsync(key); if (groupIds.Count > 0) @@ -46,6 +50,7 @@ await _dbContext.Set() .ExecuteDeleteAsync(); } + await transaction.CommitAsync(); return null; } } diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Update.cs b/SW.Bitween.Api/Resources/RetryPolicies/Update.cs index 2acf91a7..f8c57afc 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Update.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Update.cs @@ -33,6 +33,12 @@ public async Task Handle(int key, RetryPolicyUpdate model) .Except((model.Groups ?? []).Select(g => g.Id)) .ToList(); + // Dropping the groups and clearing what belonged to them is one change, so it commits as + // one: a group no policy claims whose usage and override rows survive is unreachable from + // the usage report and from reset alike. Safe to span, because RetryPolicy raises no domain + // events — nothing reaches the bus before the commit. + await using var transaction = await _dbContext.Database.BeginTransactionAsync(); + entity.Name = model.Name; entity.Groups = model.Groups ?? []; entity.AlertHandlerId = model.AlertHandlerId; @@ -52,6 +58,7 @@ await _dbContext.Set() .ExecuteDeleteAsync(); } + await transaction.CommitAsync(); return null; } } diff --git a/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs index 31c4388d..35b803e0 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs @@ -288,4 +288,36 @@ public async Task A_failed_send_does_not_stop_a_later_delivery() await alertService.Process(raisedEvent); Assert.Equal(1, await MailHogTotal()); } + + [Fact] + public async Task The_handler_refuses_to_send_a_password_over_an_unencrypted_connection() + { + if (!await MailHogIsReachable()) + return; // Environment doesn't have MailHog running — nothing to verify against. + + await ClearMailHog(); + + await using var scope = _fixture.CreateScope(); + var discovery = scope.ServiceProvider.GetRequiredService(); + + // MailHog speaks plain SMTP on 1025, which is exactly the shape of the mistake worth + // catching: a working relay, no encryption, and a password to hand over. + var handler = discovery.GetNativeHandler("NativeSmtpHandler", new Dictionary + { + ["Host"] = "localhost", + ["Port"] = "1025", + ["UseTls"] = "false", + ["Password"] = "hunter2", + ["From"] = "bitween-alerts@example.com", + ["To"] = "ops@example.com", + ["Subject"] = "Should never be sent", + ["Body"] = "Should never be sent" + }); + + await Assert.ThrowsAsync( + () => handler.Handle(new XchangeFile("{}"))); + + // Refusing has to mean refusing: no message, and therefore no password, left the process. + Assert.Equal(0, await MailHogTotal()); + } } diff --git a/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs b/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs index 46cc96df..4b5baaf9 100644 --- a/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs +++ b/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs @@ -48,17 +48,30 @@ public async Task Handle(XchangeFile xchangeFile) using var client = new SmtpClient(); - // Auto picks STARTTLS or implicit SSL from the port, which is what makes one adapter work - // against 587 and 465 without asking the client which handshake their provider uses. - await client.ConnectAsync(_options.Host, _options.Port, - _options.UseTls ? SecureSocketOptions.Auto : SecureSocketOptions.None); + // Named rather than left to Auto: on any port but 465, Auto means "encrypt if the server + // offers it", so a server that does not offer STARTTLS — or an offer stripped in transit — + // silently continues in the clear. StartTls demands it and fails if it is not there. 465 is + // the implicit-TLS port, where the handshake happens before any of that is negotiable. + var security = _options.UseTls + ? _options.Port == 465 ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTls + : SecureSocketOptions.None; + + await client.ConnectAsync(_options.Host, _options.Port, security); // A relay that accepts unauthenticated mail from inside the network is a normal setup, so // only authenticate when a password was actually supplied. if (!string.IsNullOrWhiteSpace(_options.Password)) + { + // Refusing beats sending the credential over a connection anyone on the path can read. + if (!client.IsSecure) + throw new InvalidOperationException( + "The SMTP handler will not send a password over an unencrypted connection. " + + "Set UseTls to true, or clear the password if the relay does not need one."); + await client.AuthenticateAsync( string.IsNullOrWhiteSpace(_options.Username) ? _options.From : _options.Username, _options.Password); + } await client.SendAsync(message); await client.DisconnectAsync(true); From 1e1d3a9597fc5b41ace79cb9584f91e6015e6de2 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 18 Aug 2026 16:03:34 +0300 Subject: [PATCH 5/5] Keep alert handler secrets out of responses, and reject a password without TLS --- .../Resources/RetryPolicies/Create.cs | 9 +- SW.Bitween.Api/Resources/RetryPolicies/Get.cs | 12 +- .../RetryPolicies/RetryGroupValidation.cs | 42 +++++ .../RetryPolicies/SaveAlertOverride.cs | 27 +++- .../Resources/RetryPolicies/Update.cs | 16 +- .../Resources/RetryPolicies/Usage.cs | 13 +- .../Services/AdapterSecretProperties.cs | 145 +++++++++++++++++ .../Fixtures/BitweenFixture.cs | 1 + .../Tests/RetryPolicyTests.cs | 151 ++++++++++++++++-- SW.Bitween.Web/Startup.cs | 1 + 10 files changed, 392 insertions(+), 25 deletions(-) create mode 100644 SW.Bitween.Api/Services/AdapterSecretProperties.cs diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Create.cs b/SW.Bitween.Api/Resources/RetryPolicies/Create.cs index 0f92b3e1..5d46c418 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Create.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Create.cs @@ -21,13 +21,20 @@ public async Task Handle(RetryPolicyCreate model) { _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); RetryGroupValidation.EnsureCanFire(model.Groups); + RetryGroupValidation.EnsureAlertTransportIsSecure( + model.AlertHandlerId, model.AlertHandlerProperties); + + // A new policy has nothing stored behind a sentinel, so any that arrives — from a policy + // copied out of Get, say — is dropped rather than saved as the literal password. + foreach (var group in model.Groups ?? []) + AdapterSecretProperties.MergeInPlace(null, group.AlertHandlerProperties); var entity = new RetryPolicy { Name = model.Name, Groups = model.Groups ?? [], AlertHandlerId = model.AlertHandlerId, - AlertHandlerProperties = model.AlertHandlerProperties + AlertHandlerProperties = AdapterSecretProperties.Merge(null, model.AlertHandlerProperties) }; _dbContext.Add(entity); await _dbContext.SaveChangesAsync(); diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Get.cs b/SW.Bitween.Api/Resources/RetryPolicies/Get.cs index ad517b1e..8554667a 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Get.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Get.cs @@ -11,10 +11,12 @@ namespace SW.Bitween.Resources.RetryPolicies; public class Get : IGetHandler { private readonly BitweenDbContext _dbContext; + private readonly AdapterSecretProperties _secrets; - public Get(BitweenDbContext dbContext) + public Get(BitweenDbContext dbContext, AdapterSecretProperties secrets) { _dbContext = dbContext; + _secrets = secrets; } public async Task Handle(int key) @@ -28,12 +30,18 @@ public async Task Handle(int key) if (policy == null) return null; + // Every level that can carry a handler can carry that handler's password, so every level is + // masked. Groups are edited in place by the caller, which is what Update then merges back. + foreach (var group in policy.Groups) + await _secrets.MaskInPlace(group.AlertHandlerId, group.AlertHandlerProperties); + return new RetryPolicyUpdate { Name = policy.Name, Groups = policy.Groups, AlertHandlerId = policy.AlertHandlerId, - AlertHandlerProperties = policy.AlertHandlerProperties?.ToDictionary(kv => kv.Key, kv => kv.Value) + AlertHandlerProperties = + await _secrets.Mask(policy.AlertHandlerId, policy.AlertHandlerProperties) }; } } diff --git a/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs b/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs index 787f69dd..fdf72639 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using System.Linq; using SW.Bitween.Model; +using SW.Bitween.NativeAdapters.SmtpHandler; using SW.PrimitiveTypes; namespace SW.Bitween.Resources.RetryPolicies; @@ -39,6 +41,8 @@ public static void EnsureCanFire(IEnumerable groups) throw new SWValidationException("RETRY_GROUP_ALERT_NO_HANDLER", $"Group '{group.Name}' is set to send its own budget alert but has no handler. " + "Choose a handler, or set the alert back to inherit."); + + EnsureAlertTransportIsSecure(group.AlertHandlerId, group.AlertHandlerProperties); } } @@ -54,6 +58,44 @@ public static void EnsureAlertCanSend(RetryAlertMode mode, string handlerId) "Choose a handler, or set it back to inherit."); } + /// + /// Rejects mail alert settings that would hand the password to an unencrypted connection. + /// + /// + /// + /// The handler refuses this at send time too, which is the guarantee that matters — properties + /// can also arrive straight through the API or out of a global values set. Catching it here is + /// so the person configuring it finds out when they save, rather than from a missing alert and a + /// line in the log days later. + /// + /// + /// Only the mail handler is named, because only it has a password. A general answer belongs in + /// the adapter contract — an adapter saying which of its own settings conflict — not here. + /// + /// + public static void EnsureAlertTransportIsSecure( + string handlerId, IReadOnlyDictionary properties) + { + if (properties == null || properties.Count == 0) return; + if (!nameof(NativeSmtpHandler).Equals(handlerId, StringComparison.OrdinalIgnoreCase)) return; + + // A masked password counts as set: the sentinel means one is stored, not that the field is + // empty. Only an explicit "false" turns encryption off — absent means the adapter's own + // default, which is on. + var password = Value(properties, nameof(SmtpHandlerInput.Password)); + var useTls = Value(properties, nameof(SmtpHandlerInput.UseTls)); + + if (string.IsNullOrWhiteSpace(password)) return; + if (!bool.TryParse(useTls, out var encrypted) || encrypted) return; + + throw new SWValidationException("ALERT_PASSWORD_WITHOUT_TLS", + "This alert would send its mail password over an unencrypted connection. " + + "Turn UseTls on, or clear the password if the relay does not need one."); + } + + private static string Value(IReadOnlyDictionary properties, string key) => + properties.FirstOrDefault(kv => kv.Key.Equals(key, StringComparison.OrdinalIgnoreCase)).Value; + private static string SupportedMatchersFor(XchangeResultType resultType) => resultType switch { XchangeResultType.Error => "Error supports Contains, Regex and Exception type matchers.", diff --git a/SW.Bitween.Api/Resources/RetryPolicies/SaveAlertOverride.cs b/SW.Bitween.Api/Resources/RetryPolicies/SaveAlertOverride.cs index b02bc4e2..b96ef108 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/SaveAlertOverride.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/SaveAlertOverride.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -17,17 +18,22 @@ public class SaveAlertOverride : ICommandHandler Handle(int key, RetryAlertOverrideSave request) { _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); RetryGroupValidation.EnsureAlertCanSend(request.AlertMode, request.AlertHandlerId); + RetryGroupValidation.EnsureAlertTransportIsSecure( + request.AlertHandlerId, request.AlertHandlerProperties); var policy = await _dbContext.Set().AsNoTracking() .FirstOrDefaultAsync(p => p.Id == key); @@ -58,6 +64,21 @@ public async Task Handle(int key, RetryAlertOverrideSave request) return null; } + // A masked secret has to be restored from whichever level the caller was shown it at. Usage + // masks two things for a pair: the override's own properties, and the properties of the level + // it currently inherits from. Overriding an inherited alert starts from the second — there is + // no override row yet — so both are offered here, with the override's own winning. + var group = policy.Groups.First(g => g.Id == request.GroupId); + var inherited = RetryAlertResolver.Resolve(existing, group, policy); + + var restoreFrom = new Dictionary(); + foreach (var kv in inherited?.HandlerProperties ?? new Dictionary()) + restoreFrom[kv.Key] = kv.Value; + foreach (var kv in existing?.AlertHandlerProperties ?? new Dictionary()) + restoreFrom[kv.Key] = kv.Value; + + var properties = AdapterSecretProperties.Merge(restoreFrom, request.AlertHandlerProperties); + if (existing == null) { _dbContext.Add(new RetryAlertOverride @@ -66,14 +87,14 @@ public async Task Handle(int key, RetryAlertOverrideSave request) GroupId = request.GroupId, AlertMode = request.AlertMode, AlertHandlerId = request.AlertHandlerId, - AlertHandlerProperties = request.AlertHandlerProperties + AlertHandlerProperties = properties }); } else { existing.AlertMode = request.AlertMode; existing.AlertHandlerId = request.AlertHandlerId; - existing.AlertHandlerProperties = request.AlertHandlerProperties; + existing.AlertHandlerProperties = properties; } await _dbContext.SaveChangesAsync(); diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Update.cs b/SW.Bitween.Api/Resources/RetryPolicies/Update.cs index f8c57afc..f09df49a 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Update.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Update.cs @@ -23,6 +23,8 @@ public async Task Handle(int key, RetryPolicyUpdate model) { _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); RetryGroupValidation.EnsureCanFire(model.Groups); + RetryGroupValidation.EnsureAlertTransportIsSecure( + model.AlertHandlerId, model.AlertHandlerProperties); var entity = await _dbContext.FindAsync(key); @@ -39,10 +41,22 @@ public async Task Handle(int key, RetryPolicyUpdate model) // events — nothing reaches the bus before the commit. await using var transaction = await _dbContext.Database.BeginTransactionAsync(); + // Secrets came out of Get masked, so put them back from what this same level already holds. + // A group matched by id, because a group added in this very save has nothing to restore from. + foreach (var group in model.Groups ?? []) + { + var storedGroup = entity.Groups.FirstOrDefault(g => g.Id == group.Id); + AdapterSecretProperties.MergeInPlace( + storedGroup?.AlertHandlerProperties, group.AlertHandlerProperties); + } + + var storedPolicyProperties = entity.AlertHandlerProperties; + entity.Name = model.Name; entity.Groups = model.Groups ?? []; entity.AlertHandlerId = model.AlertHandlerId; - entity.AlertHandlerProperties = model.AlertHandlerProperties; + entity.AlertHandlerProperties = + AdapterSecretProperties.Merge(storedPolicyProperties, model.AlertHandlerProperties); await _dbContext.SaveChangesAsync(); if (removedGroupIds.Count > 0) diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs b/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs index 46f82aae..aa41a66c 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs @@ -32,11 +32,14 @@ public class Usage : ICommandHandler { private readonly BitweenDbContext _dbContext; private readonly RequestContext _requestContext; + private readonly AdapterSecretProperties _secrets; - public Usage(BitweenDbContext dbContext, RequestContext requestContext) + public Usage(BitweenDbContext dbContext, RequestContext requestContext, + AdapterSecretProperties secrets) { _dbContext = dbContext; _requestContext = requestContext; + _secrets = secrets; } public async Task Handle(int key, RetryPolicyUsageRequest request) @@ -107,11 +110,11 @@ public async Task Handle(int key, RetryPolicyUsageRequest request) ExhaustedNotifiedOn = usage?.ExhaustedNotifiedOn, AlertMode = subscriptionOverride?.AlertMode ?? RetryAlertMode.Inherit, OverrideHandlerId = subscriptionOverride?.AlertHandlerId, - OverrideHandlerProperties = subscriptionOverride?.AlertHandlerProperties - ?.ToDictionary(kv => kv.Key, kv => kv.Value), + OverrideHandlerProperties = await _secrets.Mask( + subscriptionOverride?.AlertHandlerId, subscriptionOverride?.AlertHandlerProperties), ResolvedHandlerId = target?.HandlerId, - ResolvedHandlerProperties = target?.HandlerProperties - ?.ToDictionary(kv => kv.Key, kv => kv.Value), + ResolvedHandlerProperties = await _secrets.Mask( + target?.HandlerId, target?.HandlerProperties), ResolvedFrom = target?.Level, SilencedAt = target == null ? silencedAt : null }); diff --git a/SW.Bitween.Api/Services/AdapterSecretProperties.cs b/SW.Bitween.Api/Services/AdapterSecretProperties.cs new file mode 100644 index 00000000..331f3bde --- /dev/null +++ b/SW.Bitween.Api/Services/AdapterSecretProperties.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween; + +/// +/// Keeps adapter secrets — an api key, a mail password — out of responses, and puts them back when +/// an unchanged one is saved again. +/// +/// +/// +/// An adapter marks a startup value [Secure], which is what +/// reports. replaces those values with on the way out, and +/// reads the sentinel on the way back in as "keep what is stored" — so a form +/// that only changed the subject line does not overwrite the password with a row of dots. +/// +/// +/// The same sentinel and the same pair of steps already guard subscription adapter properties inside +/// Subscriptions/Get and Subscriptions/Update; this is the reusable form of it. +/// +/// +public class AdapterSecretProperties( + NativeAdapterDiscoveryService nativeAdapterDiscovery, + IServiceProvider serviceProvider) +{ + /// Stands in for a secret value in any response that carries adapter properties. + public const string Sentinel = "__private__"; + + // Describing a serverless adapter means starting it and asking, which is far too expensive to + // repeat per row of a report. Scoped service, so the memo lives exactly as long as one request. + private readonly Dictionary> _described = new(); + + /// + /// Returns a copy with every secret value replaced. Values that are already empty are left + /// alone, so "not set" stays distinguishable from "set but hidden". + /// + public async Task> Mask( + string adapterId, IReadOnlyDictionary properties) + { + if (properties == null || properties.Count == 0) + return properties?.ToDictionary(kv => kv.Key, kv => kv.Value); + + // No adapter to ask about: mask nothing rather than guess. There is also nothing to send + // the properties to, so they cannot be credentials in use. + if (string.IsNullOrEmpty(adapterId)) + return properties.ToDictionary(kv => kv.Key, kv => kv.Value); + + IDictionary startupValues; + try + { + startupValues = await Describe(adapterId); + } + catch + { + // Fail closed: when the adapter cannot be described there is no way to tell which value + // is a secret, and guessing wrong one way leaks it. + return properties.ToDictionary(kv => kv.Key, _ => Sentinel); + } + + return properties.ToDictionary(kv => kv.Key, kv => + startupValues.TryGetValue(kv.Key, out var startupValue) + && startupValue.Private + && !string.IsNullOrEmpty(kv.Value) + ? Sentinel + : kv.Value); + } + + /// + /// Resolves the sentinels in against what is already stored. A + /// sentinel with nothing stored under that key is dropped rather than saved literally. + /// + public static Dictionary Merge( + IReadOnlyDictionary stored, IReadOnlyDictionary incoming) + { + if (incoming == null) return null; + + var result = new Dictionary(); + foreach (var kv in incoming) + { + if (kv.Value != Sentinel) + { + result[kv.Key] = kv.Value; + } + else if (stored != null && stored.TryGetValue(kv.Key, out var storedValue)) + { + result[kv.Key] = storedValue; + } + } + return result; + } + + /// + /// applied to the dictionary the caller already holds. + /// + /// + /// is an immutable value object — every property is init — so a + /// group's properties cannot be swapped for a masked copy. Editing the dictionary in place is + /// the way to reach them without either loosening that contract or rebuilding each group + /// property by property, which would silently drop whatever property is added to it next. + /// + public async Task MaskInPlace(string adapterId, Dictionary properties) + { + if (properties == null || properties.Count == 0) return; + + var masked = await Mask(adapterId, properties); + properties.Clear(); + foreach (var kv in masked) properties[kv.Key] = kv.Value; + } + + /// applied to the dictionary the caller already holds. + public static void MergeInPlace( + IReadOnlyDictionary stored, Dictionary incoming) + { + if (incoming == null || incoming.Count == 0) return; + + var merged = Merge(stored, incoming); + incoming.Clear(); + foreach (var kv in merged) incoming[kv.Key] = kv.Value; + } + + private async Task> Describe(string adapterId) + { + if (_described.TryGetValue(adapterId, out var cached)) return cached; + + IDictionary startupValues; + if (adapterId.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase)) + { + startupValues = nativeAdapterDiscovery.GetStartupValues(adapterId); + } + else + { + var serverless = serviceProvider.GetRequiredService(); + await serverless.StartAsync(adapterId, null); + startupValues = await serverless.GetExpectedStartupValues(); + } + + _described[adapterId] = startupValues; + return startupValues; + } +} diff --git a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs index be0a292b..398507d4 100644 --- a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs +++ b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs @@ -98,6 +98,7 @@ public async Task InitializeAsync() services.AddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs index 26b8f928..e90158be 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs @@ -25,10 +25,13 @@ public RetryPolicyTests(BitweenFixture fixture) // ─── Helpers ────────────────────────────────────────────────────────────── + private static AdapterSecretProperties Secrets(AsyncServiceScope scope) => + scope.ServiceProvider.GetRequiredService(); + private static (Create create, Get get, Update update, Delete delete) - Handlers(BitweenDbContext db, RequestContext ctx) => ( + Handlers(BitweenDbContext db, RequestContext ctx, AdapterSecretProperties secrets) => ( new Create(db, ctx), - new Get(db), + new Get(db, secrets), new Update(db, ctx), new Delete(db, ctx)); @@ -61,7 +64,7 @@ public async Task Can_create_and_get_retry_policy() await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); - var (create, get, _, _) = Handlers(db, ctx); + var (create, get, _, _) = Handlers(db, ctx, Secrets(scope)); var id = (int)await create.Handle(SimplePolicy("Round-trip Policy")); @@ -79,7 +82,7 @@ public async Task Create_policy_with_complex_groups_round_trips_json_correctly() await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); - var (create, _, _, _) = Handlers(db, ctx); + var (create, _, _, _) = Handlers(db, ctx, Secrets(scope)); var policy = new RetryPolicyCreate { @@ -131,7 +134,7 @@ public async Task Can_update_retry_policy_name_and_groups() await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); - var (create, _, update, _) = Handlers(db, ctx); + var (create, _, update, _) = Handlers(db, ctx, Secrets(scope)); var id = (int)await create.Handle(SimplePolicy("Before Update")); @@ -170,7 +173,7 @@ public async Task Can_delete_retry_policy_not_assigned_to_any_subscription() await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); - var (create, _, _, delete) = Handlers(db, ctx); + var (create, _, _, delete) = Handlers(db, ctx, Secrets(scope)); var id = (int)await create.Handle(SimplePolicy("Deletable Policy")); @@ -188,7 +191,7 @@ public async Task Cannot_delete_retry_policy_that_is_assigned_to_a_subscription( await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); - var (create, _, _, delete) = Handlers(db, ctx); + var (create, _, _, delete) = Handlers(db, ctx, Secrets(scope)); var doc = new Document(7001, "Delete Guard Doc"); db.Set().Add(doc); @@ -240,7 +243,7 @@ public async Task Subscription_retry_policy_id_is_persisted_and_fk_resolves() await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); - var (create, _, _, _) = Handlers(db, ctx); + var (create, _, _, _) = Handlers(db, ctx, Secrets(scope)); var doc = new Document(7002, "Sub FK Doc"); db.Set().Add(doc); @@ -492,7 +495,7 @@ public async Task Usage_reports_spent_budget_and_reset_clears_it() for (var i = 0; i < 10; i++) await budget.TryConsume(groupId, 10); await db.SaveChangesAsync(); - var rows = (List)await new Usage(db, ctx).Handle(policyId, new RetryPolicyUsageRequest()); + var rows = (List)await new Usage(db, ctx, Secrets(scope)).Handle(policyId, new RetryPolicyUsageRequest()); var row = Assert.Single(rows); Assert.Equal(sub.Id, row.SubscriptionId); Assert.Equal("Usage Sub", row.SubscriptionName); @@ -509,7 +512,7 @@ public async Task Usage_reports_spent_budget_and_reset_clears_it() // The pair keeps its row — every subscription-and-group pair gets one so an alert override // stays configurable before the first failure — but with nothing spent against the ceiling. var afterReset = Assert.Single( - (List)await new Usage(db, ctx).Handle(policyId, new RetryPolicyUsageRequest())); + (List)await new Usage(db, ctx, Secrets(scope)).Handle(policyId, new RetryPolicyUsageRequest())); Assert.Equal(0, afterReset.AttemptsUsed); Assert.False(afterReset.Exhausted); Assert.Null(afterReset.LastAttemptOn); @@ -552,7 +555,7 @@ public async Task Usage_lists_never_failed_pairs_and_skips_groups_that_cannot_ex sub.SetRetryPolicy(policyId, null); await db.SaveChangesAsync(); - var rows = (List)await new Usage(db, ctx) + var rows = (List)await new Usage(db, ctx, Secrets(scope)) .Handle(policyId, new RetryPolicyUsageRequest()); // One row, not two: the pair is reported even though nothing has ever failed — otherwise its @@ -599,7 +602,7 @@ public async Task Reset_does_not_touch_counters_of_another_policy() // A row now exists for every pair whether or not it has failed, so assert the spent counter // itself survived — row count alone would pass even if the reset had wrongly cleared it. var otherRow = Assert.Single( - (List)await new Usage(db, ctx).Handle(otherId, new RetryPolicyUsageRequest())); + (List)await new Usage(db, ctx, Secrets(scope)).Handle(otherId, new RetryPolicyUsageRequest())); Assert.Equal(1, otherRow.AttemptsUsed); } @@ -951,6 +954,128 @@ public async Task Cannot_save_a_group_that_sends_its_own_alert_without_a_handler await Assert.ThrowsAsync(() => new Create(db, ctx).Handle(model)); } + // ─── Alert secrets ────────────────────────────────────────────────────────── + + // What the browser is shown in place of a secret. Spelled out rather than taken from the + // constant: the UI has its own copy of this string, and the two have to stay the same. + private const string Sentinel = "__private__"; + + private static Dictionary SmtpProperties(string password, bool useTls) => new() + { + ["Host"] = "localhost", + ["Port"] = "1025", + ["UseTls"] = useTls ? "true" : "false", + ["Password"] = password, + ["From"] = "bitween-alerts@example.com", + ["To"] = "ops@example.com", + ["Subject"] = "Retries stopped", + ["Body"] = "Budget spent." + }; + + [Fact] + public async Task An_alert_password_is_masked_on_read_and_survives_being_saved_back() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var model = SimplePolicy("Masked Alert Policy"); + model.AlertHandlerId = "NativeSmtpHandler"; + model.AlertHandlerProperties = SmtpProperties("hunter2", useTls: true); + + var policyId = (int)await new Create(db, ctx).Handle(model); + + var loaded = (RetryPolicyUpdate)await new Get(db, Secrets(scope)).Handle(policyId); + + // The password never leaves the server; everything that is not a secret still does, or the + // form would have nothing to show. + Assert.Equal(Sentinel, loaded.AlertHandlerProperties["Password"]); + Assert.Equal("localhost", loaded.AlertHandlerProperties["Host"]); + Assert.Equal("Retries stopped", loaded.AlertHandlerProperties["Subject"]); + + // Exactly what the page does when someone edits the subject and saves: the password comes + // back as the mask, and must not be stored as one. + loaded.AlertHandlerProperties["Subject"] = "Retries stopped for real"; + await new Update(db, ctx).Handle(policyId, new RetryPolicyUpdate + { + Name = loaded.Name, + Groups = loaded.Groups, + AlertHandlerId = loaded.AlertHandlerId, + AlertHandlerProperties = loaded.AlertHandlerProperties + }); + + var stored = await db.Set().AsNoTracking().SingleAsync(p => p.Id == policyId); + Assert.Equal("hunter2", stored.AlertHandlerProperties["Password"]); + Assert.Equal("Retries stopped for real", stored.AlertHandlerProperties["Subject"]); + } + + [Fact] + public async Task Overriding_an_inherited_alert_keeps_the_password_it_was_only_shown_masked() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7014, "Copied Secret Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + var model = SimplePolicy("Copied Secret Policy"); + model.AlertHandlerId = "NativeSmtpHandler"; + model.AlertHandlerProperties = SmtpProperties("hunter2", useTls: true); + var policyId = (int)await new Create(db, ctx).Handle(model); + + var groupId = (await db.Set().AsNoTracking() + .SingleAsync(p => p.Id == policyId)).Groups[0].Id; + + var sub = new Subscription("Copied Secret Sub", doc.Id); + db.Set().Add(sub); + await db.SaveChangesAsync(); + sub.SetRetryPolicy(policyId, null); + await db.SaveChangesAsync(); + + var row = Assert.Single((List)await new Usage(db, ctx, Secrets(scope)) + .Handle(policyId, new RetryPolicyUsageRequest())); + Assert.Equal(Sentinel, row.ResolvedHandlerProperties["Password"]); + + // The page offers "start from what this currently sends", so the masked value is what comes + // back — and there is no override row yet to restore it from. It has to be recovered from the + // level the caller was shown it at, or the new override would send with no password at all. + await new SaveAlertOverride(db, ctx, Secrets(scope)).Handle(policyId, new RetryAlertOverrideSave + { + SubscriptionId = sub.Id, + GroupId = groupId, + AlertMode = RetryAlertMode.Send, + AlertHandlerId = "NativeSmtpHandler", + AlertHandlerProperties = row.ResolvedHandlerProperties + }); + + var stored = await db.Set().AsNoTracking() + .SingleAsync(o => o.SubscriptionId == sub.Id && o.GroupId == groupId); + Assert.Equal("hunter2", stored.AlertHandlerProperties["Password"]); + Assert.Equal("ops@example.com", stored.AlertHandlerProperties["To"]); + } + + [Fact] + public async Task A_mail_alert_with_a_password_and_no_encryption_is_rejected_on_save() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var model = SimplePolicy("Cleartext Alert Policy"); + model.AlertHandlerId = "NativeSmtpHandler"; + model.AlertHandlerProperties = SmtpProperties("hunter2", useTls: false); + + // Caught on save, where the person configuring it is looking — the handler refuses this at + // send time too, but by then the only trace is a missing alert. + await Assert.ThrowsAsync(() => new Create(db, ctx).Handle(model)); + + // Encryption off is fine on its own; it is only the password that must not travel in clear. + model.AlertHandlerProperties = SmtpProperties("", useTls: false); + await new Create(db, ctx).Handle(model); + } + [Fact] public async Task Policy_alert_handler_round_trips() { @@ -963,7 +1088,7 @@ public async Task Policy_alert_handler_round_trips() model.AlertHandlerProperties = new Dictionary { ["to"] = "ops@example.com" }; var policyId = (int)await new Create(db, ctx).Handle(model); - var loaded = (RetryPolicyUpdate)await new Get(db).Handle(policyId); + var loaded = (RetryPolicyUpdate)await new Get(db, Secrets(scope)).Handle(policyId); Assert.Equal("NativeSmtpHandler", loaded.AlertHandlerId); Assert.Equal("ops@example.com", loaded.AlertHandlerProperties["to"]); diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index 3882fdc8..da9e61ab 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -66,6 +66,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddHttpContextAccessor();