From d7affec39d3a51d6b9d257af5e9be1c6293703d2 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 4 Aug 2026 16:44:41 +0300 Subject: [PATCH 01/54] Fix bad-result retries never matching Contains and Regex matchers now apply to bad results, not just errors, and groups that can never fire are rejected on save. --- .../Resources/RetryPolicies/Create.cs | 1 + .../RetryPolicies/RetryGroupValidation.cs | 44 +++++++++++++++++++ .../Resources/RetryPolicies/Update.cs | 1 + .../Resources/Subscriptions/Update.cs | 4 ++ SW.Bitween.Sdk/Model/AutoRetry/Matcher.cs | 35 ++++++++++----- .../Model/AutoRetry/RetryPolicyEvaluator.cs | 2 +- .../RetryPolicyEvaluatorTests.cs | 39 ++++++++++++++++ 7 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Create.cs b/SW.Bitween.Api/Resources/RetryPolicies/Create.cs index 4cb049f3..688eb768 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Create.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Create.cs @@ -20,6 +20,7 @@ public Create(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(RetryPolicyCreate model) { _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + RetryGroupValidation.EnsureCanFire(model.Groups); var entity = new RetryPolicy { diff --git a/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs b/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs new file mode 100644 index 00000000..e60ef570 --- /dev/null +++ b/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.RetryPolicies; + +/// +/// Rejects retry groups that could never fire. The evaluator skips such groups silently +/// (matchers must support the result type being evaluated — see +/// ), which reads as "retries just don't work", so the +/// misconfiguration is caught at write time instead. +/// +public static class RetryGroupValidation +{ + public static void EnsureCanFire(IEnumerable groups) + { + foreach (var group in groups ?? []) + { + if ((group.AppliesTo?.Count ?? 0) == 0) + throw new SWValidationException("RETRY_GROUP_NO_RESULT_TYPE", + $"Group '{group.Name}' applies to no result type, so it would never be evaluated. " + + "Select Error, Bad result, or both."); + + if ((group.Matchers?.Count ?? 0) == 0) + throw new SWValidationException("RETRY_GROUP_NO_MATCHERS", + $"Group '{group.Name}' has no matchers, so it would never match a failure. " + + "Add at least one matcher."); + + foreach (var resultType in group.AppliesTo) + if (!group.Matchers.Any(m => m.Supports(resultType))) + 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)}"); + } + } + + private static string SupportedMatchersFor(XchangeResultType resultType) => resultType switch + { + XchangeResultType.Error => "Error supports Contains, Regex and Exception type matchers.", + XchangeResultType.BadResult => "Bad result supports Contains, Regex and JSON path matchers.", + _ => "Successful results are never retried." + }; +} diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Update.cs b/SW.Bitween.Api/Resources/RetryPolicies/Update.cs index 5d815fd4..47846ac2 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Update.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Update.cs @@ -20,6 +20,7 @@ public Update(BitweenDbContext dbContext, RequestContext requestContext) public async Task Handle(int key, RetryPolicyUpdate model) { _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + RetryGroupValidation.EnsureCanFire(model.Groups); var entity = await _dbContext.FindAsync(key); entity.Name = model.Name; diff --git a/SW.Bitween.Api/Resources/Subscriptions/Update.cs b/SW.Bitween.Api/Resources/Subscriptions/Update.cs index b93101b1..c39d541f 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Update.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Update.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using SW.Bitween.Domain.Accounts; +using SW.Bitween.Resources.RetryPolicies; namespace SW.Bitween.Resources.Subscriptions { @@ -58,6 +59,9 @@ public async Task Handle(int key, SubscriptionUpdate model) throw new SWValidationException("RETRY_POLICY_NOT_FOUND", $"Retry policy {model.RetryPolicyId} was not found."); + if (model.CustomRetryPolicy != null) + RetryGroupValidation.EnsureCanFire(model.CustomRetryPolicy.Groups); + entity.SetRetryPolicy(model.RetryPolicyId, model.CustomRetryPolicy); trail.SetAfter(entity); diff --git a/SW.Bitween.Sdk/Model/AutoRetry/Matcher.cs b/SW.Bitween.Sdk/Model/AutoRetry/Matcher.cs index 4b6f6146..92eaa763 100644 --- a/SW.Bitween.Sdk/Model/AutoRetry/Matcher.cs +++ b/SW.Bitween.Sdk/Model/AutoRetry/Matcher.cs @@ -34,7 +34,7 @@ public enum JsonPathOp /// /// /// For groups the content is the exception stack-trace text. -/// For groups the content is the raw JSON response string. +/// For groups the content is the raw response body. /// Matcher implementations are serialised polymorphically via System.Text.Json. /// [JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] @@ -44,8 +44,13 @@ public enum JsonPathOp [JsonDerivedType(typeof(JsonPathMatcher), typeDiscriminator: "jsonPath")] public abstract class Matcher { - /// The result type this matcher operates on. - public abstract XchangeResultType ResultType { get; } + /// + /// Returns true when this matcher can be evaluated against + /// content. The evaluator skips incompatible matchers, + /// so a group whose matchers all return false here can never fire for that + /// result type. + /// + public abstract bool Supports(XchangeResultType resultType); /// /// Returns true when satisfies this matcher's condition. @@ -54,16 +59,17 @@ public abstract class Matcher public abstract bool IsMatch(string content); } -// ── Error matchers ──────────────────────────────────────────────────────────── +// ── Text matchers (Error and BadResult) ─────────────────────────────────────── /// -/// Matches when the exception text contains a literal substring. -/// Applies to content. +/// Matches when the failure text contains a literal substring — the exception text for +/// , the response body for . /// public class ContainsMatcher : Matcher { /// - public override XchangeResultType ResultType => XchangeResultType.Error; + public override bool Supports(XchangeResultType resultType) => + resultType is XchangeResultType.Error or XchangeResultType.BadResult; /// The substring to search for. public required string Value { get; init; } @@ -78,13 +84,14 @@ public override bool IsMatch(string content) => } /// -/// Matches when the exception text satisfies a regular expression. -/// Applies to content. +/// Matches when the failure text satisfies a regular expression — the exception text for +/// , the response body for . /// public class RegexMatcher : Matcher { /// - public override XchangeResultType ResultType => XchangeResultType.Error; + public override bool Supports(XchangeResultType resultType) => + resultType is XchangeResultType.Error or XchangeResultType.BadResult; /// .NET-compatible regular expression pattern. public required string Pattern { get; init; } @@ -106,6 +113,8 @@ public class RegexMatcher : Matcher public override bool IsMatch(string content) => Compiled.IsMatch(content); } +// ── Error-only matcher ──────────────────────────────────────────────────────── + /// /// Matches when the exception text mentions a specific .NET exception type name, /// scanning the entire stack-trace including inner exceptions. @@ -118,7 +127,8 @@ public class RegexMatcher : Matcher public class ExceptionTypeMatcher : Matcher { /// - public override XchangeResultType ResultType => XchangeResultType.Error; + public override bool Supports(XchangeResultType resultType) => + resultType == XchangeResultType.Error; /// /// Fully-qualified or short exception type name, e.g. "System.TimeoutException" @@ -163,7 +173,8 @@ public override bool IsMatch(string content) public class JsonPathMatcher : Matcher { /// - public override XchangeResultType ResultType => XchangeResultType.BadResult; + public override bool Supports(XchangeResultType resultType) => + resultType == XchangeResultType.BadResult; /// JSONPath expression, e.g. "$.error.code" or "$.lines[0].status". public required string Path { get; init; } diff --git a/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs b/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs index edc9fa86..fae9146b 100644 --- a/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs +++ b/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs @@ -108,7 +108,7 @@ public RetryDecision Evaluate( .Where(g => g.Enabled && g.AppliesTo.Contains(resultType)) .OrderBy(g => g.Priority)) { - var compatibleMatchers = group.Matchers.Where(m => m.ResultType == resultType); + var compatibleMatchers = group.Matchers.Where(m => m.Supports(resultType)); if (compatibleMatchers.Any(m => m.IsMatch(content))) return group; } diff --git a/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs b/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs index bb8a11a5..4c58ec4b 100644 --- a/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs +++ b/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs @@ -246,6 +246,45 @@ public void Evaluator_WrongResultType_GroupSkipped() Assert.IsFalse(decision.ShouldRetry); } + [TestMethod] + public void Evaluator_ContainsMatcher_MatchesBadResultBody() + { + var policy = PolicyWith(BadResultGroup("bad", new ContainsMatcher { Value = "INSUFFICIENT_STOCK" })); + var ev = new RetryPolicyEvaluator(policy); + var decision = ev.Evaluate(XchangeResultType.BadResult, "{\"code\":\"INSUFFICIENT_STOCK\"}", 0); + Assert.IsTrue(decision.ShouldRetry); + Assert.AreEqual("bad", decision.MatchedGroupName); + } + + [TestMethod] + public void Evaluator_ContainsMatcher_MatchesNonJsonBadResultBody() + { + // A 4xx body need not be JSON — text matchers are the only way to reach these. + var policy = PolicyWith(BadResultGroup("bad", new ContainsMatcher { Value = "rate limit" })); + var ev = new RetryPolicyEvaluator(policy); + var decision = ev.Evaluate(XchangeResultType.BadResult, "Rate limit exceeded", 0); + Assert.IsTrue(decision.ShouldRetry); + } + + [TestMethod] + public void Evaluator_RegexMatcher_MatchesBadResultBody() + { + var policy = PolicyWith(BadResultGroup("bad", new RegexMatcher { Pattern = @"""status"":\s*""FAILED""" })); + var ev = new RetryPolicyEvaluator(policy); + var decision = ev.Evaluate(XchangeResultType.BadResult, "{\"status\": \"FAILED\"}", 0); + Assert.IsTrue(decision.ShouldRetry); + } + + [TestMethod] + public void Evaluator_ExceptionTypeMatcher_SkippedForBadResult() + { + // Exception type names are meaningless against a response body — stays Error-only. + var policy = PolicyWith(BadResultGroup("bad", new ExceptionTypeMatcher { Value = "System.TimeoutException" })); + var ev = new RetryPolicyEvaluator(policy); + var decision = ev.Evaluate(XchangeResultType.BadResult, "System.TimeoutException in body", 0); + Assert.IsFalse(decision.ShouldRetry); + } + // ─── Evaluator: priority ordering ─────────────────────────────────────────── [TestMethod] From e04a65e9d096ccf061c3b3f8e742149c6e048408 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 11 Aug 2026 12:57:16 +0300 Subject: [PATCH 02/54] Enforce MaxAttemptsTotal across messages instead of per message The group total was tracked in a dictionary carried on each xchange, so every failing message started from zero and got its own full budget: 4 messages under a total of 10 produced 12 retries. It also always equalled the per-message attempt count, so the cap could never fire above MaxAttemptsPerError. The total now lives in a RetryGroupUsage table keyed by integration + group, and the evaluator claims from it via IRetryGroupBudget. Dry-runs use an in-memory implementation so simulating never spends a real budget. GroupAttemptCounts is dropped from Xchange and DelayedRetry. The total never resets on its own, so /usage reports what each integration has spent and /resetusage clears it. XchangeResult now records why a retry was refused, since a group with an exhausted budget was previously indistinguishable from one that never matched. --- SW.Bitween.Api/Data/BitweenDbContext.cs | 11 +- SW.Bitween.Api/Domain/DelayedRetry.cs | 2 - SW.Bitween.Api/Domain/RetryGroupUsage.cs | 27 + SW.Bitween.Api/Domain/Xchange/Xchange.cs | 7 +- .../Domain/XchangeResult/XchangeResult.cs | 10 + .../Resources/RetryPolicies/ResetUsage.cs | 56 + .../Resources/RetryPolicies/Test.cs | 9 +- .../Resources/RetryPolicies/Usage.cs | 69 + SW.Bitween.Api/Resources/Xchanges/Search.cs | 3 +- SW.Bitween.Api/Services/RetryGroupBudget.cs | 46 + SW.Bitween.Api/Services/XchangeService.cs | 68 +- .../Tests/RetryJobTests.cs | 48 +- .../Tests/RetryPolicyTests.cs | 174 ++ ...1081235_SharedRetryGroupTotals.Designer.cs | 1902 ++++++++++++++ .../20260811081235_SharedRetryGroupTotals.cs | 56 + ...60811092327_RetryBlockedReason.Designer.cs | 1906 ++++++++++++++ .../20260811092327_RetryBlockedReason.cs | 29 + .../BitweenDbContextModelSnapshot.cs | 29 +- ...1081221_SharedRetryGroupTotals.Designer.cs | 1899 ++++++++++++++ .../20260811081221_SharedRetryGroupTotals.cs | 59 + ...60811092323_RetryBlockedReason.Designer.cs | 1903 ++++++++++++++ .../20260811092323_RetryBlockedReason.cs | 30 + .../BitweenDbContextModelSnapshot.cs | 29 +- SW.Bitween.PgSql/BitweenDbContext.cs | 8 +- ...1081200_SharedRetryGroupTotals.Designer.cs | 2175 ++++++++++++++++ .../20260811081200_SharedRetryGroupTotals.cs | 63 + ...60811092318_RetryBlockedReason.Designer.cs | 2180 +++++++++++++++++ .../20260811092318_RetryBlockedReason.cs | 31 + .../BitweenDbContextModelSnapshot.cs | 37 +- .../Model/AutoRetry/IRetryGroupBudget.cs | 44 + .../Model/AutoRetry/RetryPolicyEvaluator.cs | 54 +- SW.Bitween.Sdk/Model/RetryPolicyModel.cs | 38 + SW.Bitween.Sdk/Model/Xchange.cs | 3 + .../RetryPolicyEvaluatorTests.cs | 157 +- 34 files changed, 12929 insertions(+), 233 deletions(-) create mode 100644 SW.Bitween.Api/Domain/RetryGroupUsage.cs create mode 100644 SW.Bitween.Api/Resources/RetryPolicies/ResetUsage.cs create mode 100644 SW.Bitween.Api/Resources/RetryPolicies/Usage.cs create mode 100644 SW.Bitween.Api/Services/RetryGroupBudget.cs create mode 100644 SW.Bitween.MsSql/Migrations/20260811081235_SharedRetryGroupTotals.Designer.cs create mode 100644 SW.Bitween.MsSql/Migrations/20260811081235_SharedRetryGroupTotals.cs create mode 100644 SW.Bitween.MsSql/Migrations/20260811092327_RetryBlockedReason.Designer.cs create mode 100644 SW.Bitween.MsSql/Migrations/20260811092327_RetryBlockedReason.cs create mode 100644 SW.Bitween.MySql/Migrations/20260811081221_SharedRetryGroupTotals.Designer.cs create mode 100644 SW.Bitween.MySql/Migrations/20260811081221_SharedRetryGroupTotals.cs create mode 100644 SW.Bitween.MySql/Migrations/20260811092323_RetryBlockedReason.Designer.cs create mode 100644 SW.Bitween.MySql/Migrations/20260811092323_RetryBlockedReason.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260811081200_SharedRetryGroupTotals.Designer.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260811081200_SharedRetryGroupTotals.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260811092318_RetryBlockedReason.Designer.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260811092318_RetryBlockedReason.cs create mode 100644 SW.Bitween.Sdk/Model/AutoRetry/IRetryGroupBudget.cs diff --git a/SW.Bitween.Api/Data/BitweenDbContext.cs b/SW.Bitween.Api/Data/BitweenDbContext.cs index cadd4704..33fea14b 100644 --- a/SW.Bitween.Api/Data/BitweenDbContext.cs +++ b/SW.Bitween.Api/Data/BitweenDbContext.cs @@ -236,10 +236,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasKey(p => p.Id); b.Property(p => p.Id).IsUnicode(false).HasMaxLength(50); b.Property(p => p.On); - b.Property(p => p.GroupAttemptCounts).StoreAsJson(); b.HasIndex(p => p.On); }); + modelBuilder.Entity(b => + { + b.ToTable("RetryGroupUsages"); + b.HasKey(p => new { p.SubscriptionId, p.GroupId }); + b.Property(p => p.AttemptsUsed); + b.Property(p => p.LastAttemptOn); + }); + modelBuilder.Entity(b => { b.ToTable("Xchanges"); @@ -252,7 +259,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(p => p.HandlerId).HasMaxLength(200).IsUnicode(false); b.Property(p => p.HandlerProperties).StoreAsJson(); b.Property(p => p.MapperProperties).StoreAsJson(); - b.Property(p => p.GroupAttemptCounts).StoreAsJson(); b.Property(p => p.InputContentType).IsUnicode(false).HasMaxLength(200); b.Property(p => p.ResponseMessageTypeName).IsUnicode(false).HasMaxLength(500); @@ -283,6 +289,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(p => p.ResponseName).HasMaxLength(200); 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.HasOne().WithOne().HasForeignKey(p => p.Id).OnDelete(DeleteBehavior.Cascade); diff --git a/SW.Bitween.Api/Domain/DelayedRetry.cs b/SW.Bitween.Api/Domain/DelayedRetry.cs index c744320a..c8bd1ffc 100644 --- a/SW.Bitween.Api/Domain/DelayedRetry.cs +++ b/SW.Bitween.Api/Domain/DelayedRetry.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using SW.PrimitiveTypes; namespace SW.Bitween.Domain; @@ -7,5 +6,4 @@ namespace SW.Bitween.Domain; public class DelayedRetry : BaseEntity { public DateTime On { get; set; } - public Dictionary GroupAttemptCounts { get; set; } = new(); } diff --git a/SW.Bitween.Api/Domain/RetryGroupUsage.cs b/SW.Bitween.Api/Domain/RetryGroupUsage.cs new file mode 100644 index 00000000..ec9b271a --- /dev/null +++ b/SW.Bitween.Api/Domain/RetryGroupUsage.cs @@ -0,0 +1,27 @@ +using System; + +namespace SW.Bitween.Domain; + +/// +/// Running total of the retries one retry group has spent for one integration, backing +/// RetryBudget.MaxAttemptsTotal. That cap is shared by every message hitting the +/// group, so it cannot be tracked on an individual xchange. +/// +/// +/// The total never resets on its own: once reaches the group's +/// MaxAttemptsTotal the group stops retrying for that integration until this row is +/// cleared. +/// +public class RetryGroupUsage +{ + /// The integration whose budget this is. A shared policy gives each one its own total. + public int SubscriptionId { get; set; } + + /// RetryGroup.Id, which survives policy edits, so the total does too. + public Guid GroupId { get; set; } + + public int AttemptsUsed { get; set; } + + /// When the last attempt was claimed — the only clue left once a group is exhausted. + public DateTime LastAttemptOn { get; set; } +} diff --git a/SW.Bitween.Api/Domain/Xchange/Xchange.cs b/SW.Bitween.Api/Domain/Xchange/Xchange.cs index e9389a36..2ac35a09 100644 --- a/SW.Bitween.Api/Domain/Xchange/Xchange.cs +++ b/SW.Bitween.Api/Domain/Xchange/Xchange.cs @@ -60,7 +60,7 @@ public Xchange(Subscription subscription, XchangeFile file, string[] references } //retry xchange - public Xchange(Xchange xchange, XchangeFile file, IWorkGroup workGroup, IReadOnlyDictionary groupAttemptCounts = null) : + public Xchange(Xchange xchange, XchangeFile file, IWorkGroup workGroup) : this(xchange.DocumentId, workGroup, file, xchange.References) { SubscriptionId = xchange.SubscriptionId; @@ -72,11 +72,10 @@ public Xchange(Xchange xchange, XchangeFile file, IWorkGroup workGroup, IReadOnl ResponseSubscriptionId = xchange.ResponseSubscriptionId; RetryFor = xchange.Id; CorrelationId = xchange.CorrelationId; - GroupAttemptCounts = groupAttemptCounts == null ? null : new Dictionary(groupAttemptCounts); } //retry with reset subscription properties - public Xchange(Subscription subscription, Xchange xchange, XchangeFile file, IReadOnlyDictionary groupAttemptCounts = null) : + public Xchange(Subscription subscription, Xchange xchange, XchangeFile file) : this(xchange.DocumentId, subscription.WorkGroup, file, xchange.References) { SubscriptionId = xchange.SubscriptionId; @@ -88,7 +87,6 @@ public Xchange(Subscription subscription, Xchange xchange, XchangeFile file, IRe ResponseSubscriptionId = subscription.ResponseSubscriptionId; RetryFor = xchange.Id; CorrelationId = xchange.CorrelationId; - GroupAttemptCounts = groupAttemptCounts == null ? null : new Dictionary(groupAttemptCounts); } public int? SubscriptionId { get; private set; } @@ -109,6 +107,5 @@ public Xchange(Subscription subscription, Xchange xchange, XchangeFile file, IRe public string RetryFor { get; private set; } public string CorrelationId { get; set; } - public IReadOnlyDictionary GroupAttemptCounts { get; private set; } } } \ No newline at end of file diff --git a/SW.Bitween.Api/Domain/XchangeResult/XchangeResult.cs b/SW.Bitween.Api/Domain/XchangeResult/XchangeResult.cs index ac674785..ca3f0daf 100644 --- a/SW.Bitween.Api/Domain/XchangeResult/XchangeResult.cs +++ b/SW.Bitween.Api/Domain/XchangeResult/XchangeResult.cs @@ -62,6 +62,16 @@ public XchangeResult(string xchangeId,WorkGroup workGroup, XchangeFile outputFil public bool ResponseBad { get; private set; } public string ResponseContentType { get; private set; } + /// + /// Why the retry policy declined to schedule another attempt for this failure, or + /// null when a retry was scheduled or no policy applied. Without it a group that + /// has exhausted its budget looks identical to one that never matched. + /// + public string RetryBlockedReason { get; private set; } + + /// Records the policy's refusal so it can be shown alongside the failure. + public void SetRetryBlocked(string reason) => RetryBlockedReason = reason; + } diff --git a/SW.Bitween.Api/Resources/RetryPolicies/ResetUsage.cs b/SW.Bitween.Api/Resources/RetryPolicies/ResetUsage.cs new file mode 100644 index 00000000..4b7545a4 --- /dev/null +++ b/SW.Bitween.Api/Resources/RetryPolicies/ResetUsage.cs @@ -0,0 +1,56 @@ +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; + +/// +/// Clears spent group budget, letting an exhausted group retry again. The total never resets on +/// its own, so this is the only way back for an integration that has hit its ceiling. +/// +[HandlerName("resetusage")] +public class ResetUsage : ICommandHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public ResetUsage(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(int key, RetryPolicyResetUsage request) + { + _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + + var policy = await _dbContext.Set().AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == key); + if (policy == null) throw new SWNotFoundException(key.ToString()); + + // Scope the reset to this policy's own integrations and groups, so a policy id in the + // route can never clear a counter belonging to a different policy. + var subscriptionIds = await _dbContext.Set() + .Where(s => s.RetryPolicyId == key) + .Select(s => s.Id) + .ToListAsync(); + + var groupIds = policy.Groups.Select(g => g.Id).ToList(); + + var query = _dbContext.Set() + .Where(u => subscriptionIds.Contains(u.SubscriptionId) && groupIds.Contains(u.GroupId)); + + if (request.SubscriptionId.HasValue) + query = query.Where(u => u.SubscriptionId == request.SubscriptionId.Value); + + if (request.GroupId.HasValue) + query = query.Where(u => u.GroupId == request.GroupId.Value); + + await query.ExecuteDeleteAsync(); + return null; + } +} diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Test.cs b/SW.Bitween.Api/Resources/RetryPolicies/Test.cs index 8fbf12ec..a8a1b05b 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Test.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Test.cs @@ -21,7 +21,7 @@ public Test(RequestContext requestContext) _requestContext = requestContext; } - public Task Handle(TestRetryPolicyRequest request) + public async Task Handle(TestRetryPolicyRequest request) { _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); @@ -30,13 +30,14 @@ public Task Handle(TestRetryPolicyRequest request) "Choose Error or Bad result — a successful result is never retried."); var policy = new CustomRetryPolicy { Groups = request.Groups ?? [] }; - var evaluator = new RetryPolicyEvaluator(policy); + // In-memory budget: a dry-run must not spend any real integration's total. + var evaluator = new RetryPolicyEvaluator(policy, new InMemoryRetryGroupBudget()); var attemptsToSimulate = Math.Clamp(request.AttemptsToSimulate, 1, 20); var attempts = new List(); for (var attemptIndex = 0; attemptIndex < attemptsToSimulate; attemptIndex++) { - var decision = evaluator.Evaluate(request.ResultType, request.Content, attemptIndex); + var decision = await evaluator.Evaluate(request.ResultType, request.Content, attemptIndex); attempts.Add(new TestRetryAttemptResult { @@ -53,6 +54,6 @@ public Task Handle(TestRetryPolicyRequest request) if (!decision.ShouldRetry) break; } - return Task.FromResult(new TestRetryPolicyResponse { Attempts = attempts }); + return new TestRetryPolicyResponse { Attempts = attempts }; } } diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs b/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs new file mode 100644 index 00000000..32a925e5 --- /dev/null +++ b/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +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. +/// +[HandlerName("usage")] +public class Usage : ICommandHandler +{ + private readonly BitweenDbContext _dbContext; + + public Usage(BitweenDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(int key, RetryPolicyUsageRequest request) + { + var policy = await _dbContext.Set().AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == key); + if (policy == null) throw new SWNotFoundException(key.ToString()); + + var subscriptions = await _dbContext.Set().AsNoTracking() + .Where(s => s.RetryPolicyId == key) + .Select(s => new { s.Id, s.Name }) + .ToListAsync(); + + var subscriptionIds = subscriptions.Select(s => s.Id).ToList(); + + var usages = await _dbContext.Set().AsNoTracking() + .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 names = subscriptions.ToDictionary(s => s.Id, s => s.Name); + + var rows = usages + .Where(u => budgets.ContainsKey(u.GroupId)) + .Select(u => 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. + .OrderByDescending(r => r.Exhausted) + .ThenByDescending(r => r.AttemptsUsed) + .ToList(); + + return new List(rows); + } +} diff --git a/SW.Bitween.Api/Resources/Xchanges/Search.cs b/SW.Bitween.Api/Resources/Xchanges/Search.cs index aef8e649..edfac1a6 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Search.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Search.cs @@ -73,7 +73,8 @@ from delayedRetry in drGroup.DefaultIfEmpty() ResponseFileName = result.ResponseName, CorrelationId = xchange.CorrelationId, PartnerId = subscriber.PartnerId, - ScheduledRetryOn = delayedRetry != null ? delayedRetry.On : (DateTime?)null + ScheduledRetryOn = delayedRetry != null ? delayedRetry.On : (DateTime?)null, + RetryBlockedReason = result.RetryBlockedReason }; var condition = searchyRequest.Conditions.FirstOrDefault(); diff --git a/SW.Bitween.Api/Services/RetryGroupBudget.cs b/SW.Bitween.Api/Services/RetryGroupBudget.cs new file mode 100644 index 00000000..85f3c3b8 --- /dev/null +++ b/SW.Bitween.Api/Services/RetryGroupBudget.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Bitween.Model; + +namespace SW.Bitween; + +/// +/// backed by the RetryGroupUsage table and scoped to one +/// integration, so a policy template shared by many integrations gives each its own total +/// instead of letting one noisy integration spend everyone's budget. +/// +public class RetryGroupBudget(BitweenDbContext dbContext, int subscriptionId) : IRetryGroupBudget +{ + /// + /// + /// The increment is left for the caller's SaveChangesAsync so it commits in the same + /// transaction as the DelayedRetry row it authorises — a scheduled retry and its + /// spent slot can never disagree. Two failures of the same group evaluated concurrently can + /// each read the same count and overshoot the cap by the number of simultaneous failures. + /// + public async Task TryConsume(Guid groupId, int maxAttemptsTotal) + { + var usage = await dbContext.Set() + .FirstOrDefaultAsync(u => u.SubscriptionId == subscriptionId && u.GroupId == groupId); + + if ((usage?.AttemptsUsed ?? 0) >= maxAttemptsTotal) return false; + + if (usage == null) + dbContext.Add(new RetryGroupUsage + { + SubscriptionId = subscriptionId, + GroupId = groupId, + AttemptsUsed = 1, + LastAttemptOn = DateTime.UtcNow + }); + else + { + usage.AttemptsUsed++; + usage.LastAttemptOn = DateTime.UtcNow; + } + + return true; + } +} diff --git a/SW.Bitween.Api/Services/XchangeService.cs b/SW.Bitween.Api/Services/XchangeService.cs index 31f6bb7f..0d32e9df 100644 --- a/SW.Bitween.Api/Services/XchangeService.cs +++ b/SW.Bitween.Api/Services/XchangeService.cs @@ -85,17 +85,17 @@ public async Task SubmitFilterXchange(int documentId, XchangeFile file, string[] await _dbContext.SaveChangesAsync(); } - public async Task CreateXchange(Xchange xchange, XchangeFile file, WorkGroup workGroup, Dictionary groupAttemptCounts = null) + public async Task CreateXchange(Xchange xchange, XchangeFile file, WorkGroup workGroup) { - var newXchange = new Xchange(xchange, file, workGroup, groupAttemptCounts); + var newXchange = new Xchange(xchange, file, workGroup); await AddFile(newXchange.Id, XchangeFileType.Input, file); _dbContext.Add(newXchange); } public async Task CreateXchange(Subscription subscription, Xchange xchange, XchangeFile file, - string[] references = null, Dictionary groupAttemptCounts = null) + string[] references = null) { - var newXchange = new Xchange(subscription, xchange, file, groupAttemptCounts); + var newXchange = new Xchange(subscription, xchange, file); await AddFile(newXchange.Id, XchangeFileType.Input, file); _dbContext.Add(newXchange); } @@ -147,7 +147,7 @@ public async Task ExecuteDelayedRetry(DelayedRetry delayedRetry) var inputFileData = await GetFile(xchange.Id, XchangeFileType.Input); var inputFile = new XchangeFile(inputFileData, xchange.InputName); - await CreateXchange(subscription, xchange, inputFile, groupAttemptCounts: delayedRetry.GroupAttemptCounts); + await CreateXchange(subscription, xchange, inputFile); _dbContext.Remove(delayedRetry); return true; } @@ -418,24 +418,35 @@ private async Task Process(XchangeMessage message) await CreateXchangesForHits(xchange, result, inputFile); } - _dbContext.Add(new XchangeResult(xchange.Id, workGroup, outputFile, responseFile, responseXchange?.Id)); + var xchangeResult = new XchangeResult(xchange.Id, workGroup, outputFile, responseFile, + responseXchange?.Id); + _dbContext.Add(xchangeResult); if (responseFile?.BadData == true) - await TryScheduleAutoRetry(xchange, XchangeResultType.BadResult, responseFile.Data); + await TryScheduleAutoRetry(xchange, XchangeResultType.BadResult, responseFile.Data, xchangeResult); await _dbContext.SaveChangesAsync(); } catch (Exception ex) { - _dbContext.Add(new XchangeResult(xchange.Id, workGroup, outputFile, responseFile, responseXchange?.Id, - ex.ToString())); - await TryScheduleAutoRetry(xchange, XchangeResultType.Error, ex.ToString()); + var xchangeResult = new XchangeResult(xchange.Id, workGroup, outputFile, responseFile, + responseXchange?.Id, ex.ToString()); + _dbContext.Add(xchangeResult); + await TryScheduleAutoRetry(xchange, XchangeResultType.Error, ex.ToString(), xchangeResult); await _dbContext.SaveChangesAsync(); } } - private async Task TryScheduleAutoRetry(Xchange xchange, XchangeResultType resultType, string content) + private async Task TryScheduleAutoRetry(Xchange xchange, XchangeResultType resultType, string content, + XchangeResult xchangeResult) { if (xchange.SubscriptionId == null) return; + // DelayedRetry.Id is xchange.Id, so an existing row means this failure was already + // evaluated and already spent a slot of the group's total budget. Re-evaluating it + // (e.g. on an at-least-once redelivery) would both violate the PK on Add and spend a + // second slot for the same failure. + var alreadyScheduled = await _dbContext.Set().FindAsync(xchange.Id); + if (alreadyScheduled != null) return; + var subscription = await _dbContext.Set() .Include(s => s.RetryPolicy) .FirstOrDefaultAsync(s => s.Id == xchange.SubscriptionId.Value); @@ -443,35 +454,22 @@ private async Task TryScheduleAutoRetry(Xchange xchange, XchangeResultType resul IRetryPolicy policy = subscription?.CustomRetryPolicy ?? (IRetryPolicy)subscription?.RetryPolicy; if (policy?.Groups == null || policy.Groups.Count == 0) return; - var evaluator = new RetryPolicyEvaluator(policy); - evaluator.RestoreGroupAttemptCounts(xchange.GroupAttemptCounts == null - ? new Dictionary() - : new Dictionary(xchange.GroupAttemptCounts)); + var evaluator = new RetryPolicyEvaluator(policy, + new RetryGroupBudget(_dbContext, xchange.SubscriptionId.Value)); var attemptIndex = await CountRetryChainDepth(xchange); - var decision = evaluator.Evaluate(resultType, content, attemptIndex); + var decision = await evaluator.Evaluate(resultType, content, attemptIndex); if (decision.ShouldRetry) - { - // Guard against duplicate scheduling (e.g. an at-least-once redelivery - // reprocessing the same xchange) — DelayedRetry.Id is xchange.Id, so a - // blind Add would violate the PK and fail the whole SaveChangesAsync. - var existing = await _dbContext.Set().FindAsync(xchange.Id); - if (existing != null) - { - existing.On = DateTime.UtcNow + decision.Delay; - existing.GroupAttemptCounts = evaluator.GetGroupAttemptCounts(); - } - else + _dbContext.Add(new DelayedRetry { - _dbContext.Add(new DelayedRetry - { - Id = xchange.Id, - On = DateTime.UtcNow + decision.Delay, - GroupAttemptCounts = evaluator.GetGroupAttemptCounts() - }); - } - } + Id = xchange.Id, + On = DateTime.UtcNow + decision.Delay + }); + else + // 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); } private async Task CountRetryChainDepth(Xchange xchange) diff --git a/SW.Bitween.IntegrationTests/Tests/RetryJobTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryJobTests.cs index 5bf58c4d..9cdf71e2 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryJobTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryJobTests.cs @@ -122,15 +122,10 @@ public async Task RetryJob_processes_due_delayed_retry_and_creates_retry_xchange // Use CreateXchange so the input file is uploaded to real cloud storage var originalXchange = await xs.CreateXchange(sub, new XchangeFile("{}")); - var groupCounts = new System.Collections.Generic.Dictionary - { - [Guid.NewGuid().ToString()] = 1 - }; var delayedRetry = new DelayedRetry { Id = originalXchange.Id, - On = DateTime.UtcNow.AddMinutes(-1), - GroupAttemptCounts = groupCounts + On = DateTime.UtcNow.AddMinutes(-1) }; db.Set().Add(delayedRetry); await db.SaveChangesAsync(); @@ -149,47 +144,6 @@ public async Task RetryJob_processes_due_delayed_retry_and_creates_retry_xchange Assert.Equal(sub.Id, retryXchange.SubscriptionId); } - [Fact] - public async Task RetryJob_carries_group_attempt_counts_onto_retry_xchange() - { - await using var scope = _fixture.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var xs = scope.ServiceProvider.GetRequiredService(); - - var doc = new Document(8002, "RetryJob GroupCounts Doc"); - db.Set().Add(doc); - await db.SaveChangesAsync(); - - var sub = new Subscription("RetryJob GroupCounts Sub", doc.Id); - sub.Inactive = false; - db.Set().Add(sub); - await db.SaveChangesAsync(); - - var originalXchange = await xs.CreateXchange(sub, new XchangeFile("{}")); - - var groupId = Guid.NewGuid().ToString(); - var delayedRetry = new DelayedRetry - { - Id = originalXchange.Id, - On = DateTime.UtcNow.AddMinutes(-1), - GroupAttemptCounts = new System.Collections.Generic.Dictionary - { - [groupId] = 2 - } - }; - db.Set().Add(delayedRetry); - await db.SaveChangesAsync(); - - await BuildJob(db, xs).Execute(); - - var retryXchange = await db.Set() - .FirstOrDefaultAsync(x => x.RetryFor == originalXchange.Id); - Assert.NotNull(retryXchange); - Assert.NotNull(retryXchange.GroupAttemptCounts); - Assert.True(retryXchange.GroupAttemptCounts.TryGetValue(groupId, out var count)); - Assert.Equal(2, count); - } - [Fact] public async Task RetryJob_processes_multiple_due_records_in_one_invocation() { diff --git a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs index 45f975f2..772470d1 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -333,6 +334,179 @@ public async Task Removing_retry_policy_nullifies_subscription_fk_via_set_null_c Assert.Null(reloaded.RetryPolicyId); } + // ─── Shared group total (MaxAttemptsTotal) ────────────────────────────────── + + [Fact] + public async Task Group_total_is_shared_across_separate_messages_of_the_same_integration() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7005, "Shared Total Doc"); + db.Set().Add(doc); + var sub = new Subscription("Shared Total Sub", doc.Id); + db.Set().Add(sub); + await db.SaveChangesAsync(); + + var group = new RetryGroup + { + Name = "Timeout", + Priority = 10, + AppliesTo = [XchangeResultType.Error], + Matchers = [new ContainsMatcher { Value = "timeout" }], + Budget = new RetryBudget + { + MaxAttemptsPerError = 3, + MaxAttemptsTotal = 10, + DelayStrategy = new FixedDelayStrategy { DelayMs = 5_000 } + } + }; + var policy = new CustomRetryPolicy { Groups = [group] }; + + // Reproduces the reported bug: four failing messages, each retried up to its own + // per-message cap of 3, under a shared total of 10 — 12 retries before the fix. + var allowed = 0; + for (var message = 0; message < 4; message++) + for (var attempt = 0; attempt < 3; attempt++) + { + // A fresh evaluator and store per failure, exactly as XchangeService builds them. + var evaluator = new RetryPolicyEvaluator(policy, new RetryGroupBudget(db, sub.Id)); + var decision = await evaluator.Evaluate(XchangeResultType.Error, "timeout", attempt); + if (decision.ShouldRetry) allowed++; + await db.SaveChangesAsync(); + } + + Assert.Equal(10, allowed); + + var usage = await db.Set().AsNoTracking() + .SingleAsync(u => u.SubscriptionId == sub.Id && u.GroupId == group.Id); + Assert.Equal(10, usage.AttemptsUsed); + } + + [Fact] + public async Task Group_total_is_tracked_per_integration_not_per_policy() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7006, "Per Integration Doc"); + db.Set().Add(doc); + var subA = new Subscription("Per Integration Sub A", doc.Id); + var subB = new Subscription("Per Integration Sub B", doc.Id); + db.Set().AddRange(subA, subB); + await db.SaveChangesAsync(); + + var group = new RetryGroup + { + Name = "Timeout", + Priority = 10, + AppliesTo = [XchangeResultType.Error], + Matchers = [new ContainsMatcher { Value = "timeout" }], + Budget = new RetryBudget + { + MaxAttemptsPerError = 10, + MaxAttemptsTotal = 1, + DelayStrategy = new FixedDelayStrategy { DelayMs = 5_000 } + } + }; + var policy = new CustomRetryPolicy { Groups = [group] }; + + // Each integration gets its own single attempt, so one integration exhausting a + // shared policy template cannot starve the others. + Assert.True(await Allow(subA.Id)); + Assert.True(await Allow(subB.Id)); + Assert.False(await Allow(subA.Id)); + Assert.False(await Allow(subB.Id)); + return; + + async Task Allow(int subscriptionId) + { + var evaluator = new RetryPolicyEvaluator(policy, new RetryGroupBudget(db, subscriptionId)); + var decision = await evaluator.Evaluate(XchangeResultType.Error, "timeout", 0); + await db.SaveChangesAsync(); + return decision.ShouldRetry; + } + } + + // ─── Usage reporting and reset ────────────────────────────────────────────── + + [Fact] + public async Task Usage_reports_spent_budget_and_reset_clears_it() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7007, "Usage Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + var policyId = (int)await new Create(db, ctx).Handle(SimplePolicy("Usage Policy")); + var saved = await db.Set().AsNoTracking().SingleAsync(p => p.Id == policyId); + var groupId = saved.Groups[0].Id; + + var sub = new Subscription("Usage Sub", doc.Id); + db.Set().Add(sub); + await db.SaveChangesAsync(); + sub.SetRetryPolicy(policyId, null); + await db.SaveChangesAsync(); + + // Spend the whole budget (SimplePolicy allows 10 in total). + var budget = new RetryGroupBudget(db, sub.Id); + for (var i = 0; i < 10; i++) await budget.TryConsume(groupId, 10); + await db.SaveChangesAsync(); + + var rows = (List)await new Usage(db).Handle(policyId, new RetryPolicyUsageRequest()); + var row = Assert.Single(rows); + Assert.Equal(sub.Id, row.SubscriptionId); + Assert.Equal("Usage Sub", row.SubscriptionName); + Assert.Equal("Timeout", row.GroupName); + Assert.Equal(10, row.AttemptsUsed); + Assert.True(row.Exhausted); + + await new ResetUsage(db, ctx).Handle(policyId, new RetryPolicyResetUsage + { + SubscriptionId = sub.Id, + GroupId = groupId + }); + + Assert.Empty((List)await new Usage(db).Handle(policyId, new RetryPolicyUsageRequest())); + + // And the group can retry again. + Assert.True(await new RetryGroupBudget(db, sub.Id).TryConsume(groupId, 10)); + } + + [Fact] + public async Task Reset_does_not_touch_counters_of_another_policy() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7008, "Reset Scope Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + var mineId = (int)await new Create(db, ctx).Handle(SimplePolicy("Reset Scope Mine")); + var otherId = (int)await new Create(db, ctx).Handle(SimplePolicy("Reset Scope Other")); + var otherGroupId = (await db.Set().AsNoTracking() + .SingleAsync(p => p.Id == otherId)).Groups[0].Id; + + var otherSub = new Subscription("Reset Scope Other Sub", doc.Id); + db.Set().Add(otherSub); + await db.SaveChangesAsync(); + otherSub.SetRetryPolicy(otherId, null); + await db.SaveChangesAsync(); + + await new RetryGroupBudget(db, otherSub.Id).TryConsume(otherGroupId, 10); + await db.SaveChangesAsync(); + + // 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).Handle(otherId, new RetryPolicyUsageRequest())); + } + // ─── Test / dry-run endpoint ──────────────────────────────────────────────── [Fact] diff --git a/SW.Bitween.MsSql/Migrations/20260811081235_SharedRetryGroupTotals.Designer.cs b/SW.Bitween.MsSql/Migrations/20260811081235_SharedRetryGroupTotals.Designer.cs new file mode 100644 index 00000000..ca317971 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260811081235_SharedRetryGroupTotals.Designer.cs @@ -0,0 +1,1902 @@ +// +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("20260811081235_SharedRetryGroupTotals")] + partial class SharedRetryGroupTotals + { + /// + 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("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, + 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.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptsUsed") + .HasColumnType("int"); + + 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("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("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("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + 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/20260811081235_SharedRetryGroupTotals.cs b/SW.Bitween.MsSql/Migrations/20260811081235_SharedRetryGroupTotals.cs new file mode 100644 index 00000000..f52cc72f --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260811081235_SharedRetryGroupTotals.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class SharedRetryGroupTotals : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GroupAttemptCounts", + table: "Xchanges"); + + migrationBuilder.DropColumn( + name: "GroupAttemptCounts", + table: "DelayedRetries"); + + migrationBuilder.CreateTable( + name: "RetryGroupUsages", + columns: table => new + { + SubscriptionId = table.Column(type: "int", nullable: false), + GroupId = table.Column(type: "uniqueidentifier", nullable: false), + AttemptsUsed = table.Column(type: "int", nullable: false), + LastAttemptOn = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RetryGroupUsages", x => new { x.SubscriptionId, x.GroupId }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RetryGroupUsages"); + + migrationBuilder.AddColumn( + name: "GroupAttemptCounts", + table: "Xchanges", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "GroupAttemptCounts", + table: "DelayedRetries", + type: "nvarchar(max)", + nullable: true); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/20260811092327_RetryBlockedReason.Designer.cs b/SW.Bitween.MsSql/Migrations/20260811092327_RetryBlockedReason.Designer.cs new file mode 100644 index 00000000..8608dffa --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260811092327_RetryBlockedReason.Designer.cs @@ -0,0 +1,1906 @@ +// +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("20260811092327_RetryBlockedReason")] + partial class RetryBlockedReason + { + /// + 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("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, + 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.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptsUsed") + .HasColumnType("int"); + + 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("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("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("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + 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/20260811092327_RetryBlockedReason.cs b/SW.Bitween.MsSql/Migrations/20260811092327_RetryBlockedReason.cs new file mode 100644 index 00000000..4dc41bbf --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260811092327_RetryBlockedReason.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class RetryBlockedReason : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RetryBlockedReason", + table: "XchangeResults", + type: "nvarchar(500)", + maxLength: 500, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RetryBlockedReason", + table: "XchangeResults"); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs index 43e9d519..0c9fd965 100644 --- a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -131,9 +131,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnicode(false) .HasColumnType("varchar(50)"); - b.Property("GroupAttemptCounts") - .HasColumnType("nvarchar(max)"); - b.Property("On") .HasColumnType("datetime2"); @@ -498,6 +495,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SW.Bitween.Domain.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptsUsed") + .HasColumnType("int"); + + b.Property("LastAttemptOn") + .HasColumnType("datetime2"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryGroupUsages", (string)null); + }); + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => { b.Property("Id") @@ -766,9 +782,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DocumentId") .HasColumnType("int"); - b.Property("GroupAttemptCounts") - .HasColumnType("nvarchar(max)"); - b.Property("HandlerId") .HasMaxLength(200) .IsUnicode(false) @@ -998,6 +1011,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ResponseXchangeId") .HasColumnType("nvarchar(max)"); + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + b.Property("Success") .HasColumnType("bit"); diff --git a/SW.Bitween.MySql/Migrations/20260811081221_SharedRetryGroupTotals.Designer.cs b/SW.Bitween.MySql/Migrations/20260811081221_SharedRetryGroupTotals.Designer.cs new file mode 100644 index 00000000..3ed0e858 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260811081221_SharedRetryGroupTotals.Designer.cs @@ -0,0 +1,1899 @@ +// +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("20260811081221_SharedRetryGroupTotals")] + partial class SharedRetryGroupTotals + { + /// + 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("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, + 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.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("AttemptsUsed") + .HasColumnType("int"); + + 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("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("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("Success") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + 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/20260811081221_SharedRetryGroupTotals.cs b/SW.Bitween.MySql/Migrations/20260811081221_SharedRetryGroupTotals.cs new file mode 100644 index 00000000..3bda1da3 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260811081221_SharedRetryGroupTotals.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class SharedRetryGroupTotals : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GroupAttemptCounts", + table: "Xchanges"); + + migrationBuilder.DropColumn( + name: "GroupAttemptCounts", + table: "DelayedRetries"); + + migrationBuilder.CreateTable( + name: "RetryGroupUsages", + columns: table => new + { + SubscriptionId = table.Column(type: "int", nullable: false), + GroupId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + AttemptsUsed = table.Column(type: "int", nullable: false), + LastAttemptOn = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RetryGroupUsages", x => new { x.SubscriptionId, x.GroupId }); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RetryGroupUsages"); + + migrationBuilder.AddColumn( + name: "GroupAttemptCounts", + table: "Xchanges", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "GroupAttemptCounts", + table: "DelayedRetries", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/20260811092323_RetryBlockedReason.Designer.cs b/SW.Bitween.MySql/Migrations/20260811092323_RetryBlockedReason.Designer.cs new file mode 100644 index 00000000..53cb83ff --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260811092323_RetryBlockedReason.Designer.cs @@ -0,0 +1,1903 @@ +// +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("20260811092323_RetryBlockedReason")] + partial class RetryBlockedReason + { + /// + 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("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, + 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.RetryGroupUsage", b => + { + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("AttemptsUsed") + .HasColumnType("int"); + + 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("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("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("Success") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + 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/20260811092323_RetryBlockedReason.cs b/SW.Bitween.MySql/Migrations/20260811092323_RetryBlockedReason.cs new file mode 100644 index 00000000..81d72732 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260811092323_RetryBlockedReason.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class RetryBlockedReason : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RetryBlockedReason", + table: "XchangeResults", + type: "varchar(500)", + maxLength: 500, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RetryBlockedReason", + table: "XchangeResults"); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs index ee14e415..edbc63a3 100644 --- a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs @@ -130,9 +130,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnicode(false) .HasColumnType("varchar(50)"); - b.Property("GroupAttemptCounts") - .HasColumnType("longtext"); - b.Property("On") .HasColumnType("datetime(6)"); @@ -496,6 +493,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + 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("LastAttemptOn") + .HasColumnType("datetime(6)"); + + b.HasKey("SubscriptionId", "GroupId"); + + b.ToTable("RetryGroupUsages", (string)null); + }); + modelBuilder.Entity("SW.Bitween.Domain.RetryPolicy", b => { b.Property("Id") @@ -763,9 +779,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DocumentId") .HasColumnType("int"); - b.Property("GroupAttemptCounts") - .HasColumnType("longtext"); - b.Property("HandlerId") .HasMaxLength(200) .IsUnicode(false) @@ -995,6 +1008,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ResponseXchangeId") .HasColumnType("longtext"); + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + b.Property("Success") .HasColumnType("tinyint(1)"); diff --git a/SW.Bitween.PgSql/BitweenDbContext.cs b/SW.Bitween.PgSql/BitweenDbContext.cs index b159f6ce..83b36df8 100644 --- a/SW.Bitween.PgSql/BitweenDbContext.cs +++ b/SW.Bitween.PgSql/BitweenDbContext.cs @@ -262,6 +262,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(p => p.ResponseName).HasMaxLength(200); b.Property(p => p.ResponseContentType).HasMaxLength(200); b.Property(p => p.OutputContentType).HasMaxLength(200); + b.Property(p => p.RetryBlockedReason).HasMaxLength(500); b.HasOne().WithOne().HasForeignKey(p => p.Id).OnDelete(DeleteBehavior.Cascade); }); @@ -384,13 +385,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { b.HasKey(p => p.Id); b.Property(p => p.Id).HasMaxLength(50); - b.Property(p => p.GroupAttemptCounts).HasColumnType("jsonb"); b.HasIndex(p => p.On); }); - modelBuilder.Entity(b => + modelBuilder.Entity(b => { - b.Property(p => p.GroupAttemptCounts).HasColumnType("jsonb"); + b.HasKey(p => new { p.SubscriptionId, p.GroupId }); + b.Property(p => p.AttemptsUsed); + b.Property(p => p.LastAttemptOn); }); modelBuilder.UseSchedulerPostgreSql(Schema); diff --git a/SW.Bitween.PgSql/Migrations/20260811081200_SharedRetryGroupTotals.Designer.cs b/SW.Bitween.PgSql/Migrations/20260811081200_SharedRetryGroupTotals.Designer.cs new file mode 100644 index 00000000..7ad3ef75 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260811081200_SharedRetryGroupTotals.Designer.cs @@ -0,0 +1,2175 @@ +// +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("20260811081200_SharedRetryGroupTotals")] + partial class SharedRetryGroupTotals + { + /// + 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("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, + 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.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("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("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("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("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + 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/20260811081200_SharedRetryGroupTotals.cs b/SW.Bitween.PgSql/Migrations/20260811081200_SharedRetryGroupTotals.cs new file mode 100644 index 00000000..2bad0745 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260811081200_SharedRetryGroupTotals.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + public partial class SharedRetryGroupTotals : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "group_attempt_counts", + schema: "infolink", + table: "xchange"); + + migrationBuilder.DropColumn( + name: "group_attempt_counts", + schema: "infolink", + table: "delayed_retry"); + + migrationBuilder.CreateTable( + name: "retry_group_usage", + schema: "infolink", + columns: table => new + { + subscription_id = table.Column(type: "integer", nullable: false), + group_id = table.Column(type: "uuid", nullable: false), + attempts_used = table.Column(type: "integer", nullable: false), + last_attempt_on = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_retry_group_usage", x => new { x.subscription_id, x.group_id }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "retry_group_usage", + schema: "infolink"); + + migrationBuilder.AddColumn>( + name: "group_attempt_counts", + schema: "infolink", + table: "xchange", + type: "jsonb", + nullable: true); + + migrationBuilder.AddColumn>( + name: "group_attempt_counts", + schema: "infolink", + table: "delayed_retry", + type: "jsonb", + nullable: true); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/20260811092318_RetryBlockedReason.Designer.cs b/SW.Bitween.PgSql/Migrations/20260811092318_RetryBlockedReason.Designer.cs new file mode 100644 index 00000000..e7f65c75 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260811092318_RetryBlockedReason.Designer.cs @@ -0,0 +1,2180 @@ +// +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("20260811092318_RetryBlockedReason")] + partial class RetryBlockedReason + { + /// + 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("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, + 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.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("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("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("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("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + 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/20260811092318_RetryBlockedReason.cs b/SW.Bitween.PgSql/Migrations/20260811092318_RetryBlockedReason.cs new file mode 100644 index 00000000..e33c8ce0 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260811092318_RetryBlockedReason.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + public partial class RetryBlockedReason : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "retry_blocked_reason", + schema: "infolink", + table: "xchange_result", + type: "character varying(500)", + maxLength: 500, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "retry_blocked_reason", + schema: "infolink", + table: "xchange_result"); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs index 8cea92fe..6c59923e 100644 --- a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -155,10 +155,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(50)") .HasColumnName("id"); - b.Property>("GroupAttemptCounts") - .HasColumnType("jsonb") - .HasColumnName("group_attempt_counts"); - b.Property("On") .HasColumnType("timestamp with time zone") .HasColumnName("on"); @@ -606,6 +602,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + 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("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") @@ -941,10 +961,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("document_id"); - b.Property>("GroupAttemptCounts") - .HasColumnType("jsonb") - .HasColumnName("group_attempt_counts"); - b.Property("HandlerId") .HasMaxLength(200) .HasColumnType("character varying(200)") @@ -1214,6 +1230,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("response_xchange_id"); + b.Property("RetryBlockedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("retry_blocked_reason"); + b.Property("Success") .HasColumnType("boolean") .HasColumnName("success"); diff --git a/SW.Bitween.Sdk/Model/AutoRetry/IRetryGroupBudget.cs b/SW.Bitween.Sdk/Model/AutoRetry/IRetryGroupBudget.cs new file mode 100644 index 00000000..eddbbeef --- /dev/null +++ b/SW.Bitween.Sdk/Model/AutoRetry/IRetryGroupBudget.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SW.Bitween.Model; + +/// +/// Tracks how much of a 's +/// has already been spent. +/// +/// +/// The total is a ceiling shared by every message that hits the group, so the count cannot +/// live on the message being evaluated — it needs a store that outlives a single xchange. +/// The implementation decides the scope: the production one keys by integration + group and +/// persists, while counts only for one dry-run. +/// +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); +} + +/// +/// In-memory for the policy dry-run endpoint, where nothing +/// should be persisted and the budget spans only the simulated run. +/// +public class InMemoryRetryGroupBudget : IRetryGroupBudget +{ + private readonly Dictionary _used = new(); + + /// + public Task TryConsume(Guid groupId, int maxAttemptsTotal) + { + var used = _used.GetValueOrDefault(groupId, 0); + if (used >= maxAttemptsTotal) return Task.FromResult(false); + + _used[groupId] = used + 1; + return Task.FromResult(true); + } +} diff --git a/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs b/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs index fae9146b..efdf77c4 100644 --- a/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs +++ b/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs @@ -1,6 +1,6 @@ using System; -using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; namespace SW.Bitween.Model; @@ -10,47 +10,18 @@ namespace SW.Bitween.Model; /// /// /// -/// Stateful per processing window. The evaluator accumulates -/// per-group attempt totals in _groupAttemptCounts. For a single in-process -/// window (e.g. a running RetryJob batch) one instance handles all messages. +/// Two independent caps. is +/// per message and is derived from the caller's attemptIndexForThisMessage, while +/// is shared by every message hitting the group and +/// is owned by the injected . The evaluator itself keeps no +/// counters, so a fresh instance per failure enforces both caps correctly. /// /// -/// Cross-invocation persistence. When a retry is scheduled the -/// current counts are saved to and stored on -/// the DelayedRetry entity (and on the new Xchange so that a later -/// failure of the retry itself can pick up where the budgets left off). On the next -/// evaluation call before . -/// -/// -/// Not thread-safe. Each goroutine/task should use its own instance. +/// Not thread-safe. Each task should use its own instance. /// /// -public class RetryPolicyEvaluator(IRetryPolicy policy) +public class RetryPolicyEvaluator(IRetryPolicy policy, IRetryGroupBudget groupBudget) { - private readonly Dictionary _groupAttemptCounts = new(); - - /// - /// Restores previously persisted group-level attempt counts, allowing budgets - /// to continue from where they left off across separate process invocations. - /// - /// - /// The dictionary returned by from a prior evaluation. - /// String keys are parsed back to — invalid entries are silently ignored. - /// - public void RestoreGroupAttemptCounts(Dictionary counts) - { - foreach (var kv in counts) - if (Guid.TryParse(kv.Key, out var guid)) - _groupAttemptCounts[guid] = kv.Value; - } - - /// - /// Returns the current group-level attempt counts as a string-keyed dictionary - /// suitable for JSON serialisation and storage on DelayedRetry / Xchange. - /// - public Dictionary GetGroupAttemptCounts() => - _groupAttemptCounts.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value); - /// /// Evaluates the policy and returns a retry decision for the failed xchange. /// @@ -69,7 +40,7 @@ public Dictionary GetGroupAttemptCounts() => /// /// A indicating whether to retry and, if so, how long to wait. /// - public RetryDecision Evaluate( + public async Task Evaluate( XchangeResultType resultType, string content, int attemptIndexForThisMessage) @@ -91,13 +62,12 @@ public RetryDecision Evaluate( return RetryDecision.Block( $"Per-message cap reached ({budget.MaxAttemptsPerError}) in group '{group.Name}'"); - var totalUsed = _groupAttemptCounts.GetValueOrDefault(group.Id, 0); - if (totalUsed >= budget.MaxAttemptsTotal) + // 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)) return RetryDecision.Block( $"Group total cap reached ({budget.MaxAttemptsTotal}) for group '{group.Name}'"); - _groupAttemptCounts[group.Id] = totalUsed + 1; - var delay = budget.DelayStrategy.GetDelay(attemptIndexForThisMessage); return RetryDecision.Allow(delay, group.Name); } diff --git a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs index 5d617542..1a2a0296 100644 --- a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs +++ b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace SW.Bitween.Model; @@ -17,6 +18,43 @@ public class RetryPolicyRow public int GroupCount { get; set; } } +/// +/// 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. +/// +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. + public bool Exhausted { get; set; } + + public DateTime LastAttemptOn { get; set; } +} + +/// Empty request body — the policy is identified by the route key. +public class RetryPolicyUsageRequest +{ +} + +/// +/// Clears spent budget so a group starts retrying again. Omit both fields to reset every +/// integration and group of the policy. +/// +public class RetryPolicyResetUsage +{ + public int? SubscriptionId { get; set; } + public Guid? GroupId { get; set; } +} + /// /// Simulates evaluating a (possibly unsaved/draft) set of retry groups against a single /// failure, across as many consecutive attempts as requested, so the management UI can diff --git a/SW.Bitween.Sdk/Model/Xchange.cs b/SW.Bitween.Sdk/Model/Xchange.cs index fa321a61..e29dbbc8 100644 --- a/SW.Bitween.Sdk/Model/Xchange.cs +++ b/SW.Bitween.Sdk/Model/Xchange.cs @@ -97,5 +97,8 @@ public class XchangeRow public string CorrelationId { get; set; } public int? PartnerId { get; set; } public DateTime? ScheduledRetryOn { get; set; } + + /// Why the retry policy declined to schedule another attempt, when it declined. + public string RetryBlockedReason { get; set; } } } \ No newline at end of file diff --git a/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs b/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs index 4c58ec4b..568d0d27 100644 --- a/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs +++ b/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using SW.Bitween.Model; @@ -57,6 +58,11 @@ private static RetryGroup BadResultGroup( } }; + // Each call gets its own budget, so tests that don't care about the shared total + // stay isolated from each other. + private static RetryPolicyEvaluator Evaluator(IRetryPolicy policy) => + new RetryPolicyEvaluator(policy, new InMemoryRetryGroupBudget()); + private sealed class TestPolicy(RetryGroup[] groups) : IRetryPolicy { public List Groups { get; } = new List(groups); @@ -218,124 +224,124 @@ public void JsonPathMatcher_ArrayIndexer_Match() // ─── Evaluator: basic routing ──────────────────────────────────────────────── [TestMethod] - public void Evaluator_MatchingGroup_AllowsRetry() + public async Task Evaluator_MatchingGroup_AllowsRetry() { var policy = PolicyWith(ErrorGroup("transient", new ContainsMatcher { Value = "timeout" })); - var ev = new RetryPolicyEvaluator(policy); - var decision = ev.Evaluate(XchangeResultType.Error, "Connection timeout", 0); + var ev = Evaluator(policy); + var decision = await ev.Evaluate(XchangeResultType.Error, "Connection timeout", 0); Assert.IsTrue(decision.ShouldRetry); Assert.AreEqual("transient", decision.MatchedGroupName); } [TestMethod] - public void Evaluator_NoMatchingGroup_Blocks() + public async Task Evaluator_NoMatchingGroup_Blocks() { var policy = PolicyWith(ErrorGroup("transient", new ContainsMatcher { Value = "timeout" })); - var ev = new RetryPolicyEvaluator(policy); - var decision = ev.Evaluate(XchangeResultType.Error, "Disk full", 0); + var ev = Evaluator(policy); + var decision = await ev.Evaluate(XchangeResultType.Error, "Disk full", 0); Assert.IsFalse(decision.ShouldRetry); } [TestMethod] - public void Evaluator_WrongResultType_GroupSkipped() + public async Task Evaluator_WrongResultType_GroupSkipped() { var policy = PolicyWith(BadResultGroup("bad", new JsonPathMatcher { Path = "$.retryable", Op = JsonPathOp.Exists })); - var ev = new RetryPolicyEvaluator(policy); + var ev = Evaluator(policy); // Group is for BadResult only — must be skipped for Error - var decision = ev.Evaluate(XchangeResultType.Error, "some exception", 0); + var decision = await ev.Evaluate(XchangeResultType.Error, "some exception", 0); Assert.IsFalse(decision.ShouldRetry); } [TestMethod] - public void Evaluator_ContainsMatcher_MatchesBadResultBody() + public async Task Evaluator_ContainsMatcher_MatchesBadResultBody() { var policy = PolicyWith(BadResultGroup("bad", new ContainsMatcher { Value = "INSUFFICIENT_STOCK" })); - var ev = new RetryPolicyEvaluator(policy); - var decision = ev.Evaluate(XchangeResultType.BadResult, "{\"code\":\"INSUFFICIENT_STOCK\"}", 0); + var ev = Evaluator(policy); + var decision = await ev.Evaluate(XchangeResultType.BadResult, "{\"code\":\"INSUFFICIENT_STOCK\"}", 0); Assert.IsTrue(decision.ShouldRetry); Assert.AreEqual("bad", decision.MatchedGroupName); } [TestMethod] - public void Evaluator_ContainsMatcher_MatchesNonJsonBadResultBody() + public async Task Evaluator_ContainsMatcher_MatchesNonJsonBadResultBody() { // A 4xx body need not be JSON — text matchers are the only way to reach these. var policy = PolicyWith(BadResultGroup("bad", new ContainsMatcher { Value = "rate limit" })); - var ev = new RetryPolicyEvaluator(policy); - var decision = ev.Evaluate(XchangeResultType.BadResult, "Rate limit exceeded", 0); + var ev = Evaluator(policy); + var decision = await ev.Evaluate(XchangeResultType.BadResult, "Rate limit exceeded", 0); Assert.IsTrue(decision.ShouldRetry); } [TestMethod] - public void Evaluator_RegexMatcher_MatchesBadResultBody() + public async Task Evaluator_RegexMatcher_MatchesBadResultBody() { var policy = PolicyWith(BadResultGroup("bad", new RegexMatcher { Pattern = @"""status"":\s*""FAILED""" })); - var ev = new RetryPolicyEvaluator(policy); - var decision = ev.Evaluate(XchangeResultType.BadResult, "{\"status\": \"FAILED\"}", 0); + var ev = Evaluator(policy); + var decision = await ev.Evaluate(XchangeResultType.BadResult, "{\"status\": \"FAILED\"}", 0); Assert.IsTrue(decision.ShouldRetry); } [TestMethod] - public void Evaluator_ExceptionTypeMatcher_SkippedForBadResult() + public async Task Evaluator_ExceptionTypeMatcher_SkippedForBadResult() { // Exception type names are meaningless against a response body — stays Error-only. var policy = PolicyWith(BadResultGroup("bad", new ExceptionTypeMatcher { Value = "System.TimeoutException" })); - var ev = new RetryPolicyEvaluator(policy); - var decision = ev.Evaluate(XchangeResultType.BadResult, "System.TimeoutException in body", 0); + var ev = Evaluator(policy); + var decision = await ev.Evaluate(XchangeResultType.BadResult, "System.TimeoutException in body", 0); Assert.IsFalse(decision.ShouldRetry); } // ─── Evaluator: priority ordering ─────────────────────────────────────────── [TestMethod] - public void Evaluator_LowerPriorityEvaluatedFirst() + public async Task Evaluator_LowerPriorityEvaluatedFirst() { var g1 = ErrorGroup("low-num", new ContainsMatcher { Value = "error" }, priority: 1); var g2 = ErrorGroup("high-num", new ContainsMatcher { Value = "error" }, priority: 20); var policy = PolicyWith(g2, g1); // intentionally reversed in array - var ev = new RetryPolicyEvaluator(policy); - var decision = ev.Evaluate(XchangeResultType.Error, "error occurred", 0); + var ev = Evaluator(policy); + var decision = await ev.Evaluate(XchangeResultType.Error, "error occurred", 0); Assert.AreEqual("low-num", decision.MatchedGroupName); } [TestMethod] - public void Evaluator_OnlyMatchingGroupFires() + public async Task Evaluator_OnlyMatchingGroupFires() { var g1 = ErrorGroup("timeouts", new ContainsMatcher { Value = "timeout" }, priority: 1); var g2 = ErrorGroup("disk", new ContainsMatcher { Value = "disk" }, priority: 20); var policy = PolicyWith(g1, g2); - var ev = new RetryPolicyEvaluator(policy); - var decision = ev.Evaluate(XchangeResultType.Error, "disk full", 0); + var ev = Evaluator(policy); + var decision = await ev.Evaluate(XchangeResultType.Error, "disk full", 0); Assert.AreEqual("disk", decision.MatchedGroupName); } // ─── Evaluator: budget — MaxAttemptsPerError ───────────────────────────────── [TestMethod] - public void Evaluator_MaxAttemptsPerError_BlocksAfterCap() + public async Task Evaluator_MaxAttemptsPerError_BlocksAfterCap() { var policy = PolicyWith(ErrorGroup("transient", new ContainsMatcher { Value = "err" }, maxPerError: 2)); - var ev = new RetryPolicyEvaluator(policy); + var ev = Evaluator(policy); - Assert.IsTrue(ev.Evaluate(XchangeResultType.Error, "err", 0).ShouldRetry); - Assert.IsTrue(ev.Evaluate(XchangeResultType.Error, "err", 1).ShouldRetry); - Assert.IsFalse(ev.Evaluate(XchangeResultType.Error, "err", 2).ShouldRetry); // cap = 2 + Assert.IsTrue((await ev.Evaluate(XchangeResultType.Error, "err", 0)).ShouldRetry); + Assert.IsTrue((await ev.Evaluate(XchangeResultType.Error, "err", 1)).ShouldRetry); + Assert.IsFalse((await ev.Evaluate(XchangeResultType.Error, "err", 2)).ShouldRetry); // cap = 2 } // ─── Evaluator: budget — MaxAttemptsTotal ──────────────────────────────────── [TestMethod] - public void Evaluator_MaxAttemptsTotal_BlocksAfterGroupCap() + public async Task Evaluator_MaxAttemptsTotal_BlocksAfterGroupCap() { var policy = PolicyWith(ErrorGroup("transient", new ContainsMatcher { Value = "err" }, maxPerError: 100, maxTotal: 3)); - var ev = new RetryPolicyEvaluator(policy); + var ev = Evaluator(policy); // Three different "messages" (attempt index 0 each time) exhaust the group total - Assert.IsTrue(ev.Evaluate(XchangeResultType.Error, "err", 0).ShouldRetry); - Assert.IsTrue(ev.Evaluate(XchangeResultType.Error, "err", 0).ShouldRetry); - Assert.IsTrue(ev.Evaluate(XchangeResultType.Error, "err", 0).ShouldRetry); - Assert.IsFalse(ev.Evaluate(XchangeResultType.Error, "err", 0).ShouldRetry); // exceeded total=3 + Assert.IsTrue((await ev.Evaluate(XchangeResultType.Error, "err", 0)).ShouldRetry); + Assert.IsTrue((await ev.Evaluate(XchangeResultType.Error, "err", 0)).ShouldRetry); + Assert.IsTrue((await ev.Evaluate(XchangeResultType.Error, "err", 0)).ShouldRetry); + Assert.IsFalse((await ev.Evaluate(XchangeResultType.Error, "err", 0)).ShouldRetry); // exceeded total=3 } // ─── Evaluator: delay strategies ───────────────────────────────────────────── @@ -369,67 +375,74 @@ public void ExponentialDelay_DoublesAndCaps() } [TestMethod] - public void Evaluator_DelayFromStrategy_ReturnsCorrectValue() + public async Task Evaluator_DelayFromStrategy_ReturnsCorrectValue() { var policy = PolicyWith(ErrorGroup("transient", new ContainsMatcher { Value = "err" }, delay: new FixedDelayStrategy { DelayMs = 3000 })); - var ev = new RetryPolicyEvaluator(policy); - var decision = ev.Evaluate(XchangeResultType.Error, "err", 0); + var ev = Evaluator(policy); + var decision = await ev.Evaluate(XchangeResultType.Error, "err", 0); Assert.AreEqual(TimeSpan.FromMilliseconds(3000), decision.Delay); } - // ─── Evaluator: GroupAttemptCounts persistence ─────────────────────────────── + // ─── Evaluator: the total cap is shared, not per message ───────────────────── [TestMethod] - public void GroupAttemptCounts_RestoredAcrossEvaluators_ContinuesBudget() + public async Task Evaluator_MaxAttemptsTotal_SharedAcrossSeparateMessages() { + // The bug this covers: one evaluator per failed xchange, each starting from zero, so + // four failing messages × 3 per-message attempts produced 12 retries under a total of 10. var policy = PolicyWith(ErrorGroup("transient", new ContainsMatcher { Value = "err" }, - maxPerError: 100, maxTotal: 2)); - - var ev1 = new RetryPolicyEvaluator(policy); - Assert.IsTrue(ev1.Evaluate(XchangeResultType.Error, "err", 0).ShouldRetry); // group count = 1 - var counts = ev1.GetGroupAttemptCounts(); + maxPerError: 3, maxTotal: 4)); + var budget = new InMemoryRetryGroupBudget(); - // Simulate the next evaluator instance restoring saved state - var ev2 = new RetryPolicyEvaluator(policy); - ev2.RestoreGroupAttemptCounts(counts); // group count restored to 1 - Assert.IsTrue(ev2.Evaluate(XchangeResultType.Error, "err", 0).ShouldRetry); // group count = 2 - var counts2 = ev2.GetGroupAttemptCounts(); + var allowed = 0; + for (var message = 0; message < 4; message++) + for (var attempt = 0; attempt < 3; attempt++) + { + // A fresh evaluator per failure, exactly as XchangeService builds one. + var ev = new RetryPolicyEvaluator(policy, budget); + if ((await ev.Evaluate(XchangeResultType.Error, "err", attempt)).ShouldRetry) allowed++; + } - var ev3 = new RetryPolicyEvaluator(policy); - ev3.RestoreGroupAttemptCounts(counts2); // group count restored to 2 - Assert.IsFalse(ev3.Evaluate(XchangeResultType.Error, "err", 0).ShouldRetry); // exceeded total=2 + Assert.AreEqual(4, allowed); } [TestMethod] - public void GroupAttemptCounts_WithoutRestore_BudgetResetsToZero() + public async Task Evaluator_MaxAttemptsTotal_SeparateBudgetsDoNotShare() { + // Two integrations pointed at the same policy template get independent totals. var policy = PolicyWith(ErrorGroup("transient", new ContainsMatcher { Value = "err" }, maxPerError: 100, maxTotal: 1)); - var ev1 = new RetryPolicyEvaluator(policy); - Assert.IsTrue(ev1.Evaluate(XchangeResultType.Error, "err", 0).ShouldRetry); // exhausted - - // Fresh evaluator without restore — budget starts from 0 again - var ev2 = new RetryPolicyEvaluator(policy); - Assert.IsTrue(ev2.Evaluate(XchangeResultType.Error, "err", 0).ShouldRetry); + Assert.IsTrue((await Evaluator(policy).Evaluate(XchangeResultType.Error, "err", 0)).ShouldRetry); + Assert.IsTrue((await Evaluator(policy).Evaluate(XchangeResultType.Error, "err", 0)).ShouldRetry); } [TestMethod] - public void GetGroupAttemptCounts_ReturnsNonEmptyAfterMatch() + public async Task Evaluator_PerMessageCapBlocked_DoesNotSpendSharedTotal() { - var policy = PolicyWith(ErrorGroup("transient", new ContainsMatcher { Value = "err" })); - var ev = new RetryPolicyEvaluator(policy); - ev.Evaluate(XchangeResultType.Error, "err", 0); - var counts = ev.GetGroupAttemptCounts(); - Assert.IsTrue(counts.Count > 0); + var policy = PolicyWith(ErrorGroup("transient", new ContainsMatcher { Value = "err" }, + maxPerError: 1, maxTotal: 2)); + var budget = new InMemoryRetryGroupBudget(); + + // Attempt index 1 is past the per-message cap, so it must not claim a slot. + Assert.IsFalse((await new RetryPolicyEvaluator(policy, budget) + .Evaluate(XchangeResultType.Error, "err", 1)).ShouldRetry); + + // Both slots of the total are therefore still there. + Assert.IsTrue((await new RetryPolicyEvaluator(policy, budget) + .Evaluate(XchangeResultType.Error, "err", 0)).ShouldRetry); + Assert.IsTrue((await new RetryPolicyEvaluator(policy, budget) + .Evaluate(XchangeResultType.Error, "err", 0)).ShouldRetry); + Assert.IsFalse((await new RetryPolicyEvaluator(policy, budget) + .Evaluate(XchangeResultType.Error, "err", 0)).ShouldRetry); } // ─── Evaluator: Block action ───────────────────────────────────────────────── [TestMethod] - public void Evaluator_BlockAction_NeverRetries() + public async Task Evaluator_BlockAction_NeverRetries() { var blockGroup = new RetryGroup { @@ -442,8 +455,8 @@ public void Evaluator_BlockAction_NeverRetries() Budget = null }; var policy = PolicyWith(blockGroup); - var ev = new RetryPolicyEvaluator(policy); - var decision = ev.Evaluate(XchangeResultType.Error, "fatal: cannot recover", 0); + var ev = Evaluator(policy); + var decision = await ev.Evaluate(XchangeResultType.Error, "fatal: cannot recover", 0); Assert.IsFalse(decision.ShouldRetry); } } From ca088860debb405d6a3ebb6de715a5f93d83f711 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 11 Aug 2026 15:12:09 +0300 Subject: [PATCH 03/54] Make budget claims atomic and keep failures visible Claim a group slot with one conditional UPDATE instead of read-then-write, so concurrent failures across instances cannot both take the last slot. The first row is inserted on its own context, falling back to the increment if that race is lost. Guard retry evaluation so a throw there can no longer replace the original exception and discard the XchangeResult. Clear a group's usage rows when it is removed from a policy or the policy is deleted, and require Admin or Member to read usage. --- .../Resources/RetryPolicies/Delete.cs | 11 +++ .../Resources/RetryPolicies/Update.cs | 16 ++++ .../Resources/RetryPolicies/Usage.cs | 7 +- SW.Bitween.Api/Services/RetryGroupBudget.cs | 82 ++++++++++++----- SW.Bitween.Api/Services/XchangeService.cs | 30 ++++++- .../Tests/RetryPolicyTests.cs | 87 +++++++++++++++++-- 6 files changed, 198 insertions(+), 35 deletions(-) diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs b/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs index 571835e4..538a617c 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Delete.cs @@ -28,7 +28,18 @@ public async Task Handle(int key) if (inUse) throw new SWException("Cannot delete a retry policy that is assigned to one or more subscriptions."); + // Same reason as Update: the policy's groups are about to stop existing, so clear their + // usage rows rather than strand them. + var policy = await _dbContext.FindAsync(key); + var groupIds = policy.Groups.Select(g => g.Id).ToList(); + await _dbContext.DeleteByKeyAsync(key); + + if (groupIds.Count > 0) + await _dbContext.Set() + .Where(u => groupIds.Contains(u.GroupId)) + .ExecuteDeleteAsync(); + return null; } } diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Update.cs b/SW.Bitween.Api/Resources/RetryPolicies/Update.cs index 47846ac2..60a8040d 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Update.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Update.cs @@ -1,4 +1,6 @@ +using System.Linq; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain; using SW.Bitween.Domain.Accounts; using SW.Bitween.Model; @@ -23,9 +25,23 @@ public async Task Handle(int key, RetryPolicyUpdate model) RetryGroupValidation.EnsureCanFire(model.Groups); var entity = await _dbContext.FindAsync(key); + + // Spent budget is keyed by group id, so a group removed here would leave usage rows + // that no policy claims — invisible to the usage report and beyond the reach of reset. + var removedGroupIds = entity.Groups + .Select(g => g.Id) + .Except((model.Groups ?? []).Select(g => g.Id)) + .ToList(); + entity.Name = model.Name; entity.Groups = model.Groups ?? []; await _dbContext.SaveChangesAsync(); + + if (removedGroupIds.Count > 0) + await _dbContext.Set() + .Where(u => removedGroupIds.Contains(u.GroupId)) + .ExecuteDeleteAsync(); + return null; } } diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs b/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs index 32a925e5..5c836380 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain; +using SW.Bitween.Domain.Accounts; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -16,14 +17,18 @@ namespace SW.Bitween.Resources.RetryPolicies; public class Usage : ICommandHandler { private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; - public Usage(BitweenDbContext dbContext) + public Usage(BitweenDbContext dbContext, RequestContext requestContext) { _dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle(int key, RetryPolicyUsageRequest request) { + _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + var policy = await _dbContext.Set().AsNoTracking() .FirstOrDefaultAsync(p => p.Id == key); if (policy == null) throw new SWNotFoundException(key.ToString()); diff --git a/SW.Bitween.Api/Services/RetryGroupBudget.cs b/SW.Bitween.Api/Services/RetryGroupBudget.cs index 85f3c3b8..d35e849d 100644 --- a/SW.Bitween.Api/Services/RetryGroupBudget.cs +++ b/SW.Bitween.Api/Services/RetryGroupBudget.cs @@ -1,6 +1,8 @@ using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using SW.Bitween.Domain; using SW.Bitween.Model; @@ -11,36 +13,70 @@ namespace SW.Bitween; /// integration, so a policy template shared by many integrations gives each its own total /// instead of letting one noisy integration spend everyone's budget. /// -public class RetryGroupBudget(BitweenDbContext dbContext, int subscriptionId) : IRetryGroupBudget +public class RetryGroupBudget( + BitweenDbContext dbContext, + IServiceProvider serviceProvider, + int subscriptionId) : IRetryGroupBudget { /// /// - /// The increment is left for the caller's SaveChangesAsync so it commits in the same - /// transaction as the DelayedRetry row it authorises — a scheduled retry and its - /// spent slot can never disagree. Two failures of the same group evaluated concurrently can - /// each read the same count and overshoot the cap by the number of simultaneous failures. + /// + /// The claim is a single conditional UPDATE, so the check and the increment happen as + /// one database operation. Bitween runs several instances, and a read-then-write would let two + /// simultaneous failures both observe the last free slot and both retry, exceeding the cap. + /// + /// + /// Because it commits on its own rather than with the caller's SaveChangesAsync, a slot + /// can be charged for a retry that is never scheduled if that later save fails. That errs + /// toward retrying less than the cap allows, and RetryPolicies/resetusage can return it. + /// The alternative — an explicit transaction spanning the caller's save — would publish this + /// xchange's bus events before the commit, since BitweenDbContext dispatches domain events + /// inside SaveChangesAsync. + /// /// public async Task TryConsume(Guid groupId, int maxAttemptsTotal) { - var usage = await dbContext.Set() - .FirstOrDefaultAsync(u => u.SubscriptionId == subscriptionId && u.GroupId == groupId); - - if ((usage?.AttemptsUsed ?? 0) >= maxAttemptsTotal) return false; - - if (usage == null) - dbContext.Add(new RetryGroupUsage - { - SubscriptionId = subscriptionId, - GroupId = groupId, - AttemptsUsed = 1, - LastAttemptOn = DateTime.UtcNow - }); - else + if (maxAttemptsTotal <= 0) return false; + + if (await TryIncrement(dbContext, groupId, maxAttemptsTotal)) return true; + + // 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; + + // 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 + // duplicate and the conditional increment is then applied to the winner's row. + using var scope = serviceProvider.CreateScope(); + var isolated = scope.ServiceProvider.GetRequiredService(); + + isolated.Add(new RetryGroupUsage { - usage.AttemptsUsed++; - usage.LastAttemptOn = DateTime.UtcNow; - } + SubscriptionId = subscriptionId, + GroupId = groupId, + AttemptsUsed = 1, + LastAttemptOn = DateTime.UtcNow + }); - return true; + try + { + await isolated.SaveChangesAsync(); + return true; + } + catch (DbUpdateException) + { + return await TryIncrement(dbContext, groupId, maxAttemptsTotal); + } } + + private async Task TryIncrement(BitweenDbContext db, Guid groupId, int maxAttemptsTotal) => + await db.Set() + .Where(u => u.SubscriptionId == subscriptionId + && u.GroupId == groupId + && u.AttemptsUsed < maxAttemptsTotal) + .ExecuteUpdateAsync(s => s + .SetProperty(u => u.AttemptsUsed, u => u.AttemptsUsed + 1) + .SetProperty(u => u.LastAttemptOn, _ => DateTime.UtcNow)) > 0; } diff --git a/SW.Bitween.Api/Services/XchangeService.cs b/SW.Bitween.Api/Services/XchangeService.cs index 0d32e9df..34a1cc98 100644 --- a/SW.Bitween.Api/Services/XchangeService.cs +++ b/SW.Bitween.Api/Services/XchangeService.cs @@ -422,7 +422,8 @@ private async Task Process(XchangeMessage message) responseXchange?.Id); _dbContext.Add(xchangeResult); if (responseFile?.BadData == true) - await TryScheduleAutoRetry(xchange, XchangeResultType.BadResult, responseFile.Data, xchangeResult); + await TrySchedulingWithoutLosingTheResult(xchange, XchangeResultType.BadResult, responseFile.Data, + xchangeResult); await _dbContext.SaveChangesAsync(); } catch (Exception ex) @@ -430,11 +431,34 @@ private async Task Process(XchangeMessage message) var xchangeResult = new XchangeResult(xchange.Id, workGroup, outputFile, responseFile, responseXchange?.Id, ex.ToString()); _dbContext.Add(xchangeResult); - await TryScheduleAutoRetry(xchange, XchangeResultType.Error, ex.ToString(), xchangeResult); + await TrySchedulingWithoutLosingTheResult(xchange, XchangeResultType.Error, ex.ToString(), xchangeResult); await _dbContext.SaveChangesAsync(); } } + /// + /// Evaluates the retry policy without ever costing the caller its failure record. + /// + /// + /// Scheduling runs before the is saved and touches the database + /// several times. Letting it throw would replace the original exception with its own and abort + /// the save, so the failure would vanish from the UI entirely and only reappear as a silent + /// redelivery. Losing the retry is recoverable; losing the record of what went wrong is not. + /// + private async Task TrySchedulingWithoutLosingTheResult(Xchange xchange, XchangeResultType resultType, + string content, XchangeResult xchangeResult) + { + try + { + await TryScheduleAutoRetry(xchange, resultType, content, xchangeResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Auto-retry evaluation failed for xchange {XchangeId}; the failure result is still recorded.", + xchange.Id); + } + } + private async Task TryScheduleAutoRetry(Xchange xchange, XchangeResultType resultType, string content, XchangeResult xchangeResult) { @@ -455,7 +479,7 @@ private async Task TryScheduleAutoRetry(Xchange xchange, XchangeResultType resul if (policy?.Groups == null || policy.Groups.Count == 0) return; var evaluator = new RetryPolicyEvaluator(policy, - new RetryGroupBudget(_dbContext, xchange.SubscriptionId.Value)); + new RetryGroupBudget(_dbContext, _serviceProvider, xchange.SubscriptionId.Value)); var attemptIndex = await CountRetryChainDepth(xchange); var decision = await evaluator.Evaluate(resultType, content, attemptIndex); diff --git a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs index 772470d1..0acecd6e 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs @@ -370,7 +370,7 @@ public async Task Group_total_is_shared_across_separate_messages_of_the_same_int for (var attempt = 0; attempt < 3; attempt++) { // A fresh evaluator and store per failure, exactly as XchangeService builds them. - var evaluator = new RetryPolicyEvaluator(policy, new RetryGroupBudget(db, sub.Id)); + var evaluator = new RetryPolicyEvaluator(policy, new RetryGroupBudget(db, scope.ServiceProvider, sub.Id)); var decision = await evaluator.Evaluate(XchangeResultType.Error, "timeout", attempt); if (decision.ShouldRetry) allowed++; await db.SaveChangesAsync(); @@ -421,13 +421,48 @@ public async Task Group_total_is_tracked_per_integration_not_per_policy() async Task Allow(int subscriptionId) { - var evaluator = new RetryPolicyEvaluator(policy, new RetryGroupBudget(db, subscriptionId)); + var evaluator = new RetryPolicyEvaluator(policy, new RetryGroupBudget(db, scope.ServiceProvider, subscriptionId)); var decision = await evaluator.Evaluate(XchangeResultType.Error, "timeout", 0); await db.SaveChangesAsync(); return decision.ShouldRetry; } } + [Fact] + public async Task Concurrent_claims_never_exceed_the_group_total() + { + await using var setup = _fixture.CreateScope(); + var setupDb = setup.ServiceProvider.GetRequiredService(); + + var doc = new Document(7010, "Concurrent Budget Doc"); + setupDb.Set().Add(doc); + var sub = new Subscription("Concurrent Budget Sub", doc.Id); + setupDb.Set().Add(sub); + await setupDb.SaveChangesAsync(); + + var groupId = Guid.NewGuid(); + const int cap = 5; + const int racers = 16; + + // Bitween runs several instances, so simultaneous failures of the same integration and + // group are normal. Each racer gets its own scope and context, mimicking separate + // instances: a read-then-write would let several observe the same free slot at once. + var tasks = Enumerable.Range(0, racers).Select(async _ => + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await new RetryGroupBudget(db, scope.ServiceProvider, sub.Id).TryConsume(groupId, cap); + }); + + var granted = (await Task.WhenAll(tasks)).Count(allowed => allowed); + + Assert.Equal(cap, granted); + + var usage = await setupDb.Set().AsNoTracking() + .SingleAsync(u => u.SubscriptionId == sub.Id && u.GroupId == groupId); + Assert.Equal(cap, usage.AttemptsUsed); + } + // ─── Usage reporting and reset ────────────────────────────────────────────── [Fact] @@ -452,11 +487,11 @@ public async Task Usage_reports_spent_budget_and_reset_clears_it() await db.SaveChangesAsync(); // Spend the whole budget (SimplePolicy allows 10 in total). - var budget = new RetryGroupBudget(db, sub.Id); + var budget = new RetryGroupBudget(db, scope.ServiceProvider, sub.Id); for (var i = 0; i < 10; i++) await budget.TryConsume(groupId, 10); await db.SaveChangesAsync(); - var rows = (List)await new Usage(db).Handle(policyId, new RetryPolicyUsageRequest()); + var rows = (List)await new Usage(db, ctx).Handle(policyId, new RetryPolicyUsageRequest()); var row = Assert.Single(rows); Assert.Equal(sub.Id, row.SubscriptionId); Assert.Equal("Usage Sub", row.SubscriptionName); @@ -470,10 +505,10 @@ public async Task Usage_reports_spent_budget_and_reset_clears_it() GroupId = groupId }); - Assert.Empty((List)await new Usage(db).Handle(policyId, new RetryPolicyUsageRequest())); + Assert.Empty((List)await new Usage(db, ctx).Handle(policyId, new RetryPolicyUsageRequest())); // And the group can retry again. - Assert.True(await new RetryGroupBudget(db, sub.Id).TryConsume(groupId, 10)); + Assert.True(await new RetryGroupBudget(db, scope.ServiceProvider, sub.Id).TryConsume(groupId, 10)); } [Fact] @@ -498,13 +533,49 @@ public async Task Reset_does_not_touch_counters_of_another_policy() otherSub.SetRetryPolicy(otherId, null); await db.SaveChangesAsync(); - await new RetryGroupBudget(db, otherSub.Id).TryConsume(otherGroupId, 10); + await new RetryGroupBudget(db, scope.ServiceProvider, otherSub.Id).TryConsume(otherGroupId, 10); await db.SaveChangesAsync(); // 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).Handle(otherId, new RetryPolicyUsageRequest())); + Assert.Single((List)await new Usage(db, ctx).Handle(otherId, new RetryPolicyUsageRequest())); + } + + [Fact] + public async Task Removing_a_group_clears_its_spent_budget() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7009, "Removed Group Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + var policyId = (int)await new Create(db, ctx).Handle(SimplePolicy("Removed Group Policy")); + var saved = await db.Set().AsNoTracking().SingleAsync(p => p.Id == policyId); + var groupId = saved.Groups[0].Id; + + var sub = new Subscription("Removed Group Sub", doc.Id); + db.Set().Add(sub); + await db.SaveChangesAsync(); + sub.SetRetryPolicy(policyId, null); + await db.SaveChangesAsync(); + + await new RetryGroupBudget(db, scope.ServiceProvider, sub.Id).TryConsume(groupId, 10); + await db.SaveChangesAsync(); + Assert.True(await db.Set().AnyAsync(u => u.GroupId == groupId)); + + // Dropping the group must take its counter with it, or the row is stranded where + // neither the usage report nor reset can reach it. + await new Update(db, ctx).Handle(policyId, new RetryPolicyUpdate + { + Name = "Removed Group Policy", + Groups = [] + }); + + Assert.False(await db.Set().AnyAsync(u => u.GroupId == groupId)); } // ─── Test / dry-run endpoint ──────────────────────────────────────────────── From fdf46fe8fd6b17943b4e0fa3f725c64dde6e2b54 Mon Sep 17 00:00:00 2001 From: Musa Misto <64855513+MusaMisto@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:25:35 +0300 Subject: [PATCH 04/54] chore(dependabot): raise semver-patch cooldown to 5 days (#243) Patch is the only update class that auto-merges with no human involved, yet carried the shortest cooldown (1 day, a third of GitHub's 3-day default). Soak time should scale with how little scrutiny a bump receives, not with how breaking semver claims it is. Costs nothing in security terms: cooldown never applies to Dependabot security updates, which still fire immediately. Propagated from simplify9/.github (dependabot-templates). --- .github/dependabot.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1a5b3a71..a8f4d5cc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,7 +13,7 @@ updates: timezone: "Asia/Amman" cooldown: default-days: 3 - semver-patch-days: 1 + semver-patch-days: 5 semver-minor-days: 3 semver-major-days: 7 open-pull-requests-limit: 10 @@ -36,7 +36,7 @@ updates: timezone: "Asia/Amman" cooldown: default-days: 3 - semver-patch-days: 1 + semver-patch-days: 5 semver-minor-days: 3 semver-major-days: 7 open-pull-requests-limit: 5 @@ -54,7 +54,7 @@ updates: timezone: "Asia/Amman" cooldown: default-days: 3 - semver-patch-days: 1 + semver-patch-days: 5 semver-minor-days: 3 semver-major-days: 7 open-pull-requests-limit: 5 From 77544aa7dd25b29db49c6e09ae7fbdb3d7eae347 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 11 Aug 2026 16:59:05 +0300 Subject: [PATCH 05/54] Add security headers and restrict CORS to configured origins --- SW.Bitween.Web/Startup.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index cfa8a114..8bca304c 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -337,12 +337,9 @@ public void ConfigureServices(IServiceCollection services) .AllowAnyMethod() .AllowCredentials(); } - else - { - builder.AllowAnyOrigin(); - builder.AllowAnyHeader(); - builder.AllowAnyMethod(); - } + // When no origins are configured, allow no cross-origin access. + // The SPA is served same-origin, so CORS is only needed for + // split-host / local-dev setups that set CorsOrigins explicitly. }); }); @@ -358,6 +355,15 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseForwardedHeaders(); + app.Use(async (context, next) => + { + var headers = context.Response.Headers; + headers["X-Frame-Options"] = "DENY"; + headers["X-Content-Type-Options"] = "nosniff"; + headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + await next(); + }); + if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); From b5e7c924e51ff76461aa20f75b169273d193e822 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 11 Aug 2026 16:59:17 +0300 Subject: [PATCH 06/54] Enforce a server-side password policy on account create and change-password --- .../Extensions/PasswordValidationExtensions.cs | 17 +++++++++++++++++ .../Resources/Accounts/ChangePassword.cs | 9 +++++++++ SW.Bitween.Api/Resources/Accounts/Create.cs | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 SW.Bitween.Api/Extensions/PasswordValidationExtensions.cs diff --git a/SW.Bitween.Api/Extensions/PasswordValidationExtensions.cs b/SW.Bitween.Api/Extensions/PasswordValidationExtensions.cs new file mode 100644 index 00000000..3da0b26a --- /dev/null +++ b/SW.Bitween.Api/Extensions/PasswordValidationExtensions.cs @@ -0,0 +1,17 @@ +using FluentValidation; + +namespace SW.Bitween +{ + public static class PasswordValidationExtensions + { + // Shared server-side password policy so every password-setting path + // (create account, change password) enforces the exact same rule. + public static IRuleBuilderOptions Password(this IRuleBuilder rule) => + rule.NotEmpty().WithMessage("Password is required.") + .MinimumLength(8).WithMessage("Password must be at least 8 characters.") + .Matches("[A-Z]").WithMessage("Password must contain an uppercase letter.") + .Matches("[a-z]").WithMessage("Password must contain a lowercase letter.") + .Matches("[0-9]").WithMessage("Password must contain a number.") + .Matches("[^A-Za-z0-9]").WithMessage("Password must contain a special character."); + } +} diff --git a/SW.Bitween.Api/Resources/Accounts/ChangePassword.cs b/SW.Bitween.Api/Resources/Accounts/ChangePassword.cs index 40ade9aa..a7f26a07 100644 --- a/SW.Bitween.Api/Resources/Accounts/ChangePassword.cs +++ b/SW.Bitween.Api/Resources/Accounts/ChangePassword.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using FluentValidation; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain.Accounts; using SW.Bitween.Model; @@ -37,4 +38,12 @@ public async Task Handle(ChangePasswordModel request) return null; } + + private class Validate : AbstractValidator + { + public Validate() + { + RuleFor(i => i.NewPassword).Password(); + } + } } \ No newline at end of file diff --git a/SW.Bitween.Api/Resources/Accounts/Create.cs b/SW.Bitween.Api/Resources/Accounts/Create.cs index f8e10142..7734e587 100644 --- a/SW.Bitween.Api/Resources/Accounts/Create.cs +++ b/SW.Bitween.Api/Resources/Accounts/Create.cs @@ -54,7 +54,7 @@ public Validate(BitweenOptions bitweenOptions) { RuleFor(i => i.Name).NotEmpty(); RuleFor(i => i.Email).NotEmpty(); - RuleFor(i => i.Password).NotEmpty().When(_ => !bitweenOptions.DisableEmailPasswordLogin); + RuleFor(i => i.Password).Password().When(_ => !bitweenOptions.DisableEmailPasswordLogin); RuleFor(i => i.Role).NotNull(); } } From 716e562a44fe4f469b3d43d05bb917a26d30fd52 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 13 Aug 2026 10:41:33 +0300 Subject: [PATCH 07/54] Apply security-audit remediations (accounts + responses) Account lockout with admin unlock, admin-only account listing, always-Secure refresh cookie, tightened CORS fallback, and hardened response headers (COOP, X-Permitted-Cross-Domain-Policies, Cache-Control, Clear-Site-Data on logout). --- SW.Bitween.Api/Data/BitweenDbContext.cs | 3 +- SW.Bitween.Api/Domain/Accounts/Account.cs | 28 + SW.Bitween.Api/Resources/Accounts/Login.cs | 26 +- SW.Bitween.Api/Resources/Accounts/Logout.cs | 3 + SW.Bitween.Api/Resources/Accounts/Search.cs | 11 +- SW.Bitween.Api/Resources/Accounts/Unlock.cs | 33 + ...260812092708_AddAccountLockout.Designer.cs | 1896 ++++++++++++++ .../20260812092708_AddAccountLockout.cs | 47 + .../BitweenDbContextModelSnapshot.cs | 7 + ...260812092701_AddAccountLockout.Designer.cs | 1893 ++++++++++++++ .../20260812092701_AddAccountLockout.cs | 47 + .../BitweenDbContextModelSnapshot.cs | 7 + SW.Bitween.PgSql/BitweenDbContext.cs | 3 +- ...260812092613_AddAccountLockout.Designer.cs | 2168 +++++++++++++++++ .../20260812092613_AddAccountLockout.cs | 52 + .../BitweenDbContextModelSnapshot.cs | 11 +- SW.Bitween.Sdk/Model/Account.cs | 7 + SW.Bitween.Web/Startup.cs | 17 + 18 files changed, 6251 insertions(+), 8 deletions(-) create mode 100644 SW.Bitween.Api/Resources/Accounts/Unlock.cs create mode 100644 SW.Bitween.MsSql/Migrations/20260812092708_AddAccountLockout.Designer.cs create mode 100644 SW.Bitween.MsSql/Migrations/20260812092708_AddAccountLockout.cs create mode 100644 SW.Bitween.MySql/Migrations/20260812092701_AddAccountLockout.Designer.cs create mode 100644 SW.Bitween.MySql/Migrations/20260812092701_AddAccountLockout.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260812092613_AddAccountLockout.Designer.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260812092613_AddAccountLockout.cs diff --git a/SW.Bitween.Api/Data/BitweenDbContext.cs b/SW.Bitween.Api/Data/BitweenDbContext.cs index cadd4704..3b704884 100644 --- a/SW.Bitween.Api/Data/BitweenDbContext.cs +++ b/SW.Bitween.Api/Data/BitweenDbContext.cs @@ -368,7 +368,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) Disabled = false, Password = defaultPasswordHash, Deleted = false, - Role = AccountRole.Admin + Role = AccountRole.Admin, + FailedLoginCount = 0 }); }); diff --git a/SW.Bitween.Api/Domain/Accounts/Account.cs b/SW.Bitween.Api/Domain/Accounts/Account.cs index a2bb0d0b..84d6624a 100644 --- a/SW.Bitween.Api/Domain/Accounts/Account.cs +++ b/SW.Bitween.Api/Domain/Accounts/Account.cs @@ -29,6 +29,34 @@ public Account(string displayName, string email, string password, AccountRole ro public string Password { get; set; } + public int FailedLoginCount { get; private set; } + public DateTime? LockoutEnd { get; private set; } + + public bool IsLockedOut(DateTime nowUtc) => LockoutEnd.HasValue && LockoutEnd.Value > nowUtc; + + public void RegisterFailedLogin(int maxAttempts, TimeSpan lockoutDuration, DateTime nowUtc) + { + FailedLoginCount++; + if (FailedLoginCount >= maxAttempts) + { + LockoutEnd = nowUtc.Add(lockoutDuration); + FailedLoginCount = 0; + } + } + + public void RegisterSuccessfulLogin() + { + FailedLoginCount = 0; + LockoutEnd = null; + } + + // Admin action: clear a lockout before it expires. + public void Unlock() + { + FailedLoginCount = 0; + LockoutEnd = null; + } + public bool AddEmailLoginMethod(string email, string password) { diff --git a/SW.Bitween.Api/Resources/Accounts/Login.cs b/SW.Bitween.Api/Resources/Accounts/Login.cs index b031b2bf..1f6327ef 100644 --- a/SW.Bitween.Api/Resources/Accounts/Login.cs +++ b/SW.Bitween.Api/Resources/Accounts/Login.cs @@ -15,6 +15,9 @@ namespace SW.Bitween.Resources.Accounts [Unprotect] public class Login : ICommandHandler { + private const int MaxFailedLoginAttempts = 5; + private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15); + private readonly BitweenDbContext _dbContext; private readonly BitweenOptions _BitweenSettings; private readonly JwtTokenParameters _jwtTokenParameters; @@ -118,20 +121,37 @@ public async Task Handle(UserLogin request) if (string.IsNullOrEmpty(refreshTokenValue) && !string.IsNullOrEmpty(request.Username) && !string.IsNullOrEmpty(request.Password) && string.IsNullOrEmpty(request.MsToken)) { + var nowUtc = DateTime.UtcNow; + if (account.IsLockedOut(nowUtc)) + { + var minutes = (int)Math.Ceiling((account.LockoutEnd!.Value - nowUtc).TotalMinutes); + _logger.LogWarning("Login rejected: account '{Email}' is temporarily locked.", account.Email); + throw new SWException( + $"Your account is temporarily locked due to multiple failed login attempts. " + + $"Please try again in {minutes} minute{(minutes == 1 ? "" : "s")}."); + } + if (request.Password == null || !SecurePasswordHasher.Verify(request.Password, account.Password)) + { + account.RegisterFailedLogin(MaxFailedLoginAttempts, LockoutDuration, nowUtc); + await _dbContext.SaveChangesAsync(); throw new SWException("Invalid username or password."); + } + + account.RegisterSuccessfulLogin(); } var newRefreshToken = CreateRefreshToken(account, LoginMethod.EmailAndPassword); await _dbContext.SaveChangesAsync(); - // Set refresh token as HttpOnly cookie — not accessible to JavaScript - var isHttps = _httpContextAccessor.HttpContext?.Request.IsHttps ?? false; + // Set refresh token as a secure, HttpOnly cookie — not accessible to JavaScript. + // Secure is always on: the app is served over HTTPS, and TLS is terminated at the + // reverse proxy, so Request.IsHttps would otherwise be false and drop the attribute. _httpContextAccessor.HttpContext?.Response.Cookies.Append("refresh_token", newRefreshToken, new CookieOptions { HttpOnly = true, - Secure = isHttps, + Secure = true, SameSite = SameSiteMode.Lax, Expires = DateTimeOffset.UtcNow.AddDays(30) }); diff --git a/SW.Bitween.Api/Resources/Accounts/Logout.cs b/SW.Bitween.Api/Resources/Accounts/Logout.cs index 5a8826ac..e6fe503e 100644 --- a/SW.Bitween.Api/Resources/Accounts/Logout.cs +++ b/SW.Bitween.Api/Resources/Accounts/Logout.cs @@ -40,6 +40,9 @@ public async Task Handle(UserLogout request) httpContext.Response.Cookies.Delete("refresh_token"); } + // Tell the browser to wipe cookies, cache and storage for this origin on logout. + httpContext?.Response.Headers.Append("Clear-Site-Data", "\"cache\", \"cookies\", \"storage\""); + return new { }; } } diff --git a/SW.Bitween.Api/Resources/Accounts/Search.cs b/SW.Bitween.Api/Resources/Accounts/Search.cs index fd7afebc..bb9cb79d 100644 --- a/SW.Bitween.Api/Resources/Accounts/Search.cs +++ b/SW.Bitween.Api/Resources/Accounts/Search.cs @@ -10,10 +10,12 @@ namespace SW.Bitween.Resources.Accounts public class Search : IQueryHandler { private readonly BitweenDbContext dbContext; + private readonly RequestContext _requestContext; - public Search(BitweenDbContext dbContext) + public Search(BitweenDbContext dbContext, RequestContext requestContext) { this.dbContext = dbContext; + _requestContext = requestContext; } public async Task Handle(SearchMembersModel request) @@ -25,10 +27,14 @@ public async Task Handle(SearchMembersModel request) if (request.Lookup) { + // id -> display name only; needed across the app (e.g. audit trails) return await query.OrderBy(i => i.DisplayName) .ToDictionaryAsync(i => i.Id, i => i.DisplayName); } + // the full roster (email, role) is an admin-only team-management view + _requestContext.EnsureAccess(AccountRole.Admin); + var count = await query.CountAsync(); var accounts = await query.OrderBy(i => i.CreatedOn) @@ -40,7 +46,8 @@ public async Task Handle(SearchMembersModel request) Email = a.Email, Name = a.DisplayName, Id = a.Id, - Role = a.Role.ToString() + Role = a.Role.ToString(), + LockoutEnd = a.LockoutEnd }) .ToListAsync(); diff --git a/SW.Bitween.Api/Resources/Accounts/Unlock.cs b/SW.Bitween.Api/Resources/Accounts/Unlock.cs new file mode 100644 index 00000000..cce4f443 --- /dev/null +++ b/SW.Bitween.Api/Resources/Accounts/Unlock.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Accounts; + +[HandlerName("unlock")] +public class Unlock : ICommandHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public Unlock(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(int key, UnlockAccountModel request) + { + _requestContext.EnsureAccess(AccountRole.Admin); + + var account = await _dbContext.Set().FindAsync(key); + if (account is null) + throw new SWValidationException("ACCOUNT_NOT_FOUND", $"No account exists with the id {key}"); + + account.Unlock(); + await _dbContext.SaveChangesAsync(); + + return null; + } +} diff --git a/SW.Bitween.MsSql/Migrations/20260812092708_AddAccountLockout.Designer.cs b/SW.Bitween.MsSql/Migrations/20260812092708_AddAccountLockout.Designer.cs new file mode 100644 index 00000000..a61e46f5 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260812092708_AddAccountLockout.Designer.cs @@ -0,0 +1,1896 @@ +// +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("20260812092708_AddAccountLockout")] + partial class AddAccountLockout + { + /// + 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("GroupAttemptCounts") + .HasColumnType("nvarchar(max)"); + + 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.RetryPolicy", 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("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("GroupAttemptCounts") + .HasColumnType("nvarchar(max)"); + + 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("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("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + 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/20260812092708_AddAccountLockout.cs b/SW.Bitween.MsSql/Migrations/20260812092708_AddAccountLockout.cs new file mode 100644 index 00000000..662b1a62 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260812092708_AddAccountLockout.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class AddAccountLockout : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FailedLoginCount", + table: "Accounts", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "LockoutEnd", + table: "Accounts", + type: "datetime2", + nullable: true); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 9999, + columns: new[] { "FailedLoginCount", "LockoutEnd" }, + values: new object[] { 0, null }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FailedLoginCount", + table: "Accounts"); + + migrationBuilder.DropColumn( + name: "LockoutEnd", + table: "Accounts"); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs index 43e9d519..d0d4314a 100644 --- a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -55,6 +55,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("EmailProvider") .HasColumnType("tinyint"); + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("LockoutEnd") + .HasColumnType("datetime2"); + b.Property("LoginMethods") .HasColumnType("tinyint"); @@ -95,6 +101,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) DisplayName = "Admin", Email = "admin@Bitween.systems", EmailProvider = (byte)0, + FailedLoginCount = 0, LoginMethods = (byte)2, Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", Role = 0 diff --git a/SW.Bitween.MySql/Migrations/20260812092701_AddAccountLockout.Designer.cs b/SW.Bitween.MySql/Migrations/20260812092701_AddAccountLockout.Designer.cs new file mode 100644 index 00000000..b7948513 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260812092701_AddAccountLockout.Designer.cs @@ -0,0 +1,1893 @@ +// +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("20260812092701_AddAccountLockout")] + partial class AddAccountLockout + { + /// + 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("GroupAttemptCounts") + .HasColumnType("longtext"); + + 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.RetryPolicy", 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("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("GroupAttemptCounts") + .HasColumnType("longtext"); + + 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("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("Success") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + 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/20260812092701_AddAccountLockout.cs b/SW.Bitween.MySql/Migrations/20260812092701_AddAccountLockout.cs new file mode 100644 index 00000000..0fabaec5 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260812092701_AddAccountLockout.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class AddAccountLockout : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FailedLoginCount", + table: "Accounts", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "LockoutEnd", + table: "Accounts", + type: "datetime(6)", + nullable: true); + + migrationBuilder.UpdateData( + table: "Accounts", + keyColumn: "Id", + keyValue: 9999, + columns: new[] { "FailedLoginCount", "LockoutEnd" }, + values: new object[] { 0, null }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FailedLoginCount", + table: "Accounts"); + + migrationBuilder.DropColumn( + name: "LockoutEnd", + table: "Accounts"); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs index ee14e415..9a2a34ee 100644 --- a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs @@ -55,6 +55,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("EmailProvider") .HasColumnType("tinyint unsigned"); + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("LockoutEnd") + .HasColumnType("datetime(6)"); + b.Property("LoginMethods") .HasColumnType("tinyint unsigned"); @@ -94,6 +100,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) DisplayName = "Admin", Email = "admin@Bitween.systems", EmailProvider = (byte)0, + FailedLoginCount = 0, LoginMethods = (byte)2, Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", Role = 0 diff --git a/SW.Bitween.PgSql/BitweenDbContext.cs b/SW.Bitween.PgSql/BitweenDbContext.cs index b159f6ce..dba47cac 100644 --- a/SW.Bitween.PgSql/BitweenDbContext.cs +++ b/SW.Bitween.PgSql/BitweenDbContext.cs @@ -339,7 +339,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) CreatedOn = defaultCreatedOn.ToUniversalTime(), Disabled = false, Password = defaultPasswordHash, - Role = AccountRole.Admin + Role = AccountRole.Admin, + FailedLoginCount = 0 }); }); diff --git a/SW.Bitween.PgSql/Migrations/20260812092613_AddAccountLockout.Designer.cs b/SW.Bitween.PgSql/Migrations/20260812092613_AddAccountLockout.Designer.cs new file mode 100644 index 00000000..cf14dea6 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260812092613_AddAccountLockout.Designer.cs @@ -0,0 +1,2168 @@ +// +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("20260812092613_AddAccountLockout")] + partial class AddAccountLockout + { + /// + 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>("GroupAttemptCounts") + .HasColumnType("jsonb") + .HasColumnName("group_attempt_counts"); + + 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.RetryPolicy", 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("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>("GroupAttemptCounts") + .HasColumnType("jsonb") + .HasColumnName("group_attempt_counts"); + + 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("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("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + 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/20260812092613_AddAccountLockout.cs b/SW.Bitween.PgSql/Migrations/20260812092613_AddAccountLockout.cs new file mode 100644 index 00000000..3577dd72 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260812092613_AddAccountLockout.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + public partial class AddAccountLockout : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "failed_login_count", + schema: "infolink", + table: "Accounts", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "lockout_end", + schema: "infolink", + table: "Accounts", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.UpdateData( + schema: "infolink", + table: "Accounts", + keyColumn: "id", + keyValue: 9999, + columns: new[] { "failed_login_count", "lockout_end" }, + values: new object[] { 0, null }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "failed_login_count", + schema: "infolink", + table: "Accounts"); + + migrationBuilder.DropColumn( + name: "lockout_end", + schema: "infolink", + table: "Accounts"); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs index 8cea92fe..3f60a6a4 100644 --- a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -66,6 +66,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -113,6 +121,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) DisplayName = "Admin", Email = "admin@Bitween.systems", EmailProvider = (byte)0, + FailedLoginCount = 0, LoginMethods = (byte)2, Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", Role = 0 @@ -941,7 +950,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("document_id"); - b.Property>("GroupAttemptCounts") + b.Property>("GroupAttemptCounts") .HasColumnType("jsonb") .HasColumnName("group_attempt_counts"); diff --git a/SW.Bitween.Sdk/Model/Account.cs b/SW.Bitween.Sdk/Model/Account.cs index c112c9d4..5628e74f 100644 --- a/SW.Bitween.Sdk/Model/Account.cs +++ b/SW.Bitween.Sdk/Model/Account.cs @@ -35,6 +35,13 @@ public class AccountModel public string Role { get; set; } public DateTime CreatedOn { get; set; } + + // Non-null and in the future => the account is currently locked out. + public DateTime? LockoutEnd { get; set; } +} + +public class UnlockAccountModel +{ } public class ChangePasswordModel diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index 8bca304c..aaa42f5f 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -1,5 +1,6 @@ using System; using System.Text; +using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; @@ -361,6 +362,22 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) headers["X-Frame-Options"] = "DENY"; headers["X-Content-Type-Options"] = "nosniff"; headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + headers["X-Permitted-Cross-Domain-Policies"] = "none"; + headers["Cross-Origin-Opener-Policy"] = "same-origin-allow-popups"; + + // Sensitive API responses (JSON) must not be cached by the browser or + // intermediaries. Scoped by content type so static assets stay cacheable. + context.Response.OnStarting(() => + { + var contentType = context.Response.ContentType; + if (!string.IsNullOrEmpty(contentType) && + contentType.Contains("application/json", StringComparison.OrdinalIgnoreCase)) + { + context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate"; + } + return Task.CompletedTask; + }); + await next(); }); From a14c0587fd70e1ee9f718bd2b85443f023dd39f3 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 13 Aug 2026 11:52:04 +0300 Subject: [PATCH 08/54] Reject empty-credential logins and make failed-login lockout atomic --- SW.Bitween.Api/Domain/Accounts/Account.cs | 10 ---------- SW.Bitween.Api/Resources/Accounts/Login.cs | 22 ++++++++++++++++++++-- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/SW.Bitween.Api/Domain/Accounts/Account.cs b/SW.Bitween.Api/Domain/Accounts/Account.cs index 84d6624a..d9da70e0 100644 --- a/SW.Bitween.Api/Domain/Accounts/Account.cs +++ b/SW.Bitween.Api/Domain/Accounts/Account.cs @@ -34,16 +34,6 @@ public Account(string displayName, string email, string password, AccountRole ro public bool IsLockedOut(DateTime nowUtc) => LockoutEnd.HasValue && LockoutEnd.Value > nowUtc; - public void RegisterFailedLogin(int maxAttempts, TimeSpan lockoutDuration, DateTime nowUtc) - { - FailedLoginCount++; - if (FailedLoginCount >= maxAttempts) - { - LockoutEnd = nowUtc.Add(lockoutDuration); - FailedLoginCount = 0; - } - } - public void RegisterSuccessfulLogin() { FailedLoginCount = 0; diff --git a/SW.Bitween.Api/Resources/Accounts/Login.cs b/SW.Bitween.Api/Resources/Accounts/Login.cs index 1f6327ef..e47bc8dd 100644 --- a/SW.Bitween.Api/Resources/Accounts/Login.cs +++ b/SW.Bitween.Api/Resources/Accounts/Login.cs @@ -71,6 +71,16 @@ public async Task Handle(UserLogin request) throw new SWException("Email and password login is disabled. Please sign in with Microsoft."); } + // A credential login must carry both a username and a password. Without this guard a + // request with a valid username but empty/missing password would skip verification + // below and still be issued a token. + if (string.IsNullOrEmpty(refreshTokenValue) && string.IsNullOrEmpty(request.MsToken) && + (string.IsNullOrEmpty(request.Username) || string.IsNullOrEmpty(request.Password))) + { + _logger.LogWarning("Login rejected: missing username or password on a credential login."); + throw new SWException("Invalid username or password."); + } + if (!string.IsNullOrEmpty(refreshTokenValue)) { // account query already filtered above @@ -134,8 +144,16 @@ public async Task Handle(UserLogin request) if (request.Password == null || !SecurePasswordHasher.Verify(request.Password, account.Password)) { - account.RegisterFailedLogin(MaxFailedLoginAttempts, LockoutDuration, nowUtc); - await _dbContext.SaveChangesAsync(); + // Atomic DB-side update so concurrent wrong-password attempts can't read the + // same count and lose increments, which would let them slip past the lockout. + var lockoutEnd = nowUtc.Add(LockoutDuration); + await _dbContext.Set() + .Where(a => a.Id == account.Id) + .ExecuteUpdateAsync(s => s + .SetProperty(a => a.LockoutEnd, + a => a.FailedLoginCount + 1 >= MaxFailedLoginAttempts ? lockoutEnd : a.LockoutEnd) + .SetProperty(a => a.FailedLoginCount, + a => a.FailedLoginCount + 1 >= MaxFailedLoginAttempts ? 0 : a.FailedLoginCount + 1)); throw new SWException("Invalid username or password."); } From 48a06f3d12e1c12c2681843de80d2068e00f9d9e Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 13 Aug 2026 11:52:04 +0300 Subject: [PATCH 09/54] Apply Cache-Control no-store to structured +json responses --- SW.Bitween.Web/Startup.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index aaa42f5f..3882fdc8 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -371,7 +371,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { var contentType = context.Response.ContentType; if (!string.IsNullOrEmpty(contentType) && - contentType.Contains("application/json", StringComparison.OrdinalIgnoreCase)) + (contentType.Contains("application/json", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("+json", StringComparison.OrdinalIgnoreCase))) { context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate"; } From 1fdac0463bb91c128c76b612ea6f1e4b1d832c76 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 18 Aug 2026 12:39:01 +0300 Subject: [PATCH 10/54] 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 11/54] 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 12/54] 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 13/54] 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 14/54] 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(); From efed203ca33c667ec1d30959d312877f44fc3cb2 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Wed, 19 Aug 2026 12:56:15 +0300 Subject: [PATCH 15/54] Report and reset retry budgets per subscription Inline policies had no id, so their counters were unreachable by every existing endpoint. Shared row builder extracted rather than copied. --- .../Resources/RetryPolicies/Usage.cs | 82 +------------ .../Subscriptions/ResetRetryUsage.cs | 49 ++++++++ .../Resources/Subscriptions/RetryUsage.cs | 50 ++++++++ SW.Bitween.Api/Services/RetryUsageReport.cs | 109 ++++++++++++++++++ .../Fixtures/BitweenFixture.cs | 32 ++++- SW.Bitween.Sdk/Model/RetryPolicyModel.cs | 12 +- SW.Bitween.Web/Startup.cs | 1 + 7 files changed, 253 insertions(+), 82 deletions(-) create mode 100644 SW.Bitween.Api/Resources/Subscriptions/ResetRetryUsage.cs create mode 100644 SW.Bitween.Api/Resources/Subscriptions/RetryUsage.cs create mode 100644 SW.Bitween.Api/Services/RetryUsageReport.cs diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs b/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs index aa41a66c..501547da 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Usage.cs @@ -32,14 +32,13 @@ public class Usage : ICommandHandler { private readonly BitweenDbContext _dbContext; private readonly RequestContext _requestContext; - private readonly AdapterSecretProperties _secrets; + private readonly RetryUsageReport _report; - public Usage(BitweenDbContext dbContext, RequestContext requestContext, - AdapterSecretProperties secrets) + public Usage(BitweenDbContext dbContext, RequestContext requestContext, RetryUsageReport report) { _dbContext = dbContext; _requestContext = requestContext; - _secrets = secrets; + _report = report; } public async Task Handle(int key, RetryPolicyUsageRequest request) @@ -55,78 +54,7 @@ public async Task Handle(int key, RetryPolicyUsageRequest request) .Select(s => new { s.Id, s.Name }) .ToListAsync(); - var subscriptionIds = subscriptions.Select(s => s.Id).ToList(); - - var usages = await _dbContext.Set().AsNoTracking() - .Where(u => subscriptionIds.Contains(u.SubscriptionId)) - .ToListAsync(); - - 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. 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) - { - 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); - - // 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 = 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 = await _secrets.Mask( - subscriptionOverride?.AlertHandlerId, subscriptionOverride?.AlertHandlerProperties), - ResolvedHandlerId = target?.HandlerId, - ResolvedHandlerProperties = await _secrets.Mask( - target?.HandlerId, target?.HandlerProperties), - 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 await _report.Build( + subscriptions.Select(s => (s.Id, s.Name)).ToList(), policy.Groups, policy); } } diff --git a/SW.Bitween.Api/Resources/Subscriptions/ResetRetryUsage.cs b/SW.Bitween.Api/Resources/Subscriptions/ResetRetryUsage.cs new file mode 100644 index 00000000..8853347a --- /dev/null +++ b/SW.Bitween.Api/Resources/Subscriptions/ResetRetryUsage.cs @@ -0,0 +1,49 @@ +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.Subscriptions; + +/// +/// Clears one subscription's spent retry budget so its groups start retrying again. +/// +/// +/// The policy-scoped reset finds subscriptions by policy id, which leaves an inline +/// CustomRetryPolicy unreachable: its counters are written like any other and then nothing can +/// clear them, so once exhausted that subscription would never retry again. This resets by +/// subscription instead, which also picks up counters left behind by groups that no longer exist. +/// +[HandlerName("resetretryusage")] +public class ResetRetryUsage : ICommandHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public ResetRetryUsage(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(int key, SubscriptionRetryResetUsage request) + { + _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + + if (!await _dbContext.Set().AnyAsync(s => s.Id == key)) + throw new SWNotFoundException(key.ToString()); + + // Scoped by subscription rather than by policy, so it cannot reach anyone else's counters no + // matter which kind of policy this subscription uses. + var query = _dbContext.Set().Where(u => u.SubscriptionId == key); + + if (request.GroupId.HasValue) + query = query.Where(u => u.GroupId == request.GroupId.Value); + + await query.ExecuteDeleteAsync(); + return null; + } +} diff --git a/SW.Bitween.Api/Resources/Subscriptions/RetryUsage.cs b/SW.Bitween.Api/Resources/Subscriptions/RetryUsage.cs new file mode 100644 index 00000000..e5ba7847 --- /dev/null +++ b/SW.Bitween.Api/Resources/Subscriptions/RetryUsage.cs @@ -0,0 +1,50 @@ +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.Subscriptions; + +/// +/// Reports one subscription's spent retry budget and where each group's exhaustion alert would go. +/// +/// +/// The policy-scoped report answers the same question for every subscription sharing a policy, but it +/// can only find subscriptions by policy id — so a subscription carrying an inline +/// CustomRetryPolicy is invisible to it while still spending and recording budget. Asking from +/// the subscription's side reaches those too. +/// +[HandlerName("retryusage")] +public class RetryUsage : ICommandHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + private readonly RetryUsageReport _report; + + public RetryUsage(BitweenDbContext dbContext, RequestContext requestContext, RetryUsageReport report) + { + _dbContext = dbContext; + _requestContext = requestContext; + _report = report; + } + + public async Task Handle(int key, RetryPolicyUsageRequest request) + { + _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); + + var subscription = await _dbContext.Set().AsNoTracking() + .Include(s => s.RetryPolicy) + .FirstOrDefaultAsync(s => s.Id == key); + if (subscription == null) throw new SWNotFoundException(key.ToString()); + + // Whichever policy actually applies. An inline one has no row, so the policy level of the + // alert hierarchy simply is not there for it — passed as null, which the resolver expects. + var groups = subscription.CustomRetryPolicy?.Groups ?? subscription.RetryPolicy?.Groups ?? []; + + return await _report.Build( + [(subscription.Id, subscription.Name)], groups, subscription.RetryPolicy); + } +} diff --git a/SW.Bitween.Api/Services/RetryUsageReport.cs b/SW.Bitween.Api/Services/RetryUsageReport.cs new file mode 100644 index 00000000..72c7a6ce --- /dev/null +++ b/SW.Bitween.Api/Services/RetryUsageReport.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Bitween.Model; + +namespace SW.Bitween; + +/// +/// Builds the state of subscription-and-group pairs — spent budget, and where the pair's +/// budget-exhausted alert would go. +/// +/// +/// Shared because the same pairs are asked about from two directions: a policy wants every +/// subscription using it, and a subscription wants its own, whether its policy is a shared one or an +/// inline CustomRetryPolicy that no policy id can reach. Two copies of alert resolution and +/// secret masking would drift, and the half that drifted would be the half that leaks. +/// +public class RetryUsageReport(BitweenDbContext dbContext, AdapterSecretProperties secrets) +{ + /// + /// One row per subscription and per group that could actually exhaust. + /// + /// The pairs' subscriptions, with the names to report. + /// Every group of the applicable policy; the unusable ones are dropped here. + /// + /// The shared policy, or null for an inline one — which has no row to carry a + /// policy-level alert, so those pairs can only resolve to a group or an override. + /// + public async Task> Build( + IReadOnlyList<(int Id, string Name)> subscriptions, + IReadOnlyList allGroups, + RetryPolicy policy) + { + // 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. A ceiling of zero counts as "never": TryConsume + // denies it outright rather than claiming and exhausting it. + var groups = allGroups.Where(g => g.Budget is { MaxAttemptsTotal: > 0 }).ToList(); + if (groups.Count == 0 || subscriptions.Count == 0) return []; + + var subscriptionIds = subscriptions.Select(s => s.Id).ToList(); + + var usages = await dbContext.Set().AsNoTracking() + .Where(u => subscriptionIds.Contains(u.SubscriptionId)) + .ToListAsync(); + + var overrides = await dbContext.Set().AsNoTracking() + .Where(o => subscriptionIds.Contains(o.SubscriptionId)) + .ToListAsync(); + + // 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)); + + var rows = new List(); + + foreach (var subscription in subscriptions) + foreach (var group in groups) + { + 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); + + // 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 = 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 = await secrets.Mask( + subscriptionOverride?.AlertHandlerId, subscriptionOverride?.AlertHandlerProperties), + ResolvedHandlerId = target?.HandlerId, + ResolvedHandlerProperties = await secrets.Mask( + target?.HandlerId, target?.HandlerProperties), + 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(); + } +} diff --git a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs index 398507d4..a01fb659 100644 --- a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs +++ b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs @@ -18,6 +18,8 @@ using SW.CloudFiles.LocalTests; using SW.PrimitiveTypes; using SW.Serverless; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; using Testcontainers.PostgreSql; using Testcontainers.RabbitMq; using Xunit; @@ -25,15 +27,35 @@ namespace SW.Bitween.IntegrationTests.Fixtures; /// -/// Collection-scoped fixture that starts a PostgreSQL container and a RabbitMQ container, -/// applies EF migrations, installs serverless adapters to local cloud storage, and builds -/// a fully wired service provider. +/// Collection-scoped fixture that starts a PostgreSQL container, a RabbitMQ container and a +/// MailHog container, applies EF migrations, installs serverless adapters to local cloud storage, +/// and builds a fully wired service provider. /// public sealed class BitweenFixture : IAsyncLifetime { private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder().Build(); private readonly RabbitMqContainer _rabbitMq = new RabbitMqBuilder().Build(); + // A real SMTP server, because the one thing no unit test can prove about the alert feature is + // that an actual handshake succeeds. Started here rather than expected on the developer's + // machine: a test that quietly does nothing when a local service is missing reports a green run + // while the whole delivery path goes unexercised. + private readonly IContainer _mailHog = new ContainerBuilder() + .WithImage("mailhog/mailhog:v1.0.1") + .WithPortBinding(SmtpContainerPort, true) + .WithPortBinding(ApiContainerPort, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(ApiContainerPort)) + .Build(); + + private const int SmtpContainerPort = 1025; + private const int ApiContainerPort = 8025; + + /// Host port the MailHog SMTP listener is mapped to, for a handler's Port setting. + public int MailHogSmtpPort => _mailHog.GetMappedPublicPort(SmtpContainerPort); + + /// Base address of MailHog's own API, for reading back what was delivered. + public string MailHogApi => $"http://{_mailHog.Hostname}:{_mailHog.GetMappedPublicPort(ApiContainerPort)}"; + public IHost App { get; private set; } = null!; private ExceptionDispatchInfo? _initError; @@ -42,7 +64,7 @@ public async Task InitializeAsync() { try { - await Task.WhenAll(_postgres.StartAsync(), _rabbitMq.StartAsync()); + await Task.WhenAll(_postgres.StartAsync(), _rabbitMq.StartAsync(), _mailHog.StartAsync()); var dataSourceBuilder = new NpgsqlDataSourceBuilder(_postgres.GetConnectionString()); dataSourceBuilder.EnableDynamicJson(); @@ -99,6 +121,7 @@ public async Task InitializeAsync() services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -148,6 +171,7 @@ public async Task DisposeAsync() } await _postgres.DisposeAsync(); await _rabbitMq.DisposeAsync(); + await _mailHog.DisposeAsync(); } } diff --git a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs index 184a81c1..44259b7b 100644 --- a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs +++ b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs @@ -162,11 +162,21 @@ public class RetryAlertOverrideSave public Dictionary? AlertHandlerProperties { get; set; } } -/// Empty request body — the policy is identified by the route key. +/// Empty request body — the subject is identified by the route key. public class RetryPolicyUsageRequest { } +/// +/// Clears one subscription's spent budget, for one group or for all of them. Reaches a subscription +/// whose policy is an inline CustomRetryPolicy, which has no id for the policy-scoped reset to +/// address. +/// +public class SubscriptionRetryResetUsage +{ + public Guid? GroupId { get; set; } +} + /// /// Clears spent budget so a group starts retrying again. Omit both fields to reset every /// subscription and group of the policy. diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index da9e61ab..78c7f98c 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -67,6 +67,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddHttpContextAccessor(); From d9f7c7c610fa9d13033ac8c4ba4f6efd2ce9a802 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Wed, 19 Aug 2026 12:56:29 +0300 Subject: [PATCH 16/54] Stop a failed scheduled retry from stalling the queue Drops the retry and records why on the exchange instead of throwing, drains in batches, survives a missing subscription, and refuses a group that allows retries with no budget. --- .../Resources/DelayedRetries/RunNow.cs | 8 +- .../RetryPolicies/RetryGroupValidation.cs | 8 ++ .../Resources/Xchanges/BulkRetry.cs | 6 +- SW.Bitween.Api/Services/RetryJob.cs | 54 ++++++-- SW.Bitween.Api/Services/XchangeService.cs | 32 ++++- .../Tests/RetryAlertServiceTests.cs | 70 +++++------ .../Tests/RetryJobTests.cs | 118 +++++++++++++++++- .../Tests/RetryPolicyTests.cs | 102 ++++++++++++++- .../Model/AutoRetry/RetryPolicyEvaluator.cs | 10 +- .../RetryPolicyEvaluatorTests.cs | 25 ++++ 10 files changed, 374 insertions(+), 59 deletions(-) diff --git a/SW.Bitween.Api/Resources/DelayedRetries/RunNow.cs b/SW.Bitween.Api/Resources/DelayedRetries/RunNow.cs index 9f287393..1fac9fd4 100644 --- a/SW.Bitween.Api/Resources/DelayedRetries/RunNow.cs +++ b/SW.Bitween.Api/Resources/DelayedRetries/RunNow.cs @@ -29,8 +29,14 @@ public async Task Handle(string key, DelayedRetryRunNow request) if (delayedRetry == null) throw new SWValidationException("NOT_FOUND", "No auto-retry is currently scheduled for this exchange."); + // Also covers an input file that can no longer be read, which the exchange itself now + // records — so the message points there rather than naming only one of the reasons. if (!await _xchangeService.ExecuteDelayedRetry(delayedRetry)) - throw new SWValidationException("NOT_FOUND", "The original exchange or its subscription no longer exists."); + { + await _dbContext.SaveChangesAsync(); + throw new SWValidationException("CANNOT_RETRY", + "This retry could not be carried out. The exchange it belongs to says why."); + } await _dbContext.SaveChangesAsync(); return null; diff --git a/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs b/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs index fdf72639..2586b33b 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/RetryGroupValidation.cs @@ -35,6 +35,14 @@ public static void EnsureCanFire(IEnumerable groups) $"Group '{group.Name}' applies to {resultType} but none of its matchers can be " + $"evaluated against {resultType} content. {SupportedMatchersFor(resultType)}"); + // Allow with no budget has nothing to work from — no per-message cap, no total, no delay — + // so the evaluator can only refuse it. Caught here because a group saved that way silently + // stops retrying, which reads as the whole feature being broken. + if (group.Action == RetryAction.Allow && group.Budget == null) + throw new SWValidationException("RETRY_GROUP_ALLOW_WITHOUT_BUDGET", + $"Group '{group.Name}' allows retries but has no budget. Set the attempt caps and " + + "delay, or change the action to block."); + // 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)) diff --git a/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs b/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs index 282549e5..88fe4685 100644 --- a/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs +++ b/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs @@ -49,7 +49,11 @@ public async Task Handle(XchangeBulkRetry request) else { - await _xchangeService.CreateXchange(xchange, xchangeFile, subscription.WorkGroup); + // Null when the subscription has since been deleted, which a document-only + // exchange also has from the start. The single-exchange retry has always allowed + // for it; without the same here, one such id in a selection threw and took the + // whole bulk retry down with it. + await _xchangeService.CreateXchange(xchange, xchangeFile, subscription?.WorkGroup); } } diff --git a/SW.Bitween.Api/Services/RetryJob.cs b/SW.Bitween.Api/Services/RetryJob.cs index f371b4cd..d463594b 100644 --- a/SW.Bitween.Api/Services/RetryJob.cs +++ b/SW.Bitween.Api/Services/RetryJob.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using SW.Bitween.Domain; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -13,23 +14,58 @@ namespace SW.Bitween; /// Polls for due records and re-submits the failed Xchanges. /// Scheduled via (registered by SchedulerSeedService). /// +/// +/// Works through every retry that is already due rather than a hundred a minute, and commits one row +/// at a time. Committing the batch in one go meant a single row that could not be carried out +/// discarded the work of all the others and left their schedules in place, so the same batch came +/// back a minute later and failed the same way — no retry would ever have run again. +/// [ScheduleConfig(AllowConcurrentExecution = false, MisfireInstructions = MisfireInstructions.Skip)] -public class RetryJob(BitweenDbContext dbContext, XchangeService xchangeService) : IScheduledJob +public class RetryJob(BitweenDbContext dbContext, XchangeService xchangeService, ILogger logger) + : IScheduledJob { private const int BatchSize = 100; public async Task Execute() { - var ready = await dbContext.Set() - .Where(r => r.On <= DateTime.UtcNow) - .Take(BatchSize) - .ToListAsync(); + // Fixed before the first batch: a retry scheduled while this run is working belongs to the next + // tick, otherwise a fast-failing subscription could keep this run going indefinitely. + var due = DateTime.UtcNow; - foreach (var delayedRetry in ready) + while (true) { - await xchangeService.ExecuteDelayedRetry(delayedRetry); - } + var ready = await dbContext.Set() + .Where(r => r.On <= due) + .OrderBy(r => r.On) + .Take(BatchSize) + .ToListAsync(); + + if (ready.Count == 0) return; + + foreach (var delayedRetry in ready) + { + try + { + await xchangeService.ExecuteDelayedRetry(delayedRetry); + await dbContext.SaveChangesAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, + "Dropping the scheduled retry of xchange {XchangeId}: it could not be carried out.", + delayedRetry.Id); - await dbContext.SaveChangesAsync(); + // Whatever the failed run left staged goes first — saving it would commit the very + // changes that failing was meant to prevent. + dbContext.ChangeTracker.Clear(); + + // Every row leaves the queue one way or another, which is what stops the loop above + // from meeting the same row again and turning the drain into a spin. + await dbContext.Set() + .Where(r => r.Id == delayedRetry.Id) + .ExecuteDeleteAsync(); + } + } + } } } diff --git a/SW.Bitween.Api/Services/XchangeService.cs b/SW.Bitween.Api/Services/XchangeService.cs index b859e2f4..0c195f04 100644 --- a/SW.Bitween.Api/Services/XchangeService.cs +++ b/SW.Bitween.Api/Services/XchangeService.cs @@ -145,13 +145,41 @@ public async Task ExecuteDelayedRetry(DelayedRetry delayedRetry) return false; } - var inputFileData = await GetFile(xchange.Id, XchangeFileType.Input); - var inputFile = new XchangeFile(inputFileData, xchange.InputName); + var inputFile = await ReadInputFile(xchange); + if (inputFile == null) + { + // The input is what a retry re-sends, so without it there is nothing to retry with. Handled + // like a missing subscription — drop the schedule and move on — but recorded on the result + // as well, because unlike a deleted subscription this needs someone to look into it. + _dbContext.Remove(delayedRetry); + + var result = await _dbContext.FindAsync(xchange.Id); + result?.SetRetryBlocked("The scheduled retry was dropped: the input file could not be read."); + return false; + } + await CreateXchange(subscription, xchange, inputFile); _dbContext.Remove(delayedRetry); return true; } + /// + /// The original input, or null when it cannot be read — deleted from storage, expired by a + /// lifecycle rule, or storage itself unavailable. + /// + private async Task ReadInputFile(Xchange xchange) + { + try + { + return new XchangeFile(await GetFile(xchange.Id, XchangeFileType.Input), xchange.InputName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The input file of xchange {XchangeId} could not be read.", xchange.Id); + return null; + } + } + private Task CreateOnHoldXchange(Subscription subscription, XchangeFile file, string[] references = null) { var xchange = new OnHoldXchange(subscription, file.Data, file.Filename, file.BadData, references); diff --git a/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs index 35b803e0..5e445634 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryAlertServiceTests.cs @@ -20,60 +20,49 @@ namespace SW.Bitween.IntegrationTests.Tests; /// 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. +/// MailHog is started by alongside PostgreSQL and RabbitMQ, so these +/// tests run everywhere the rest of the suite does. They used to return early when a local MailHog +/// was missing, which xunit reports as a pass — a green run then said nothing about whether an alert +/// can actually be delivered. /// [Collection("Bitween")] 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". + // MailHog answers instantly or not at all, so the default 100 seconds only ever means "the run + // hangs instead of failing". private static readonly TimeSpan MailHogTimeout = TimeSpan.FromSeconds(5); + private readonly BitweenFixture _fixture; + private string MessagesApi => $"{_fixture.MailHogApi}/api/v2/messages"; + public RetryAlertServiceTests(BitweenFixture fixture) { _fixture = fixture; } - private static async Task MailHogIsReachable() - { - try - { - using var http = new HttpClient { Timeout = MailHogTimeout }; - 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() + private async Task ClearMailHog() { using var http = new HttpClient { Timeout = MailHogTimeout }; - var response = await http.DeleteAsync("http://localhost:8025/api/v1/messages"); + var response = await http.DeleteAsync($"{_fixture.MailHogApi}/api/v1/messages"); response.EnsureSuccessStatusCode(); } - private static async Task LatestMailHogMessage() + private async Task LatestMailHogMessage() { using var http = new HttpClient { Timeout = MailHogTimeout }; - var json = await http.GetStringAsync($"{MailHogApi}/messages"); + var json = await http.GetStringAsync(MessagesApi); 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() + private async Task MailHogTotal() { using var http = new HttpClient { Timeout = MailHogTimeout }; - var json = await http.GetStringAsync($"{MailHogApi}/messages"); + var json = await http.GetStringAsync(MessagesApi); using var doc = JsonDocument.Parse(json); return doc.RootElement.GetProperty("total").GetInt32(); } @@ -81,9 +70,6 @@ private static async Task MailHogTotal() [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(); @@ -120,7 +106,7 @@ public async Task Exhausted_budget_alert_arrives_in_MailHog_with_the_group_and_s AlertHandlerProperties = new Dictionary { ["Host"] = "localhost", - ["Port"] = "1025", + ["Port"] = _fixture.MailHogSmtpPort.ToString(), ["UseTls"] = "false", ["From"] = "bitween-alerts@example.com", ["To"] = "ops@example.com", @@ -203,9 +189,6 @@ public async Task Exhausted_budget_alert_arrives_in_MailHog_with_the_group_and_s [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(); @@ -240,7 +223,7 @@ public async Task A_failed_send_does_not_stop_a_later_delivery() AlertHandlerProperties = new Dictionary { ["Host"] = "localhost", - ["Port"] = "1025", + ["Port"] = _fixture.MailHogSmtpPort.ToString(), ["UseTls"] = "false", ["From"] = "bitween-alerts@example.com", ["To"] = "ops@example.com", @@ -285,16 +268,21 @@ public async Task A_failed_send_does_not_stop_a_later_delivery() && n.Success)); // And now that one did get through, the guard has to hold: no third row, no second email. + // Both are asserted — a redelivery that wrongly logged another success while sending nothing + // would otherwise pass here. + var rowsAfterDelivery = await db.Set() + .CountAsync(n => n.XchangeId == xchange.Id); + await alertService.Process(raisedEvent); + Assert.Equal(1, await MailHogTotal()); + Assert.Equal(rowsAfterDelivery, + await db.Set().CountAsync(n => n.XchangeId == xchange.Id)); } [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(); @@ -305,7 +293,7 @@ public async Task The_handler_refuses_to_send_a_password_over_an_unencrypted_con var handler = discovery.GetNativeHandler("NativeSmtpHandler", new Dictionary { ["Host"] = "localhost", - ["Port"] = "1025", + ["Port"] = _fixture.MailHogSmtpPort.ToString(), ["UseTls"] = "false", ["Password"] = "hunter2", ["From"] = "bitween-alerts@example.com", @@ -314,8 +302,12 @@ public async Task The_handler_refuses_to_send_a_password_over_an_unencrypted_con ["Body"] = "Should never be sent" }); - await Assert.ThrowsAsync( + // Matched on the message, not just the type: the handler also throws + // InvalidOperationException for a missing recipient, so dropping the To above would otherwise + // leave this passing without ever reaching the credential guard. + var refusal = await Assert.ThrowsAsync( () => handler.Handle(new XchangeFile("{}"))); + Assert.Contains("will not send a password over an unencrypted connection", refusal.Message); // Refusing has to mean refusing: no message, and therefore no password, left the process. Assert.Equal(0, await MailHogTotal()); diff --git a/SW.Bitween.IntegrationTests/Tests/RetryJobTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryJobTests.cs index 9cdf71e2..e150a9b4 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryJobTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryJobTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; using SW.Bitween.Domain; using SW.Bitween.IntegrationTests.Fixtures; using SW.Bitween.Model; @@ -24,7 +25,7 @@ public RetryJobTests(BitweenFixture fixture) // ─── Helpers ────────────────────────────────────────────────────────────── private RetryJob BuildJob(BitweenDbContext db, XchangeService xchangeService) => - new(db, xchangeService); + new(db, xchangeService, NullLogger.Instance); // ─── Batch query ────────────────────────────────────────────────────────── @@ -101,6 +102,121 @@ public async Task RetryJob_removes_delayed_retry_when_subscription_is_missing() "A DelayedRetry whose Subscription no longer exists must be removed without creating a retry Xchange."); } + // ─── One bad row must not stop the rest ─────────────────────────────────── + + /// + /// An Xchange whose input file was never uploaded: reading it fails, which is what a retry whose + /// file has since been deleted from storage looks like. + /// + private static async Task AddUnreadableXchange(BitweenDbContext db, Subscription sub) + { + var xchange = new Xchange(sub, new XchangeFile("{}")); + db.Set().Add(xchange); + db.Set().Add(new XchangeResult(xchange.Id, null, null, exception: "boom")); + await db.SaveChangesAsync(); + return xchange; + } + + [Fact] + public async Task RetryJob_drops_a_retry_whose_input_is_gone_and_still_runs_the_others() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(8010, "RetryJob Missing Input Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + var sub = new Subscription("RetryJob Missing Input Sub", doc.Id) { Inactive = false }; + db.Set().Add(sub); + await db.SaveChangesAsync(); + + var unreadable = await AddUnreadableXchange(db, sub); + var healthy = await xs.CreateXchange(sub, new XchangeFile("{}")); + + db.Set().AddRange( + new DelayedRetry { Id = unreadable.Id, On = DateTime.UtcNow.AddMinutes(-2) }, + new DelayedRetry { Id = healthy.Id, On = DateTime.UtcNow.AddMinutes(-1) }); + await db.SaveChangesAsync(); + + await BuildJob(db, xs).Execute(); + + // With one commit per row, the retry that could not be made does not undo the one that could. + // Committing the batch in one go would have lost both and left both schedules behind. + Assert.False(await db.Set().AnyAsync(r => r.Id == healthy.Id)); + Assert.True(await db.Set().AnyAsync(x => x.RetryFor == healthy.Id)); + + // The unusable one leaves the queue too, rather than being tried again every minute for good. + Assert.False(await db.Set().AnyAsync(r => r.Id == unreadable.Id)); + + // And it says so where a reader already looks for "why is this not being retried?". + var result = await db.Set().AsNoTracking().SingleAsync(r => r.Id == unreadable.Id); + Assert.Contains("input file could not be read", result.RetryBlockedReason); + } + + [Fact] + public async Task RetryJob_works_through_more_than_one_batch_in_a_single_run() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + + // Schedules pointing at exchanges that no longer exist: the cheapest row to process, and enough + // of them to need more than one batch of 100. + var ids = Enumerable.Range(0, 105) + .Select(i => $"rjt-batch-{Guid.NewGuid():N}-{i}") + .ToList(); + + db.Set().AddRange(ids.Select((id, i) => new DelayedRetry + { + Id = id, + On = DateTime.UtcNow.AddMinutes(-(i + 1)) + })); + await db.SaveChangesAsync(); + + await BuildJob(db, xs).Execute(); + + // All of them, not the first hundred: a backlog should not have to wait a minute per hundred. + var left = await db.Set().CountAsync(r => ids.Contains(r.Id)); + Assert.Equal(0, left); + } + + // ─── Bulk retry with no subscription ────────────────────────────────────── + + [Fact] + public async Task BulkRetry_handles_an_exchange_with_no_subscription() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(8012, "BulkRetry No Sub Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + // A document-only exchange: SubscriptionId is null from the start, which is also what an + // exchange whose subscription was later deleted looks like. Created through the service so its + // input file is really in storage, since bulk retry reads it before looking anything else up. + var orphan = await xs.CreateXchange(doc, WorkGroup.None, new XchangeFile("{}")); + await db.SaveChangesAsync(); + + var healthy = await xs.CreateXchange(doc, WorkGroup.None, new XchangeFile("{}")); + await db.SaveChangesAsync(); + + // One selection containing both. This threw before, so the whole bulk retry failed — including + // for the exchanges that were perfectly retryable. + await new Resources.Xchanges.BulkRetry(db, xs).Handle(new XchangeBulkRetry + { + Ids = [orphan.Id, healthy.Id], + Reset = false + }); + await db.SaveChangesAsync(); + + Assert.True(await db.Set().AnyAsync(x => x.RetryFor == orphan.Id)); + Assert.True(await db.Set().AnyAsync(x => x.RetryFor == healthy.Id)); + } + // ─── Full execution path ────────────────────────────────────────────────── [Fact] diff --git a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs index e90158be..8d9a87be 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs @@ -28,6 +28,9 @@ public RetryPolicyTests(BitweenFixture fixture) private static AdapterSecretProperties Secrets(AsyncServiceScope scope) => scope.ServiceProvider.GetRequiredService(); + private static RetryUsageReport Report(AsyncServiceScope scope) => + scope.ServiceProvider.GetRequiredService(); + private static (Create create, Get get, Update update, Delete delete) Handlers(BitweenDbContext db, RequestContext ctx, AdapterSecretProperties secrets) => ( new Create(db, ctx), @@ -495,7 +498,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, Secrets(scope)).Handle(policyId, new RetryPolicyUsageRequest()); + var rows = (List)await new Usage(db, ctx, Report(scope)).Handle(policyId, new RetryPolicyUsageRequest()); var row = Assert.Single(rows); Assert.Equal(sub.Id, row.SubscriptionId); Assert.Equal("Usage Sub", row.SubscriptionName); @@ -512,7 +515,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, Secrets(scope)).Handle(policyId, new RetryPolicyUsageRequest())); + (List)await new Usage(db, ctx, Report(scope)).Handle(policyId, new RetryPolicyUsageRequest())); Assert.Equal(0, afterReset.AttemptsUsed); Assert.False(afterReset.Exhausted); Assert.Null(afterReset.LastAttemptOn); @@ -555,7 +558,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, Secrets(scope)) + var rows = (List)await new Usage(db, ctx, Report(scope)) .Handle(policyId, new RetryPolicyUsageRequest()); // One row, not two: the pair is reported even though nothing has ever failed — otherwise its @@ -602,7 +605,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, Secrets(scope)).Handle(otherId, new RetryPolicyUsageRequest())); + (List)await new Usage(db, ctx, Report(scope)).Handle(otherId, new RetryPolicyUsageRequest())); Assert.Equal(1, otherRow.AttemptsUsed); } @@ -954,6 +957,95 @@ public async Task Cannot_save_a_group_that_sends_its_own_alert_without_a_handler await Assert.ThrowsAsync(() => new Create(db, ctx).Handle(model)); } + // ─── Reaching an inline policy's counters ──────────────────────────────────── + + [Fact] + public async Task An_inline_policy_budget_can_be_reported_and_reset_by_subscription() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7015, "Inline Policy Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + var sub = new Subscription("Inline Policy Sub", doc.Id); + db.Set().Add(sub); + await db.SaveChangesAsync(); + + // Carried on the subscription itself, so there is no policy id anywhere to ask about it. + var inline = new CustomRetryPolicy { Groups = SimplePolicy("unused").Groups }; + sub.SetRetryPolicy(null, inline); + await db.SaveChangesAsync(); + + var groupId = inline.Groups[0].Id; + + // Spends the whole shared budget, which is what stops this subscription retrying at all. + for (var i = 0; i < 10; i++) + await new RetryGroupBudget(db, scope.ServiceProvider, sub.Id).TryConsume(groupId, 10); + await db.SaveChangesAsync(); + + var row = Assert.Single((List)await new Resources.Subscriptions.RetryUsage( + db, ctx, Report(scope)).Handle(sub.Id, new RetryPolicyUsageRequest())); + + Assert.Equal(10, row.AttemptsUsed); + Assert.True(row.Exhausted); + + // An inline policy has no row to hold a policy-level alert, so nothing resolves from there — + // and that has to read as "nothing configured" rather than as a level being consulted. + Assert.Null(row.ResolvedHandlerId); + Assert.Null(row.ResolvedFrom); + + // The point of the whole endpoint: before this, no reset could reach these counters, so the + // subscription stayed stopped for good. + await new Resources.Subscriptions.ResetRetryUsage(db, ctx) + .Handle(sub.Id, new SubscriptionRetryResetUsage()); + + var afterReset = Assert.Single((List)await new Resources.Subscriptions.RetryUsage( + db, ctx, Report(scope)).Handle(sub.Id, new RetryPolicyUsageRequest())); + Assert.Equal(0, afterReset.AttemptsUsed); + Assert.False(afterReset.Exhausted); + + // And it stays scoped to this subscription: a policy-scoped report must still not see it, + // which is precisely why the subscription-scoped one had to exist. + Assert.False(await db.Set().AnyAsync(u => u.SubscriptionId == sub.Id)); + } + + // ─── Allow with no budget ─────────────────────────────────────────────────── + + [Fact] + public async Task Allow_without_a_budget_is_rejected_on_save() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + RetryPolicyCreate PolicyWithBudgetlessGroup(string name, RetryAction action) => new() + { + Name = name, + Groups = + [ + new RetryGroup + { + Name = "No budget", + Priority = 10, + Action = action, + AppliesTo = [XchangeResultType.Error], + Matchers = [new ContainsMatcher { Value = "timeout" }] + } + ] + }; + + // Nothing to work from — no caps, no delay — so the evaluator could only ever refuse it, and + // refusing quietly reads as retries being broken. Rejected where it is configured instead. + await Assert.ThrowsAsync( + () => new Create(db, ctx).Handle(PolicyWithBudgetlessGroup("Budgetless Allow", RetryAction.Allow))); + + // Block is the shape that legitimately has no budget, and it must still save. + await new Create(db, ctx).Handle(PolicyWithBudgetlessGroup("Budgetless Block", RetryAction.Block)); + } + // ─── Alert secrets ────────────────────────────────────────────────────────── // What the browser is shown in place of a secret. Spelled out rather than taken from the @@ -1034,7 +1126,7 @@ public async Task Overriding_an_inherited_alert_keeps_the_password_it_was_only_s sub.SetRetryPolicy(policyId, null); await db.SaveChangesAsync(); - var row = Assert.Single((List)await new Usage(db, ctx, Secrets(scope)) + var row = Assert.Single((List)await new Usage(db, ctx, Report(scope)) .Handle(policyId, new RetryPolicyUsageRequest())); Assert.Equal(Sentinel, row.ResolvedHandlerProperties["Password"]); diff --git a/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs b/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs index 29250b09..1706875f 100644 --- a/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs +++ b/SW.Bitween.Sdk/Model/AutoRetry/RetryPolicyEvaluator.cs @@ -56,7 +56,15 @@ public async Task Evaluate( if (group.Action == RetryAction.Block) return RetryDecision.Block($"Group '{group.Name}' explicitly blocks this error", group); - var budget = group.Budget!; + // A group that allows retries without saying how many is refused rather than trusted: the + // dereference used to throw here, and because the caller logs and swallows that, retries just + // stopped happening with no reason recorded anywhere. Validation keeps new policies out of this + // state; this is for the ones already saved in it. + if (group.Budget is null) + return RetryDecision.Block( + $"Group '{group.Name}' allows retries but has no budget, so none can be scheduled", group); + + var budget = group.Budget; if (attemptIndexForThisMessage >= budget.MaxAttemptsPerError) return RetryDecision.Block( diff --git a/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs b/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs index 568d0d27..1e2eb2e5 100644 --- a/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs +++ b/SW.Bitween.UnitTests/RetryPolicyEvaluatorTests.cs @@ -68,6 +68,31 @@ private sealed class TestPolicy(RetryGroup[] groups) : IRetryPolicy public List Groups { get; } = new List(groups); } + // ─── Allow with no budget ─────────────────────────────────────────────────── + + [TestMethod] + public async Task AllowWithoutBudget_IsRefusedWithAReason() + { + // Only reachable for a policy saved before validation rejected this shape. It used to throw, + // and the caller logs and swallows the throw, so retries stopped happening and nothing said why. + var group = new RetryGroup + { + Name = "No budget", + Priority = 10, + Enabled = true, + AppliesTo = [XchangeResultType.Error], + Action = RetryAction.Allow, + Matchers = [new ContainsMatcher { Value = "timeout" }], + Budget = null + }; + + var decision = await Evaluator(PolicyWith(group)).Evaluate(XchangeResultType.Error, "timeout", 0); + + Assert.IsFalse(decision.ShouldRetry); + StringAssert.Contains(decision.Reason, "no budget"); + Assert.AreEqual("No budget", decision.MatchedGroup?.Name); + } + // ─── ContainsMatcher ──────────────────────────────────────────────────────── [TestMethod] From 6b28edc1d614bfe200c7c223cd3aec72e73eda6a Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Wed, 19 Aug 2026 14:59:23 +0300 Subject: [PATCH 17/54] Keep the retry budget honest about manual retries and recoveries A retry someone starts by hand no longer spends the group's shared total, and a subscription's next success gives its spent budget back. Adapter dispatch now goes through one helper instead of three copies. --- SW.Bitween.Api/Domain/RetryGroupUsage.cs | 7 +- SW.Bitween.Api/Domain/Xchange/Xchange.cs | 14 +- .../Resources/RetryPolicies/ResetUsage.cs | 4 +- .../Resources/Xchanges/BulkRetry.cs | 6 +- SW.Bitween.Api/Resources/Xchanges/Retry.cs | 5 +- SW.Bitween.Api/Services/AdapterInvoker.cs | 39 + SW.Bitween.Api/Services/RetryAlertService.cs | 19 +- SW.Bitween.Api/Services/RetryGroupBudget.cs | 26 + SW.Bitween.Api/Services/XchangeService.cs | 69 +- .../Fixtures/BitweenFixture.cs | 1 + .../Tests/RetryPolicyTests.cs | 191 ++ ...20260819100503_ManualRetryFlag.Designer.cs | 1959 ++++++++++++++ .../20260819100503_ManualRetryFlag.cs | 29 + .../BitweenDbContextModelSnapshot.cs | 3 + ...20260819100452_ManualRetryFlag.Designer.cs | 1956 ++++++++++++++ .../20260819100452_ManualRetryFlag.cs | 29 + .../BitweenDbContextModelSnapshot.cs | 3 + ...20260819100439_ManualRetryFlag.Designer.cs | 2246 +++++++++++++++++ .../20260819100439_ManualRetryFlag.cs | 31 + .../BitweenDbContextModelSnapshot.cs | 4 + SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs | 7 +- SW.Bitween.Web/Startup.cs | 1 + 22 files changed, 6601 insertions(+), 48 deletions(-) create mode 100644 SW.Bitween.Api/Services/AdapterInvoker.cs create mode 100644 SW.Bitween.MsSql/Migrations/20260819100503_ManualRetryFlag.Designer.cs create mode 100644 SW.Bitween.MsSql/Migrations/20260819100503_ManualRetryFlag.cs create mode 100644 SW.Bitween.MySql/Migrations/20260819100452_ManualRetryFlag.Designer.cs create mode 100644 SW.Bitween.MySql/Migrations/20260819100452_ManualRetryFlag.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260819100439_ManualRetryFlag.Designer.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260819100439_ManualRetryFlag.cs diff --git a/SW.Bitween.Api/Domain/RetryGroupUsage.cs b/SW.Bitween.Api/Domain/RetryGroupUsage.cs index 850081cb..4f640ace 100644 --- a/SW.Bitween.Api/Domain/RetryGroupUsage.cs +++ b/SW.Bitween.Api/Domain/RetryGroupUsage.cs @@ -8,9 +8,10 @@ namespace SW.Bitween.Domain; /// group, so it cannot be tracked on an individual xchange. /// /// -/// The total never resets on its own: once reaches the group's -/// MaxAttemptsTotal the group stops retrying for that integration until this row is -/// cleared. +/// Once reaches the group's MaxAttemptsTotal the group stops +/// retrying for that integration until this row is cleared. It is cleared by the integration's next +/// success — the only signal that the downstream it was failing against has recovered — or by one of +/// the reset endpoints. /// public class RetryGroupUsage { diff --git a/SW.Bitween.Api/Domain/Xchange/Xchange.cs b/SW.Bitween.Api/Domain/Xchange/Xchange.cs index 2ac35a09..a7a081c4 100644 --- a/SW.Bitween.Api/Domain/Xchange/Xchange.cs +++ b/SW.Bitween.Api/Domain/Xchange/Xchange.cs @@ -60,9 +60,10 @@ public Xchange(Subscription subscription, XchangeFile file, string[] references } //retry xchange - public Xchange(Xchange xchange, XchangeFile file, IWorkGroup workGroup) : + public Xchange(Xchange xchange, XchangeFile file, IWorkGroup workGroup, bool manualRetry = false) : this(xchange.DocumentId, workGroup, file, xchange.References) { + ManualRetry = manualRetry; SubscriptionId = xchange.SubscriptionId; PartnerId = xchange.PartnerId; MapperId = xchange.MapperId; @@ -75,9 +76,10 @@ public Xchange(Xchange xchange, XchangeFile file, IWorkGroup workGroup) : } //retry with reset subscription properties - public Xchange(Subscription subscription, Xchange xchange, XchangeFile file) : + public Xchange(Subscription subscription, Xchange xchange, XchangeFile file, bool manualRetry = false) : this(xchange.DocumentId, subscription.WorkGroup, file, xchange.References) { + ManualRetry = manualRetry; SubscriptionId = xchange.SubscriptionId; PartnerId = xchange.PartnerId ?? subscription.PartnerId; MapperId = subscription.MapperId; @@ -106,6 +108,14 @@ public Xchange(Subscription subscription, Xchange xchange, XchangeFile file) : public string ResponseMessageTypeName { get; private set; } public string RetryFor { get; private set; } + + /// + /// true when a person asked for this retry, rather than the retry policy scheduling + /// it. The policy leaves these alone, so pressing Retry never spends the group's shared + /// budget and never quietly starts an automatic chain behind the person who pressed it. + /// + public bool ManualRetry { get; private set; } + public string CorrelationId { get; set; } } } \ No newline at end of file diff --git a/SW.Bitween.Api/Resources/RetryPolicies/ResetUsage.cs b/SW.Bitween.Api/Resources/RetryPolicies/ResetUsage.cs index 4b7545a4..757dfa7c 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/ResetUsage.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/ResetUsage.cs @@ -9,8 +9,8 @@ namespace SW.Bitween.Resources.RetryPolicies; /// -/// Clears spent group budget, letting an exhausted group retry again. The total never resets on -/// its own, so this is the only way back for an integration that has hit its ceiling. +/// Clears spent group budget, letting an exhausted group retry again. A budget also clears itself +/// when the integration next succeeds, so this is for putting one back before that happens. /// [HandlerName("resetusage")] public class ResetUsage : ICommandHandler diff --git a/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs b/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs index 88fe4685..49065a29 100644 --- a/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs +++ b/SW.Bitween.Api/Resources/Xchanges/BulkRetry.cs @@ -44,7 +44,8 @@ public async Task Handle(XchangeBulkRetry request) if (subscription == null) throw new SWValidationException("SUBSCRIPTION_NOT_FOUND", "Cant reset properties, subscription doesnt exist anymore"); - await _xchangeService.CreateXchange(subscription, xchange, xchangeFile); + await _xchangeService.CreateXchange(subscription, xchange, xchangeFile, + manualRetry: true); } else { @@ -53,7 +54,8 @@ public async Task Handle(XchangeBulkRetry request) // exchange also has from the start. The single-exchange retry has always allowed // for it; without the same here, one such id in a selection threw and took the // whole bulk retry down with it. - await _xchangeService.CreateXchange(xchange, xchangeFile, subscription?.WorkGroup); + await _xchangeService.CreateXchange(xchange, xchangeFile, subscription?.WorkGroup, + manualRetry: true); } } diff --git a/SW.Bitween.Api/Resources/Xchanges/Retry.cs b/SW.Bitween.Api/Resources/Xchanges/Retry.cs index 6b2c034b..3c76cd8f 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Retry.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Retry.cs @@ -34,11 +34,12 @@ public async Task Handle(string key, XchangeRetry xchangeRetry) if (subscription == null) throw new SWValidationException("SUBSCRIPTION_NOT_FOUND", "Cant reset properties, subscription doesnt exist anymore"); - await xchangeService.CreateXchange(subscription, xchange, xchangeFile); + await xchangeService.CreateXchange(subscription, xchange, xchangeFile, manualRetry: true); } else { - await xchangeService.CreateXchange(xchange,xchangeFile,subscription?.WorkGroup ); + await xchangeService.CreateXchange(xchange, xchangeFile, subscription?.WorkGroup, + manualRetry: true); } diff --git a/SW.Bitween.Api/Services/AdapterInvoker.cs b/SW.Bitween.Api/Services/AdapterInvoker.cs new file mode 100644 index 00000000..f310d484 --- /dev/null +++ b/SW.Bitween.Api/Services/AdapterInvoker.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using SW.PrimitiveTypes; + +namespace SW.Bitween; + +/// +/// Runs a handler adapter, whichever kind it is: in-process for a native handler, or through +/// serverless for an uploaded one. +/// +/// +/// The choice between the two is made from the id alone and is identical wherever a handler is +/// invoked, so it lives here once. Kept as the single place that knows the adapter contract — when +/// that contract changes, a copy of this block somewhere else is what gets left behind. +/// +public class AdapterInvoker( + NativeAdapterDiscoveryService nativeAdapterDiscovery, + IServiceProvider serviceProvider) +{ + /// + /// Hands to the handler and returns whatever it produced, which is + /// null for a handler that only consumes. + /// + public async Task Handle(string handlerId, Dictionary handlerProperties, + string correlationId, XchangeFile payload) + { + if (handlerId.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase)) + { + var handler = nativeAdapterDiscovery.GetNativeHandler(handlerId, handlerProperties); + return await handler.Handle(payload); + } + + var serverless = serviceProvider.GetRequiredService(); + await serverless.StartAsync(handlerId, correlationId, handlerProperties); + return await serverless.InvokeAsync(nameof(IInfolinkHandler.Handle), payload); + } +} diff --git a/SW.Bitween.Api/Services/RetryAlertService.cs b/SW.Bitween.Api/Services/RetryAlertService.cs index 3b59515b..414e3be1 100644 --- a/SW.Bitween.Api/Services/RetryAlertService.cs +++ b/SW.Bitween.Api/Services/RetryAlertService.cs @@ -3,7 +3,6 @@ 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; @@ -23,8 +22,7 @@ namespace SW.Bitween; /// public class RetryAlertService( BitweenDbContext dbContext, - NativeAdapterDiscoveryService nativeAdapterDiscovery, - IServiceProvider serviceProvider, + AdapterInvoker adapterInvoker, ILogger logger) : IConsume { public async Task Process(RetryBudgetExhaustedEvent message) @@ -115,19 +113,8 @@ private async Task Send(RetryAlertTarget target, RetryBudgetExhaustedNotificatio 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); - } + await adapterInvoker.Handle(target.HandlerId, handlerProperties, + notification.CorrelationId ?? xchangeId, payload); dbContext.Add(XchangeNotification.ForRetryBudgetAlert(xchangeId)); } diff --git a/SW.Bitween.Api/Services/RetryGroupBudget.cs b/SW.Bitween.Api/Services/RetryGroupBudget.cs index 049122e1..bd7f3f0c 100644 --- a/SW.Bitween.Api/Services/RetryGroupBudget.cs +++ b/SW.Bitween.Api/Services/RetryGroupBudget.cs @@ -77,6 +77,32 @@ public async Task TryConsume(Guid groupId, int maxAttemptsTota } } + /// + /// Hands back every group total this integration has spent, because it has just succeeded. + /// + /// + /// + /// A spent total is a statement about a downstream that was failing, and one success says that is + /// no longer true. Nothing else can say it: an exhausted group schedules no further retries, so + /// the only evidence left that the downstream recovered is an ordinary message getting through. + /// Without this, one bad afternoon stops retrying for good until somebody notices and resets it + /// by hand. + /// + /// + /// Every group is cleared rather than only the exhausted one. The caps are per group, but the + /// downstream they were all failing against is shared, and a success is evidence about that + /// downstream. + /// + /// + /// Deleting the rows re-arms the exhaustion alert along with the budget, so if the total runs out + /// again somebody is told again rather than the second outage passing in silence. + /// + /// + public Task ClearAfterSuccess() => + dbContext.Set() + .Where(u => u.SubscriptionId == subscriptionId) + .ExecuteDeleteAsync(); + /// /// Takes responsibility for alerting that this integration's budget for the group is spent. /// diff --git a/SW.Bitween.Api/Services/XchangeService.cs b/SW.Bitween.Api/Services/XchangeService.cs index 0c195f04..7ffa181d 100644 --- a/SW.Bitween.Api/Services/XchangeService.cs +++ b/SW.Bitween.Api/Services/XchangeService.cs @@ -35,13 +35,15 @@ public class XchangeService : private readonly ILogger _logger; private readonly IInfolinkCache _BitweenCache; private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; + private readonly AdapterInvoker _adapterInvoker; public XchangeService(BitweenOptions BitweenSettings, BitweenDbContext dbContext, FilterService filterService, ICloudFilesService cloudFiles, IServiceProvider serviceProvider, IPublish publish, ILogger logger, IInfolinkCache BitweenCache, - NativeAdapterDiscoveryService nativeAdapterDiscovery) + NativeAdapterDiscoveryService nativeAdapterDiscovery, AdapterInvoker adapterInvoker) { + _adapterInvoker = adapterInvoker; _BitweenSettings = BitweenSettings; _dbContext = dbContext; _filterService = filterService; @@ -85,17 +87,18 @@ public async Task SubmitFilterXchange(int documentId, XchangeFile file, string[] await _dbContext.SaveChangesAsync(); } - public async Task CreateXchange(Xchange xchange, XchangeFile file, WorkGroup workGroup) + public async Task CreateXchange(Xchange xchange, XchangeFile file, WorkGroup workGroup, + bool manualRetry = false) { - var newXchange = new Xchange(xchange, file, workGroup); + var newXchange = new Xchange(xchange, file, workGroup, manualRetry); await AddFile(newXchange.Id, XchangeFileType.Input, file); _dbContext.Add(newXchange); } public async Task CreateXchange(Subscription subscription, Xchange xchange, XchangeFile file, - string[] references = null) + string[] references = null, bool manualRetry = false) { - var newXchange = new Xchange(subscription, xchange, file); + var newXchange = new Xchange(subscription, xchange, file, manualRetry); await AddFile(newXchange.Id, XchangeFileType.Input, file); _dbContext.Add(newXchange); } @@ -452,6 +455,8 @@ private async Task Process(XchangeMessage message) if (responseFile?.BadData == true) await TrySchedulingWithoutLosingTheResult(xchange, XchangeResultType.BadResult, responseFile.Data, xchangeResult); + else + await TryClearingRetryBudgetAfterSuccess(xchange); await _dbContext.SaveChangesAsync(); } catch (Exception ex) @@ -487,11 +492,49 @@ private async Task TrySchedulingWithoutLosingTheResult(Xchange xchange, XchangeR } } + /// + /// Gives the subscription its retry budget back after a success, without ever costing the caller + /// its successful result. + /// + /// + /// Guarded for the same reason scheduling is, and with more at stake: this runs after the handler + /// has already delivered, so letting it throw would abort the save of a result whose side effects + /// have happened, and the redelivery would repeat them. A budget left spent is a nuisance somebody + /// can undo by hand; a duplicated delivery cannot be undone at all. + /// + private async Task TryClearingRetryBudgetAfterSuccess(Xchange xchange) + { + if (xchange.SubscriptionId == null) return; + + try + { + await new RetryGroupBudget(_dbContext, _serviceProvider, xchange.SubscriptionId.Value) + .ClearAfterSuccess(); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Retry budget of subscription {SubscriptionId} could not be cleared after a success; " + + "it may still refuse retries until it is reset.", xchange.SubscriptionId.Value); + } + } + private async Task TryScheduleAutoRetry(Xchange xchange, XchangeResultType resultType, string content, XchangeResult xchangeResult) { if (xchange.SubscriptionId == null) return; + // A person asked for this attempt, so the policy stays out of it. Otherwise pressing Retry + // spends a slot of the group's shared total — the budget meant for unattended retries — and + // can be what finally exhausts it and raises the alert. Recorded rather than skipped + // silently, so the absence of a follow-up attempt has a visible reason. + if (xchange.ManualRetry) + { + xchangeResult.SetRetryBlocked( + "This attempt was started by hand, so the retry policy left it alone and its budget is untouched."); + return; + } + // DelayedRetry.Id is xchange.Id, so an existing row means this failure was already // evaluated and already spent a slot of the group's total budget. Re-evaluating it // (e.g. on an at-least-once redelivery) would both violate the PK on Add and spend a @@ -671,20 +714,8 @@ private async Task NotifyResult(Notifier notifier, XchangeResult xchangeResult, try { - // Check if it's a native adapter - if (notifier.HandlerId.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase)) - { - var handler = _nativeAdapterDiscovery.GetNativeHandler(notifier.HandlerId, handlerProperties); - await handler.Handle(new XchangeFile(JsonConvert.SerializeObject(notificationData), xchangeResult.Id)); - } - else - { - // Use serverless for external adapters - var serverless = _serviceProvider.GetRequiredService(); - await serverless.StartAsync(notifier.HandlerId, correlationId, handlerProperties); - await serverless.InvokeAsync(nameof(IInfolinkHandler.Handle), - new XchangeFile(JsonConvert.SerializeObject(notificationData), xchangeResult.Id)); - } + await _adapterInvoker.Handle(notifier.HandlerId, handlerProperties, correlationId, + new XchangeFile(JsonConvert.SerializeObject(notificationData), xchangeResult.Id)); _dbContext.Add(new XchangeNotification(xchangeResult.Id, notifier.Id, notifier.Name)); } diff --git a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs index a01fb659..657f893a 100644 --- a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs +++ b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs @@ -122,6 +122,7 @@ public async Task InitializeAsync() services.AddScoped(); services.AddScoped(); 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 8d9a87be..f8f5de07 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; using Microsoft.Extensions.DependencyInjection; using SW.Bitween.Domain; using SW.Bitween.IntegrationTests.Fixtures; @@ -1186,4 +1187,194 @@ public async Task Policy_alert_handler_round_trips() Assert.Equal("ops@example.com", loaded.AlertHandlerProperties["to"]); } + // ─── Manual retries and the shared budget ───────────────────────────────── + + /// + /// A person pressing Retry must not spend the budget set aside for unattended retries. + /// + /// + /// Both attempts in this test are children of the same failed exchange, fail the same way against + /// the same group, and differ only in who asked for them. Without that pairing the test could pass + /// simply because nothing was ever evaluated. + /// + [Fact] + public async Task A_retry_started_by_hand_is_left_alone_by_the_policy() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + + const string failure = "manual retry budget probe failed"; + + var doc = new Document(7031, "Manual Retry Doc"); + db.Set().Add(doc); + await db.SaveChangesAsync(); + + var groupId = Guid.NewGuid(); + var policy = new RetryPolicy + { + Name = "Manual Retry Policy " + Guid.NewGuid().ToString("N")[..6], + Groups = + [ + new RetryGroup + { + Id = groupId, + Name = "Probe", + Priority = 10, + AppliesTo = [XchangeResultType.Error], + Matchers = [new ContainsMatcher { Value = failure }], + Budget = new RetryBudget + { + MaxAttemptsPerError = 3, + MaxAttemptsTotal = 5, + DelayStrategy = new FixedDelayStrategy { DelayMs = 60_000 } + } + } + ] + }; + db.Set().Add(policy); + await db.SaveChangesAsync(); + + // A handler that fails on demand, so the failure text is chosen here rather than inherited + // from whatever the environment happens to throw, and the matcher above can be exact. + var sub = new Subscription("Manual Retry Sub", doc.Id); + sub.HandlerId = "sw.bitween.sampleconfigurableadapter"; + sub.SetDictionaries( + new Dictionary { ["SimulateError"] = "true", ["ErrorMessage"] = failure }, + null, null, null, null); + db.Set().Add(sub); + await db.SaveChangesAsync(); + sub.SetRetryPolicy(policy.Id, null); + await db.SaveChangesAsync(); + + + // The document cache is a warm singleton shared by the whole collection, and production + // clears it over the bus whenever a document changes. Cleared here for the same reason: a + // document created after the cache warmed is invisible to the filter step, which then fails + // on its own before any handler runs. + scope.ServiceProvider.GetRequiredService().Revoke(); + + var original = await xs.CreateXchange(sub, new XchangeFile("{}")); + await db.SaveChangesAsync(); + + // One of each, exactly as their callers build them: the endpoint behind the Retry button, and + // RetryJob working through a due DelayedRetry. + await xs.CreateXchange(sub, original, new XchangeFile("{}"), manualRetry: true); + await xs.CreateXchange(sub, original, new XchangeFile("{}")); + await db.SaveChangesAsync(); + + var children = await db.Set().AsNoTracking() + .Where(x => x.RetryFor == original.Id).ToListAsync(); + var byHand = Assert.Single(children, x => x.ManualRetry); + var byPolicy = Assert.Single(children, x => !x.ManualRetry); + + await Run(byHand.Id); + await Run(byPolicy.Id); + + var handResult = await db.Set().AsNoTracking().SingleAsync(r => r.Id == byHand.Id); + var policyResult = await db.Set().AsNoTracking().SingleAsync(r => r.Id == byPolicy.Id); + + // Both genuinely failed, and failed the way the group is written for, so the policy had + // something to match in either case. + Assert.False(handResult.Success); + Assert.False(policyResult.Success); + Assert.Contains(failure, handResult.Exception); + Assert.Contains(failure, policyResult.Exception); + + Assert.Null(handResult.RetryGroupId); + Assert.Contains("by hand", handResult.RetryBlockedReason); + Assert.False(await db.Set().AsNoTracking().AnyAsync(r => r.Id == byHand.Id)); + + // The control: the same failure, evaluated, charged for and scheduled. + Assert.Equal(groupId, policyResult.RetryGroupId); + Assert.True(await db.Set().AsNoTracking().AnyAsync(r => r.Id == byPolicy.Id)); + + var usage = await db.Set().AsNoTracking() + .SingleAsync(u => u.SubscriptionId == sub.Id && u.GroupId == groupId); + Assert.Equal(1, usage.AttemptsUsed); + + // Runs the exchange through the same entry point the bus calls, so the guard is exercised + // where it actually sits rather than through a seam opened up for the test. + async Task Run(string xchangeId) + { + await using var runScope = _fixture.CreateScope(); + await runScope.ServiceProvider.GetRequiredService() + .Process("XchangeCreated", JsonConvert.SerializeObject(new { Id = xchangeId })); + } + } + + // ─── Recovery ───────────────────────────────────────────────────────────── + + /// + /// A success is what tells Bitween the downstream is back, so it is what gives the budget back. + /// + /// + /// Nothing else can: an exhausted group schedules no more retries, so no retry will ever succeed + /// to report the recovery. Only ordinary traffic getting through can, which is what this drives. + /// + [Fact] + public async Task A_success_gives_the_group_its_spent_budget_back() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7032, "Recovery Doc"); + db.Set().Add(doc); + var sub = new Subscription("Recovery Sub", doc.Id); + db.Set().Add(sub); + await db.SaveChangesAsync(); + + var group = new RetryGroup + { + Name = "Timeout", + Priority = 10, + AppliesTo = [XchangeResultType.Error], + Matchers = [new ContainsMatcher { Value = "timeout" }], + Budget = new RetryBudget + { + MaxAttemptsPerError = 5, + MaxAttemptsTotal = 2, + DelayStrategy = new FixedDelayStrategy { DelayMs = 60_000 } + } + }; + var policy = new CustomRetryPolicy { Groups = [group] }; + + async Task Fail() => + await new RetryPolicyEvaluator(policy, new RetryGroupBudget(db, scope.ServiceProvider, sub.Id)) + .Evaluate(XchangeResultType.Error, "System.TimeoutException: timeout", 0); + + Assert.True((await Fail()).ShouldRetry); + Assert.True((await Fail()).ShouldRetry); + + var exhausted = await Fail(); + Assert.False(exhausted.ShouldRetry); + Assert.True(exhausted.BudgetJustExhausted); + + + // The document cache is a warm singleton shared by the whole collection, and production + // clears it over the bus whenever a document changes. Cleared here for the same reason: a + // document created after the cache warmed is invisible to the filter step, which then fails + // on its own before any handler runs. + scope.ServiceProvider.GetRequiredService().Revoke(); + + // The subscription has no handler, so this exchange simply succeeds — an ordinary message + // getting through after the outage, which is the only evidence of recovery there is. + var recovered = await xs.CreateXchange(sub, new XchangeFile("{}")); + await db.SaveChangesAsync(); + + await using (var runScope = _fixture.CreateScope()) + await runScope.ServiceProvider.GetRequiredService() + .Process("XchangeCreated", JsonConvert.SerializeObject(new { Id = recovered.Id })); + + var result = await db.Set().AsNoTracking().SingleAsync(r => r.Id == recovered.Id); + Assert.True(result.Success); + + Assert.Empty(await db.Set().AsNoTracking() + .Where(u => u.SubscriptionId == sub.Id).ToListAsync()); + + // Retrying works again, and because the row is gone the next exhaustion alerts afresh. + var afterRecovery = await Fail(); + Assert.True(afterRecovery.ShouldRetry); + } } diff --git a/SW.Bitween.MsSql/Migrations/20260819100503_ManualRetryFlag.Designer.cs b/SW.Bitween.MsSql/Migrations/20260819100503_ManualRetryFlag.Designer.cs new file mode 100644 index 00000000..0db64634 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260819100503_ManualRetryFlag.Designer.cs @@ -0,0 +1,1959 @@ +// +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("20260819100503_ManualRetryFlag")] + partial class ManualRetryFlag + { + /// + 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("ManualRetry") + .HasColumnType("bit"); + + 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/20260819100503_ManualRetryFlag.cs b/SW.Bitween.MsSql/Migrations/20260819100503_ManualRetryFlag.cs new file mode 100644 index 00000000..73d83836 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260819100503_ManualRetryFlag.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class ManualRetryFlag : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ManualRetry", + table: "Xchanges", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ManualRetry", + table: "Xchanges"); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs index 84d75fcb..ebe5dc75 100644 --- a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -850,6 +850,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("InputSize") .HasColumnType("int"); + b.Property("ManualRetry") + .HasColumnType("bit"); + b.Property("MapperId") .HasMaxLength(200) .IsUnicode(false) diff --git a/SW.Bitween.MySql/Migrations/20260819100452_ManualRetryFlag.Designer.cs b/SW.Bitween.MySql/Migrations/20260819100452_ManualRetryFlag.Designer.cs new file mode 100644 index 00000000..61ddbe0f --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260819100452_ManualRetryFlag.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.MySql; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + [DbContext(typeof(BitweenDbContext))] + [Migration("20260819100452_ManualRetryFlag")] + partial class ManualRetryFlag + { + /// + 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("ManualRetry") + .HasColumnType("tinyint(1)"); + + 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/20260819100452_ManualRetryFlag.cs b/SW.Bitween.MySql/Migrations/20260819100452_ManualRetryFlag.cs new file mode 100644 index 00000000..2be75b27 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260819100452_ManualRetryFlag.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class ManualRetryFlag : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ManualRetry", + table: "Xchanges", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ManualRetry", + table: "Xchanges"); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs index 7f7faa45..718f31e0 100644 --- a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs @@ -847,6 +847,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("InputSize") .HasColumnType("int"); + b.Property("ManualRetry") + .HasColumnType("tinyint(1)"); + b.Property("MapperId") .HasMaxLength(200) .IsUnicode(false) diff --git a/SW.Bitween.PgSql/Migrations/20260819100439_ManualRetryFlag.Designer.cs b/SW.Bitween.PgSql/Migrations/20260819100439_ManualRetryFlag.Designer.cs new file mode 100644 index 00000000..12ed1d41 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260819100439_ManualRetryFlag.Designer.cs @@ -0,0 +1,2246 @@ +// +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("20260819100439_ManualRetryFlag")] + partial class ManualRetryFlag + { + /// + 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("ManualRetry") + .HasColumnType("boolean") + .HasColumnName("manual_retry"); + + 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/20260819100439_ManualRetryFlag.cs b/SW.Bitween.PgSql/Migrations/20260819100439_ManualRetryFlag.cs new file mode 100644 index 00000000..31efce51 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260819100439_ManualRetryFlag.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + public partial class ManualRetryFlag : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "manual_retry", + schema: "infolink", + table: "xchange", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "manual_retry", + schema: "infolink", + table: "xchange"); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs index 9ef35b2f..95a4a390 100644 --- a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -1041,6 +1041,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("input_size"); + b.Property("ManualRetry") + .HasColumnType("boolean") + .HasColumnName("manual_retry"); + b.Property("MapperId") .HasMaxLength(200) .HasColumnType("character varying(200)") diff --git a/SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs b/SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs index 787e0b4d..132089be 100644 --- a/SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs +++ b/SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs @@ -89,8 +89,11 @@ public class RetryBudget public int MaxAttemptsPerError { get; init; } /// - /// Hard ceiling on the total number of group-level retries across all messages in the - /// current processing window. Prevents a burst of failures from hammering the downstream. + /// Hard ceiling on the total number of group-level retries across all messages, counted per + /// subscription so one shared policy does not let a single noisy subscription spend everyone's + /// allowance. Prevents a burst of failures from hammering the downstream. It is not a rate over + /// a rolling window: the count only falls when the subscription succeeds again, or when somebody + /// resets it. /// public int MaxAttemptsTotal { get; init; } diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index 78c7f98c..a7540d2b 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -68,6 +68,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddHttpContextAccessor(); From a173e19741841707ce2928d7d45ae985207ebac8 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Wed, 19 Aug 2026 14:59:32 +0300 Subject: [PATCH 18/54] Send alert email without a certificate revocation check MailKit hard-fails when the CA's OCSP or CRL server is unreachable, which stopped a valid Gmail certificate from ever delivering an alert. --- .../SmtpHandler/NativeSmtpHandler.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs b/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs index 4b5baaf9..86fb3c19 100644 --- a/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs +++ b/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs @@ -48,6 +48,14 @@ public async Task Handle(XchangeFile xchangeFile) using var client = new SmtpClient(); + // A revocation lookup needs the issuing CA's OCSP or CRL server to be reachable, which the + // corporate networks Bitween runs inside routinely block. MailKit checks by default and treats + // "could not determine" as a rejection, so a perfectly valid certificate stops the alert — + // observed against Gmail, whose certificate OpenSSL accepts on the same machine. The chain, + // the hostname and the expiry are all still verified; only the revocation lookup is skipped, + // which is the same trade-off ordinary mail clients make. + client.CheckCertificateRevocation = false; + // 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 From 0c4ab3d60bc5ffd03ec902519cb3a7ec4db78876 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Wed, 19 Aug 2026 15:29:37 +0300 Subject: [PATCH 19/54] Release only budgets that have actually run out A partly-spent total is left alone, so a downstream that fails some messages and succeeds others still reaches its cap, and a slot charged after the run began is no longer handed back. A dropped retry now records why in every case that has an exchange to record it on. --- SW.Bitween.Api/Domain/RetryGroupUsage.cs | 8 +- .../Resources/DelayedRetries/RunNow.cs | 13 +- .../Resources/RetryPolicies/ResetUsage.cs | 5 +- SW.Bitween.Api/Services/RetryGroupBudget.cs | 62 +++++++-- SW.Bitween.Api/Services/XchangeService.cs | 11 +- .../Tests/RetryPolicyTests.cs | 127 +++++++++++++++++- SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs | 6 +- 7 files changed, 205 insertions(+), 27 deletions(-) diff --git a/SW.Bitween.Api/Domain/RetryGroupUsage.cs b/SW.Bitween.Api/Domain/RetryGroupUsage.cs index 4f640ace..b19f7a9b 100644 --- a/SW.Bitween.Api/Domain/RetryGroupUsage.cs +++ b/SW.Bitween.Api/Domain/RetryGroupUsage.cs @@ -9,9 +9,11 @@ namespace SW.Bitween.Domain; /// /// /// Once reaches the group's MaxAttemptsTotal the group stops -/// retrying for that integration until this row is cleared. It is cleared by the integration's next -/// success — the only signal that the downstream it was failing against has recovered — or by one of -/// the reset endpoints. +/// retrying for that integration until this row is cleared. A row that has reached the cap is cleared +/// by the integration's next success — the only signal that the downstream it was failing against has +/// recovered — or by one of the reset endpoints. A row still below the cap is left alone by a success: +/// the cap is there for a downstream that fails some messages and succeeds others, which is exactly +/// when crediting it back would stop it ever being reached. /// public class RetryGroupUsage { diff --git a/SW.Bitween.Api/Resources/DelayedRetries/RunNow.cs b/SW.Bitween.Api/Resources/DelayedRetries/RunNow.cs index 1fac9fd4..bf24845f 100644 --- a/SW.Bitween.Api/Resources/DelayedRetries/RunNow.cs +++ b/SW.Bitween.Api/Resources/DelayedRetries/RunNow.cs @@ -29,13 +29,18 @@ public async Task Handle(string key, DelayedRetryRunNow request) if (delayedRetry == null) throw new SWValidationException("NOT_FOUND", "No auto-retry is currently scheduled for this exchange."); - // Also covers an input file that can no longer be read, which the exchange itself now - // records — so the message points there rather than naming only one of the reasons. if (!await _xchangeService.ExecuteDelayedRetry(delayedRetry)) { await _dbContext.SaveChangesAsync(); - throw new SWValidationException("CANNOT_RETRY", - "This retry could not be carried out. The exchange it belongs to says why."); + + // Every other refusal writes its reason onto the exchange, so the message sends the + // caller there instead of listing them. A missing exchange is the one case with + // nowhere to write it, and pointing at something that is gone explains nothing. + var exchangeExists = await _dbContext.Set().AnyAsync(x => x.Id == key); + + throw new SWValidationException("CANNOT_RETRY", exchangeExists + ? "This retry could not be carried out. The exchange it belongs to says why." + : "This retry could not be carried out: the exchange it belonged to no longer exists."); } await _dbContext.SaveChangesAsync(); diff --git a/SW.Bitween.Api/Resources/RetryPolicies/ResetUsage.cs b/SW.Bitween.Api/Resources/RetryPolicies/ResetUsage.cs index 757dfa7c..dc0de3db 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/ResetUsage.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/ResetUsage.cs @@ -9,8 +9,9 @@ namespace SW.Bitween.Resources.RetryPolicies; /// -/// Clears spent group budget, letting an exhausted group retry again. A budget also clears itself -/// when the integration next succeeds, so this is for putting one back before that happens. +/// Clears spent group budget, letting an exhausted group retry again. A budget that has run out also +/// clears itself when the integration next succeeds, so this is for putting one back before that +/// happens, or for handing back a total that is spent but not yet exhausted. /// [HandlerName("resetusage")] public class ResetUsage : ICommandHandler diff --git a/SW.Bitween.Api/Services/RetryGroupBudget.cs b/SW.Bitween.Api/Services/RetryGroupBudget.cs index bd7f3f0c..deb10836 100644 --- a/SW.Bitween.Api/Services/RetryGroupBudget.cs +++ b/SW.Bitween.Api/Services/RetryGroupBudget.cs @@ -78,30 +78,68 @@ public async Task TryConsume(Guid groupId, int maxAttemptsTota } /// - /// Hands back every group total this integration has spent, because it has just succeeded. + /// Lifts this integration's group budgets that have run out, because it has just succeeded. /// /// /// - /// A spent total is a statement about a downstream that was failing, and one success says that is - /// no longer true. Nothing else can say it: an exhausted group schedules no further retries, so - /// the only evidence left that the downstream recovered is an ordinary message getting through. - /// Without this, one bad afternoon stops retrying for good until somebody notices and resets it - /// by hand. + /// An exhausted total is a statement about a downstream that was failing, and one success says + /// that is no longer true. Nothing else can say it: an exhausted group schedules no further + /// retries, so no retry will ever succeed to report the recovery — only ordinary traffic getting + /// through can. Without this, one bad afternoon stops retrying for good until somebody notices + /// and resets it by hand. /// /// - /// Every group is cleared rather than only the exhausted one. The caps are per group, but the - /// downstream they were all failing against is shared, and a success is evidence about that - /// downstream. + /// Only budgets that are actually used up. A partly-spent total is left alone. + /// The cap exists to stop a flaky downstream being hammered, and that is precisely a downstream + /// where some messages succeed and others fail — crediting the total back on every ordinary + /// success would mean such a subscription never reaches its cap at all. /// /// - /// Deleting the rows re-arms the exhaustion alert along with the budget, so if the total runs out + /// keeps this from erasing a charge it never saw. Bitween runs + /// several instances, so a failure can claim a slot while this success is still being processed; + /// deleting that row would hand back a slot already spent and let the group exceed its total. + /// Only rows whose last attempt predates the success are released. + /// + /// + /// Deleting a row re-arms the exhaustion alert along with the budget, so if the total runs out /// again somebody is told again rather than the second outage passing in silence. /// /// - public Task ClearAfterSuccess() => - dbContext.Set() + /// How many group budgets were released. + public async Task ReleaseExhaustedBudgets(DateTime succeededFrom) + { + // Cheapest question first, and for almost every success the answer ends it here: a + // subscription that has never spent a retry has no row, and must not pay for a policy load + // or a write on the strength of having worked. + var spent = await dbContext.Set().AsNoTracking() .Where(u => u.SubscriptionId == subscriptionId) + .Select(u => new { u.GroupId, u.AttemptsUsed }) + .ToListAsync(); + if (spent.Count == 0) return 0; + + var subscription = await dbContext.Set().AsNoTracking() + .Include(s => s.RetryPolicy) + .FirstOrDefaultAsync(s => s.Id == subscriptionId); + + IRetryPolicy policy = subscription?.CustomRetryPolicy ?? (IRetryPolicy)subscription?.RetryPolicy; + if (policy?.Groups == null) return 0; + + // A group whose total is gone from the policy is left to Update and Delete to clean up, which + // they already do — releasing it here would be guessing at a cap that no longer exists. + var exhausted = spent + .Where(u => policy.Groups.Any(g => g.Id == u.GroupId + && g.Budget is { MaxAttemptsTotal: > 0 } + && u.AttemptsUsed >= g.Budget.MaxAttemptsTotal)) + .Select(u => u.GroupId) + .ToList(); + if (exhausted.Count == 0) return 0; + + return await dbContext.Set() + .Where(u => u.SubscriptionId == subscriptionId + && exhausted.Contains(u.GroupId) + && u.LastAttemptOn < succeededFrom) .ExecuteDeleteAsync(); + } /// /// Takes responsibility for alerting that this integration's budget for the group is spent. diff --git a/SW.Bitween.Api/Services/XchangeService.cs b/SW.Bitween.Api/Services/XchangeService.cs index 7ffa181d..902c5340 100644 --- a/SW.Bitween.Api/Services/XchangeService.cs +++ b/SW.Bitween.Api/Services/XchangeService.cs @@ -144,7 +144,14 @@ public async Task ExecuteDelayedRetry(DelayedRetry delayedRetry) .FirstOrDefaultAsync(s => s.Id == xchange.SubscriptionId); if (subscription == null) { + // Recorded on the result like the unreadable-input case below, rather than only dropping + // the schedule: the exchange is still there for someone to look at, so leaving it with no + // reason means the retry simply stopped happening with nothing to explain it. _dbContext.Remove(delayedRetry); + + var orphaned = await _dbContext.FindAsync(xchange.Id); + orphaned?.SetRetryBlocked( + "The scheduled retry was dropped: the subscription it belonged to no longer exists."); return false; } @@ -508,8 +515,10 @@ private async Task TryClearingRetryBudgetAfterSuccess(Xchange xchange) try { + // The exchange's own start time is the watermark: anything charged after this run began + // belongs to a failure this success knows nothing about, and is left where it is. await new RetryGroupBudget(_dbContext, _serviceProvider, xchange.SubscriptionId.Value) - .ClearAfterSuccess(); + .ReleaseExhaustedBudgets(xchange.StartedOn); } catch (Exception ex) { diff --git a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs index f8f5de07..b164666d 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs @@ -1338,10 +1338,15 @@ public async Task A_success_gives_the_group_its_spent_budget_back() DelayStrategy = new FixedDelayStrategy { DelayMs = 60_000 } } }; - var policy = new CustomRetryPolicy { Groups = [group] }; + // Attached to the subscription, not just handed to the evaluator: releasing a budget reads the + // group's cap back from the policy the subscription actually holds, because a total that is + // only partly spent must be left alone. + sub.SetRetryPolicy(null, new CustomRetryPolicy { Groups = [group] }); + await db.SaveChangesAsync(); async Task Fail() => - await new RetryPolicyEvaluator(policy, new RetryGroupBudget(db, scope.ServiceProvider, sub.Id)) + await new RetryPolicyEvaluator(sub.CustomRetryPolicy, + new RetryGroupBudget(db, scope.ServiceProvider, sub.Id)) .Evaluate(XchangeResultType.Error, "System.TimeoutException: timeout", 0); Assert.True((await Fail()).ShouldRetry); @@ -1377,4 +1382,122 @@ await runScope.ServiceProvider.GetRequiredService() var afterRecovery = await Fail(); Assert.True(afterRecovery.ShouldRetry); } + + /// + /// A total that is only partly spent is not credited back by an ordinary success. + /// + /// + /// The cap is there for a downstream that fails some messages and succeeds others. Handing the + /// total back on every success would mean exactly that downstream never reaches its cap, so the + /// release is deliberately limited to a budget that has actually run out. + /// + [Fact] + public async Task A_partly_spent_budget_is_left_alone_by_a_success() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var xs = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7033, "Partly Spent Doc"); + db.Set().Add(doc); + var sub = new Subscription("Partly Spent Sub", doc.Id); + db.Set().Add(sub); + await db.SaveChangesAsync(); + + var group = new RetryGroup + { + Name = "Timeout", + Priority = 10, + AppliesTo = [XchangeResultType.Error], + Matchers = [new ContainsMatcher { Value = "timeout" }], + Budget = new RetryBudget + { + MaxAttemptsPerError = 5, + MaxAttemptsTotal = 4, + DelayStrategy = new FixedDelayStrategy { DelayMs = 60_000 } + } + }; + sub.SetRetryPolicy(null, new CustomRetryPolicy { Groups = [group] }); + await db.SaveChangesAsync(); + + // One of four spent, so the group is still allowed to retry and has nothing to recover from. + var spend = await new RetryPolicyEvaluator(sub.CustomRetryPolicy, + new RetryGroupBudget(db, scope.ServiceProvider, sub.Id)) + .Evaluate(XchangeResultType.Error, "System.TimeoutException: timeout", 0); + Assert.True(spend.ShouldRetry); + + scope.ServiceProvider.GetRequiredService().Revoke(); + + var succeeded = await xs.CreateXchange(sub, new XchangeFile("{}")); + await db.SaveChangesAsync(); + + await using (var runScope = _fixture.CreateScope()) + await runScope.ServiceProvider.GetRequiredService() + .Process("XchangeCreated", JsonConvert.SerializeObject(new { Id = succeeded.Id })); + + Assert.True((await db.Set().AsNoTracking().SingleAsync(r => r.Id == succeeded.Id)).Success); + + var usage = await db.Set().AsNoTracking() + .SingleAsync(u => u.SubscriptionId == sub.Id && u.GroupId == group.Id); + Assert.Equal(1, usage.AttemptsUsed); + } + + /// + /// A slot charged after the success began is not handed back by it. + /// + /// + /// Bitween runs several instances, so a failure can claim a slot while a success is still being + /// processed. Releasing that row would give back a slot already spent and let the group retry past + /// its total. The row's last attempt is compared against the successful exchange's start, which is + /// what this drives directly — the timing is otherwise a race no test could pin down. + /// + [Fact] + public async Task A_slot_charged_after_the_success_began_is_not_handed_back() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(7034, "Watermark Doc"); + db.Set().Add(doc); + var sub = new Subscription("Watermark Sub", doc.Id); + db.Set().Add(sub); + await db.SaveChangesAsync(); + + var group = new RetryGroup + { + Name = "Timeout", + Priority = 10, + AppliesTo = [XchangeResultType.Error], + Matchers = [new ContainsMatcher { Value = "timeout" }], + Budget = new RetryBudget + { + MaxAttemptsPerError = 5, + MaxAttemptsTotal = 1, + DelayStrategy = new FixedDelayStrategy { DelayMs = 60_000 } + } + }; + sub.SetRetryPolicy(null, new CustomRetryPolicy { Groups = [group] }); + await db.SaveChangesAsync(); + + // Exhausted, and charged after the moment the success below claims to have started. + db.Set().Add(new RetryGroupUsage + { + SubscriptionId = sub.Id, + GroupId = group.Id, + AttemptsUsed = 1, + LastAttemptOn = DateTime.UtcNow.AddMinutes(5) + }); + await db.SaveChangesAsync(); + + var budget = new RetryGroupBudget(db, scope.ServiceProvider, sub.Id); + + Assert.Equal(0, await budget.ReleaseExhaustedBudgets(DateTime.UtcNow)); + Assert.Equal(1, (await db.Set().AsNoTracking() + .SingleAsync(u => u.SubscriptionId == sub.Id)).AttemptsUsed); + + // The same budget, released once the success is known to postdate the charge. + Assert.Equal(1, await budget.ReleaseExhaustedBudgets(DateTime.UtcNow.AddMinutes(10))); + Assert.Empty(await db.Set().AsNoTracking() + .Where(u => u.SubscriptionId == sub.Id).ToListAsync()); + } } diff --git a/SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs b/SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs index 132089be..6811e3c4 100644 --- a/SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs +++ b/SW.Bitween.Sdk/Model/AutoRetry/RetryGroup.cs @@ -91,9 +91,9 @@ public class RetryBudget /// /// Hard ceiling on the total number of group-level retries across all messages, counted per /// subscription so one shared policy does not let a single noisy subscription spend everyone's - /// allowance. Prevents a burst of failures from hammering the downstream. It is not a rate over - /// a rolling window: the count only falls when the subscription succeeds again, or when somebody - /// resets it. + /// allowance. Prevents a burst of failures from hammering the downstream. It is not a rate over a + /// rolling window: the count only falls once it has been reached — the subscription's next success + /// then lifts it — or when somebody resets it by hand. /// public int MaxAttemptsTotal { get; init; } From dd4423f23fa0f8e464b8e25e6238da0515a5108e Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Wed, 19 Aug 2026 15:29:47 +0300 Subject: [PATCH 20/54] Refuse a revoked certificate, tolerate one that cannot be checked Replaces the blanket opt-out: revocation checking stays on, and only an undeterminable status is soft-failed. A revoked certificate, an untrusted root, a wrong hostname and an expired certificate all still fail. --- .../SmtpHandler/NativeSmtpHandler.cs | 48 ++++++++++-- .../NativeSmtpHandlerTests.cs | 77 +++++++++++++++++++ 2 files changed, 118 insertions(+), 7 deletions(-) diff --git a/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs b/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs index 86fb3c19..6cc74e84 100644 --- a/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs +++ b/SW.Bitween.NativeAdapters/SmtpHandler/NativeSmtpHandler.cs @@ -1,3 +1,7 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; using MailKit.Net.Smtp; using MailKit.Security; using MimeKit; @@ -48,13 +52,17 @@ public async Task Handle(XchangeFile xchangeFile) using var client = new SmtpClient(); - // A revocation lookup needs the issuing CA's OCSP or CRL server to be reachable, which the - // corporate networks Bitween runs inside routinely block. MailKit checks by default and treats - // "could not determine" as a rejection, so a perfectly valid certificate stops the alert — - // observed against Gmail, whose certificate OpenSSL accepts on the same machine. The chain, - // the hostname and the expiry are all still verified; only the revocation lookup is skipped, - // which is the same trade-off ordinary mail clients make. - client.CheckCertificateRevocation = false; + // Revocation stays switched on, so a certificate the CA has actually revoked is still refused. + // What the callback softens is the other outcome: the lookup needs the CA's OCSP or CRL server + // to be reachable, which the corporate networks Bitween runs inside routinely block, and + // MailKit's default treats "could not find out" exactly like "revoked". That rejected a + // perfectly good Gmail certificate — one OpenSSL accepts on the same machine — and the alert + // never went out. Soft-failing an undeterminable status is what browsers and mail clients do; + // every other defect, revocation included, still fails. + client.CheckCertificateRevocation = true; + client.ServerCertificateValidationCallback = (_, _, chain, errors) => + IsCertificateAcceptable(errors, + chain?.ChainStatus.Select(s => s.Status) ?? Enumerable.Empty()); // 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 — @@ -111,6 +119,32 @@ internal static string Fill(string template, string payload) return ScribanJsonHelper.RenderText(template, payload); } + /// + /// Whether a server certificate should be accepted, given what validation found wrong with it. + /// + /// + /// Only one defect is tolerated: a revocation status that could not be established, because the + /// CA's OCSP or CRL server was unreachable. A certificate the CA has revoked, an untrusted root, + /// a wrong hostname and an expired certificate are all still refused — as is any chain flag not + /// named here, so a defect nobody thought about fails closed rather than slipping through. + /// + internal static bool IsCertificateAcceptable(SslPolicyErrors errors, + IEnumerable chainStatus) + { + if (errors == SslPolicyErrors.None) return true; + + // A missing certificate or the wrong name on one is not a revocation question at all, and the + // chain flags say nothing about either. + if (errors != SslPolicyErrors.RemoteCertificateChainErrors) return false; + + const X509ChainStatusFlags undeterminable = + X509ChainStatusFlags.RevocationStatusUnknown | X509ChainStatusFlags.OfflineRevocation; + + // Masked rather than compared: one entry can carry several flags at once, and "revoked" set + // alongside "could not check" has to fail. + return chainStatus.All(status => (status & ~undeterminable) == X509ChainStatusFlags.NoError); + } + private static bool LooksLikeJson(string payload) { if (string.IsNullOrWhiteSpace(payload)) return false; diff --git a/SW.Bitween.UnitTests/NativeSmtpHandlerTests.cs b/SW.Bitween.UnitTests/NativeSmtpHandlerTests.cs index a747fc75..ec0528b9 100644 --- a/SW.Bitween.UnitTests/NativeSmtpHandlerTests.cs +++ b/SW.Bitween.UnitTests/NativeSmtpHandlerTests.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; using SW.Bitween.Model; @@ -98,4 +100,79 @@ public void StartupValues_KeepDefaultsWhenOmitted() Assert.IsTrue(input.IsHtml); Assert.IsNull(input.Password); } + + // ─── Server certificate acceptance ────────────────────────────────────────── + + [TestMethod] + public void Certificate_WithNothingWrong_IsAccepted() + { + Assert.IsTrue(NativeSmtpHandler.IsCertificateAcceptable( + SslPolicyErrors.None, new[] { X509ChainStatusFlags.NoError })); + } + + [TestMethod] + public void Certificate_WhoseRevocationCouldNotBeChecked_IsAccepted() + { + // The whole point of the soft-fail: the CA's OCSP or CRL server was unreachable, which says + // nothing bad about the certificate itself. + Assert.IsTrue(NativeSmtpHandler.IsCertificateAcceptable( + SslPolicyErrors.RemoteCertificateChainErrors, + new[] { X509ChainStatusFlags.RevocationStatusUnknown })); + + Assert.IsTrue(NativeSmtpHandler.IsCertificateAcceptable( + SslPolicyErrors.RemoteCertificateChainErrors, + new[] { X509ChainStatusFlags.OfflineRevocation })); + + Assert.IsTrue(NativeSmtpHandler.IsCertificateAcceptable( + SslPolicyErrors.RemoteCertificateChainErrors, + new[] { X509ChainStatusFlags.RevocationStatusUnknown | X509ChainStatusFlags.OfflineRevocation })); + } + + [TestMethod] + public void Certificate_ThatWasRevoked_IsRefused() + { + Assert.IsFalse(NativeSmtpHandler.IsCertificateAcceptable( + SslPolicyErrors.RemoteCertificateChainErrors, + new[] { X509ChainStatusFlags.Revoked })); + } + + [TestMethod] + public void Certificate_RevokedAlongsideAnUncheckableStatus_IsRefused() + { + // One chain entry can carry several flags at once, so the tolerated ones have to be masked + // out rather than compared — otherwise a revoked certificate rides in on the same entry. + Assert.IsFalse(NativeSmtpHandler.IsCertificateAcceptable( + SslPolicyErrors.RemoteCertificateChainErrors, + new[] { X509ChainStatusFlags.Revoked | X509ChainStatusFlags.RevocationStatusUnknown })); + + Assert.IsFalse(NativeSmtpHandler.IsCertificateAcceptable( + SslPolicyErrors.RemoteCertificateChainErrors, + new[] { X509ChainStatusFlags.RevocationStatusUnknown, X509ChainStatusFlags.Revoked })); + } + + [TestMethod] + public void Certificate_WithAnyOtherDefect_IsRefused() + { + Assert.IsFalse(NativeSmtpHandler.IsCertificateAcceptable( + SslPolicyErrors.RemoteCertificateChainErrors, + new[] { X509ChainStatusFlags.UntrustedRoot })); + + Assert.IsFalse(NativeSmtpHandler.IsCertificateAcceptable( + SslPolicyErrors.RemoteCertificateChainErrors, + new[] { X509ChainStatusFlags.NotTimeValid })); + + // A wrong hostname or no certificate at all is not a chain question, so the chain flags must + // not be allowed to excuse it. + Assert.IsFalse(NativeSmtpHandler.IsCertificateAcceptable( + SslPolicyErrors.RemoteCertificateNameMismatch, + new[] { X509ChainStatusFlags.NoError })); + + Assert.IsFalse(NativeSmtpHandler.IsCertificateAcceptable( + SslPolicyErrors.RemoteCertificateNotAvailable, + new[] { X509ChainStatusFlags.NoError })); + + Assert.IsFalse(NativeSmtpHandler.IsCertificateAcceptable( + SslPolicyErrors.RemoteCertificateChainErrors | SslPolicyErrors.RemoteCertificateNameMismatch, + new[] { X509ChainStatusFlags.RevocationStatusUnknown })); + } } From 09aa3406f5a309051b91d3351dcb85dad068f34e Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Wed, 19 Aug 2026 15:30:53 +0300 Subject: [PATCH 21/54] Stop the retry job claiming it dropped a retry it may have created --- SW.Bitween.Api/Services/RetryJob.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/SW.Bitween.Api/Services/RetryJob.cs b/SW.Bitween.Api/Services/RetryJob.cs index d463594b..1b3400c9 100644 --- a/SW.Bitween.Api/Services/RetryJob.cs +++ b/SW.Bitween.Api/Services/RetryJob.cs @@ -51,9 +51,13 @@ public async Task Execute() } catch (Exception ex) { + // Not "dropped": SaveChangesAsync commits before it publishes, so a failure in the + // publish leaves the replacement exchange committed and only its announcement + // missing. Saying the retry was dropped would send whoever reads this looking for + // an exchange that does exist. logger.LogError(ex, - "Dropping the scheduled retry of xchange {XchangeId}: it could not be carried out.", - delayedRetry.Id); + "The scheduled retry of xchange {XchangeId} did not complete; clearing its " + + "schedule so the queue keeps draining.", delayedRetry.Id); // Whatever the failed run left staged goes first — saving it would commit the very // changes that failing was meant to prevent. From 2b0cde0d4b8908bc744fa18f90fb632e4b374383 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 20 Aug 2026 10:46:53 +0300 Subject: [PATCH 22/54] feat: carry the security audit into the redesigned UI --- SW.Bitween.Web/ClientApp/src/api/client.ts | 2 ++ .../ClientApp/src/api/http/session.ts | 2 ++ SW.Bitween.Web/ClientApp/src/api/http/team.ts | 10 ++++++ SW.Bitween.Web/ClientApp/src/api/types.ts | 6 ++++ .../ClientApp/src/pages/auth/Login.tsx | 4 +-- .../ClientApp/src/pages/team/MemberDrawer.tsx | 23 +++++++++++-- .../ClientApp/src/pages/team/MembersTab.tsx | 11 +++--- SW.Bitween.Web/Startup.cs | 34 +++++++++++++++++++ 8 files changed, 83 insertions(+), 9 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 113f0cd6..fd313b99 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -86,6 +86,8 @@ export interface ApiClient { /** Stands in for a self-service reset, which would need mail Bitween doesn't have. */ setUserPassword(id: string, password: string): Promise; updateUserRoles(id: string, roleIds: string[]): Promise; + /** Clears a failed-sign-in lockout early, rather than waiting it out. */ + unlockUser(id: string): Promise; setUserDisabled(id: string, disabled: boolean): Promise; deleteUser(id: string): Promise; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/session.ts b/SW.Bitween.Web/ClientApp/src/api/http/session.ts index 87e8df01..88076776 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/session.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/session.ts @@ -27,6 +27,8 @@ const buildSession = (profile: Profile): Session => { email: profile.email, roleIds: (profile.roles ?? []).map((r) => String(r.id)), status: profile.disabled ? "disabled" : "active", + // Always null here: you cannot be signed in and locked out at the same time. + lockedUntil: null, microsoftLinked: false, createdOn: profile.createdOn, }; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/team.ts b/SW.Bitween.Web/ClientApp/src/api/http/team.ts index 45b4b5e9..c00f919b 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/team.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/team.ts @@ -24,6 +24,8 @@ interface RawAccount { email: string; role: string; disabled: boolean; + /** Set by the backend's failed-sign-in lockout; null or in the past means not locked. */ + lockoutEnd: string | null; createdOn: string; roles: RawRoleSummary[] | null; } @@ -44,6 +46,8 @@ const toUser = (r: RawAccount): User => ({ email: r.email, roleIds: (r.roles ?? []).map((role) => String(role.id)), status: r.disabled ? "disabled" : "active", + // Past lockouts are not state anyone can act on, so they read as no lockout at all. + lockedUntil: r.lockoutEnd && new Date(r.lockoutEnd) > new Date() ? r.lockoutEnd : null, // Not tracked by the backend: no login-method projection, no last-seen column. microsoftLinked: false, createdOn: r.createdOn, @@ -113,6 +117,12 @@ export const teamMethods = { return fetchUser(id); }, + /** Clears a failed-sign-in lockout early, rather than waiting it out. */ + async unlockUser(id: string): Promise { + await post(`/accounts/${id}/unlock`, {}); + return fetchUser(id); + }, + async setUserDisabled(id: string, disabled: boolean): Promise { await post(`/accounts/${id}/setDisabled`, { disabled }); return fetchUser(id); diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index dbc2dee0..7727acf3 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -38,6 +38,12 @@ export interface User { email: string; roleIds: string[]; status: UserStatus; + /** + * Set while the account is locked out after repeated failed sign-ins. Orthogonal to + * `status`: a lockout is automatic and expires on its own, where disabling is an + * admin decision that does not. Null once it has passed. + */ + lockedUntil: string | null; /** Whether a Microsoft account is linked for SSO. */ microsoftLinked: boolean; createdOn: string; diff --git a/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx b/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx index 1ae6acee..d36258cb 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx @@ -73,7 +73,7 @@ export function LoginPage() { setEmail(e.target.value)} @@ -83,7 +83,7 @@ export function LoginPage() { setPassword(e.target.value)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx index 47805ea8..697140ba 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, KeyRound, Trash2, UserRoundCheck, UserRoundX, X } from "lucide-react"; +import { Check, KeyRound, LockOpen, Trash2, UserRoundCheck, UserRoundX, X } from "lucide-react"; import { api } from "../../api"; import { Can } from "../../auth/guards"; import { useSession } from "../../auth/SessionContext"; @@ -9,7 +9,7 @@ import { CopyField } from "../../components/ui/CopyField"; import { Badge, Button, FormError, LoadingBlock } from "../../components/ui/basics"; import { Checkbox, PasswordInput } from "../../components/ui/forms"; import { ConfirmDialog } from "../../components/ui/overlays"; -import { formatDate, timeAgo } from "../../lib/dates"; +import { formatDate, timeAgo, timeUntil } from "../../lib/dates"; import { statusBadge } from "./MembersTab"; function Section({ title, children }: { title: string; children: ReactNode }) { @@ -58,6 +58,11 @@ export function MemberDrawer({ userId, onClose }: { userId: string; onClose: () mutationFn: (disabled: boolean) => api.setUserDisabled(userId, disabled), onSuccess: invalidate, }); + const unlock = useMutation({ + mutationFn: () => api.unlockUser(userId), + onSuccess: invalidate, + }); + const setPassword = useMutation({ mutationFn: (password: string) => api.setUserPassword(userId, password), onSuccess: (_result, password) => { @@ -91,7 +96,7 @@ export function MemberDrawer({ userId, onClose }: { userId: string; onClose: () {isSelf && You}

{u.email}

-
{statusBadge(u.status)}
+
{statusBadge(u.status, u.lockedUntil)}
@@ -195,6 +200,18 @@ export function MemberDrawer({ userId, onClose }: { userId: string; onClose: () {setPassword.error?.message} )} + {editable && u.lockedUntil && ( +
+ +

+ Locked after repeated failed sign-ins, for another {timeUntil(u.lockedUntil)}. + Unlocking clears it now. +

+ {unlock.error?.message} +
+ )} {editable && (
- {statusBadge(user.status)} + {statusBadge(user.status, user.lockedUntil)} {user.lastActiveOn ? timeAgo(user.lastActiveOn) : "—"} diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index e3d16fb2..09f6918b 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -364,6 +364,27 @@ public void ConfigureServices(IServiceCollection services) } + /// + /// Enforced. Proven Report-Only first across every page in the admin UI — a wrong + /// enforced policy blanks the app, so the order matters. Switch back to + /// "Content-Security-Policy-Report-Only" before widening the policy again. + /// + private const string ContentSecurityPolicyHeader = "Content-Security-Policy"; + + /// Mirrors the policy the legacy UI enforces at nginx, minus its nginx-only bits. + private const string ContentSecurityPolicy = + "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; " + + "connect-src 'self' https://login.microsoftonline.com; " + + "frame-src https://login.microsoftonline.com; " + + "font-src 'self' data:; " + + "form-action 'self' https://login.microsoftonline.com; " + + "frame-ancestors 'none'; " + + "base-uri 'self'; " + + "object-src 'none'"; + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseForwardedHeaders(); @@ -377,6 +398,19 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) headers["X-Permitted-Cross-Domain-Policies"] = "none"; headers["Cross-Origin-Opener-Policy"] = "same-origin-allow-popups"; + // Content-Security-Policy for the admin UI. + // + // The legacy deployment set this in nginx, which only ever served the SPA. + // Here the same host also serves Swagger UI, which needs inline scripts and + // styles of its own, so the policy is scoped to everything else — applied + // globally it would simply take Swagger down. + // + // 'unsafe-inline' is present for styles only: the brand colour is applied at + // runtime as custom properties. Scripts need no such exemption — the built + // index.html carries no inline script, only the module bundle. + if (!context.Request.Path.StartsWithSegments("/swagger")) + headers[ContentSecurityPolicyHeader] = ContentSecurityPolicy; + // Sensitive API responses (JSON) must not be cached by the browser or // intermediaries. Scoped by content type so static assets stay cacheable. context.Response.OnStarting(() => From f5582ad0dddde97a4f076fa3bb8f3812df071ca1 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 20 Aug 2026 14:09:12 +0300 Subject: [PATCH 23/54] test: give the retry tests the permissions the merge started requiring --- .../Tests/RetryPolicyTests.cs | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs index 3cbfc083..1c81406b 100644 --- a/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/RetryPolicyTests.cs @@ -478,7 +478,7 @@ public async Task Usage_reports_spent_budget_and_reset_clears_it() { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var doc = new Document(7007, "Usage Doc"); db.Set().Add(doc); @@ -530,7 +530,7 @@ public async Task Usage_lists_never_failed_pairs_and_skips_groups_that_cannot_ex { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var doc = new Document(7011, "Never Failed Doc"); db.Set().Add(doc); @@ -580,7 +580,7 @@ public async Task Reset_does_not_touch_counters_of_another_policy() { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var doc = new Document(7008, "Reset Scope Doc"); db.Set().Add(doc); @@ -615,7 +615,7 @@ public async Task Removing_a_group_clears_its_spent_budget() { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var doc = new Document(7009, "Removed Group Doc"); db.Set().Add(doc); @@ -653,7 +653,7 @@ 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 ctx = scope.Superuser(); var doc = new Document(7012, "Attempts Doc"); db.Set().Add(doc); @@ -723,7 +723,7 @@ 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 ctx = scope.Superuser(); var doc = new Document(7013, "Attempts Scope Doc"); db.Set().Add(doc); @@ -904,7 +904,7 @@ 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 ctx = scope.Superuser(); var doc = new Document(7103, "Alert Rearm Doc"); db.Set().Add(doc); @@ -942,7 +942,7 @@ 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 ctx = scope.Superuser(); var model = SimplePolicy("Alert Validation Policy"); model.Groups = @@ -968,7 +968,7 @@ public async Task An_inline_policy_budget_can_be_reported_and_reset_by_subscript { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var doc = new Document(7015, "Inline Policy Doc"); db.Set().Add(doc); @@ -1023,7 +1023,7 @@ public async Task Allow_without_a_budget_is_rejected_on_save() { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); RetryPolicyCreate PolicyWithBudgetlessGroup(string name, RetryAction action) => new() { @@ -1073,7 +1073,7 @@ public async Task An_alert_password_is_masked_on_read_and_survives_being_saved_b { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var model = SimplePolicy("Masked Alert Policy"); model.AlertHandlerId = "NativeSmtpHandler"; @@ -1110,7 +1110,7 @@ public async Task Overriding_an_inherited_alert_keeps_the_password_it_was_only_s { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var doc = new Document(7014, "Copied Secret Doc"); db.Set().Add(doc); @@ -1157,7 +1157,7 @@ public async Task A_mail_alert_with_a_password_and_no_encryption_is_rejected_on_ { await using var scope = _fixture.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var ctx = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.Superuser(); var model = SimplePolicy("Cleartext Alert Policy"); model.AlertHandlerId = "NativeSmtpHandler"; @@ -1177,7 +1177,7 @@ 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 ctx = scope.Superuser(); var model = SimplePolicy("Alert Handler Policy"); model.AlertHandlerId = "NativeSmtpHandler"; From 372bd2c3ee4dde09926cd0f5bf372779b836b119 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 20 Aug 2026 14:09:32 +0300 Subject: [PATCH 24/54] feat: surface retry budgets, and whether the alert reached anyone --- SW.Bitween.Api/Services/RetryUsageReport.cs | 42 ++ SW.Bitween.Sdk/Model/RetryPolicyModel.cs | 19 + SW.Bitween.Web/ClientApp/src/api/client.ts | 33 +- .../ClientApp/src/api/http/retryPolicies.ts | 151 +++++- SW.Bitween.Web/ClientApp/src/api/types.ts | 95 ++++ .../pages/integrations/studio/Overview.tsx | 3 + .../pages/integrations/studio/RetryBudget.tsx | 102 ++++ .../src/pages/retry-policies/AlertRouting.tsx | 90 ++++ .../src/pages/retry-policies/GroupDialog.tsx | 59 ++- .../pages/retry-policies/RetryPolicyPage.tsx | 169 +++++- .../src/pages/retry-policies/UsagePanel.tsx | 492 ++++++++++++++++++ 11 files changed, 1236 insertions(+), 19 deletions(-) create mode 100644 SW.Bitween.Web/ClientApp/src/pages/integrations/studio/RetryBudget.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/retry-policies/AlertRouting.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx diff --git a/SW.Bitween.Api/Services/RetryUsageReport.cs b/SW.Bitween.Api/Services/RetryUsageReport.cs index 72c7a6ce..3fd4d6a3 100644 --- a/SW.Bitween.Api/Services/RetryUsageReport.cs +++ b/SW.Bitween.Api/Services/RetryUsageReport.cs @@ -50,6 +50,40 @@ public async Task> Build( .Where(o => subscriptionIds.Contains(o.SubscriptionId)) .ToListAsync(); + // What became of the alerts that were raised. The counter records only that one was + // claimed — claiming is what stops a redelivery sending it twice, and it happens before + // the send is tried — so whether anyone was actually told lives here instead, in the + // delivery log. The pair is reconstructed the same way the alert reached it: the exchange + // names the integration, its result names the group. + var alertLog = await ( + from notification in dbContext.Set().AsNoTracking() + where notification.NotifierName == XchangeNotification.RetryBudgetAlertName + join xchange in dbContext.Set().AsNoTracking() + on notification.XchangeId equals xchange.Id + join result in dbContext.Set().AsNoTracking() + on notification.XchangeId equals result.Id + where xchange.SubscriptionId != null + && subscriptionIds.Contains(xchange.SubscriptionId.Value) + && result.RetryGroupId != null + select new + { + SubscriptionId = xchange.SubscriptionId!.Value, + GroupId = result.RetryGroupId!.Value, + notification.Success, + notification.Exception, + notification.FinishedOn + }) + .ToListAsync(); + + // One success is delivery, however many failures preceded it — the same rule the sender + // itself applies when it decides an alert has already gone out. Failing that, the most + // recent failure is the outcome. + var alertByPair = alertLog + .GroupBy(a => (a.SubscriptionId, a.GroupId)) + .ToDictionary( + g => g.Key, + g => g.OrderByDescending(a => a.Success).ThenByDescending(a => a.FinishedOn).First()); + // 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. @@ -63,6 +97,7 @@ public async Task> Build( { usageByPair.TryGetValue((subscription.Id, group.Id), out var usage); overrideByPair.TryGetValue((subscription.Id, group.Id), out var subscriptionOverride); + alertByPair.TryGetValue((subscription.Id, group.Id), out var alert); var target = RetryAlertResolver.Resolve(subscriptionOverride, group, policy); @@ -85,6 +120,13 @@ public async Task> Build( Exhausted = usage != null && usage.AttemptsUsed >= group.Budget.MaxAttemptsTotal, LastAttemptOn = usage?.LastAttemptOn, ExhaustedNotifiedOn = usage?.ExhaustedNotifiedOn, + // Only meaningful once an alert has been claimed. A delivery row surviving from + // before a reset would otherwise report an outcome for an alert that has since + // been re-armed and not raised again. + AlertDelivered = usage?.ExhaustedNotifiedOn == null ? null : alert?.Success, + AlertError = usage?.ExhaustedNotifiedOn != null && alert is { Success: false } + ? alert.Exception + : null, AlertMode = subscriptionOverride?.AlertMode ?? RetryAlertMode.Inherit, OverrideHandlerId = subscriptionOverride?.AlertHandlerId, OverrideHandlerProperties = await secrets.Mask( diff --git a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs index 44259b7b..20983ecf 100644 --- a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs +++ b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs @@ -69,6 +69,25 @@ public class RetryGroupUsageRow ///
public DateTime? ExhaustedNotifiedOn { get; set; } + /// + /// Whether the raised alert actually reached its handler. + /// + /// + /// Separate from because they are different facts: + /// claiming the alert is what stops it firing twice, and it is claimed before the send is + /// attempted. A send through a customer-configured adapter can fail — a wrong password, a + /// refused TLS handshake — and the counter records none of that. Reporting only the claim + /// tells the reader someone was told when nobody was, which is the one thing this page + /// must never do. + /// + /// Null when no alert has been claimed, or when one was claimed with no delivery attempt + /// recorded against it. "Not known" and "did not arrive" are not the same answer. + /// + public bool? AlertDelivered { get; set; } + + /// Why delivery failed, when it did. + public string? AlertError { get; set; } + /// This pair's own override mode. Inherit when no override row exists. public RetryAlertMode AlertMode { get; set; } diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index fd313b99..f76ddeb5 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -34,9 +34,12 @@ import type { PermissionKey, QueueHealthSnapshot, RetryGroup, + RetryAlertConfig, + RetryAttempts, RetryPolicy, RetryPolicyDetail, RetryPolicyListRow, + RetryUsageRow, RetryResultType, RetryTestAttempt, Role, @@ -255,7 +258,15 @@ export interface ApiClient { listRetryPolicies(): Promise; getRetryPolicy(id: number): Promise; createRetryPolicy(input: { name: string }): Promise; - updateRetryPolicy(id: number, changes: { name: string; groups: RetryGroup[] }): Promise; + updateRetryPolicy( + id: number, + changes: { + name: string; + groups: RetryGroup[]; + alertHandlerId: string | null; + alertHandlerProperties: Record; + }, + ): Promise; deleteRetryPolicy(id: number): Promise; /** Dry-runs draft groups against a simulated failure over N attempts. */ testRetryPolicy(input: { @@ -265,6 +276,26 @@ export interface ApiClient { attempts: number; }): Promise; + /** Spent budget and alert routing for every integration-and-group pair under this policy. */ + getRetryUsage(policyId: number): Promise; + /** + * The same report for one integration, which is the only way to reach one whose policy is an + * inline `CustomRetryPolicy` — those carry no policy id for the policy-scoped report to address, + * yet still spend budget and can sit stopped with no counter anyone can see. + */ + getIntegrationRetryUsage(integrationId: number): Promise; + /** The failures one group caught for one integration — what its spent budget went on. */ + getRetryAttempts(policyId: number, pair: { integrationId: number; groupId: string }): Promise; + /** Hands a spent budget back so the group retries again. Omit a field to reset across it. */ + resetRetryUsage(policyId: number, pair?: { integrationId?: number; groupId?: string }): Promise; + /** Reset by integration, for the inline-policy case the policy-scoped reset cannot reach. */ + resetIntegrationRetryUsage(integrationId: number, groupId?: string): Promise; + /** Sets, changes or clears where one pair's alert goes — the most specific level. */ + saveRetryAlertOverride( + policyId: number, + input: { integrationId: number; groupId: string } & RetryAlertConfig, + ): Promise; + // — settings — listSettings(): Promise; /** `value: null` resets the setting back to its default. */ diff --git a/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts index 06b9c41a..d8694a8a 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts @@ -2,6 +2,9 @@ import type { ApiClient } from "../client"; import { ApiRequestError, type IntegrationType, + type RetryAlertConfig, + type RetryAlertLevel, + type RetryAttempts, type RetryDelay, type RetryGroup, type RetryPolicy, @@ -9,6 +12,7 @@ import { type RetryPolicyListRow, type RetryResultType, type RetryTestAttempt, + type RetryUsageRow, } from "../types"; import { get, getEnrichment, post, request } from "./request"; @@ -24,6 +28,8 @@ interface RawRetryPolicyRow { interface RawRetryPolicy { name: string; groups: RawRetryGroup[] | null; + alertHandlerId: string | null; + alertHandlerProperties: Record | null; } interface RawSubscriptionRef { id: number; @@ -70,8 +76,9 @@ interface RawRetryBudget { maxAttemptsTotal: number; delayStrategy: RawDelayStrategy; } -interface RawRetryGroup extends Omit { +interface RawRetryGroup extends Omit { budget?: RawRetryBudget | null; + alertHandlerProperties?: Record | null; } interface RawTestAttempt { attemptNumber: number; @@ -126,6 +133,12 @@ const toGroup = (g: RawRetryGroup): RetryGroup => ({ delay: toDelay(g.budget.delayStrategy), } : undefined, + // Named rather than left to the spread: a policy saved from an older client has no + // alert fields at all, and an undefined mode would compare unequal to "Inherit" and + // leave the Save bar up on a page nobody had edited. + alertMode: g.alertMode ?? "Inherit", + alertHandlerId: g.alertHandlerId ?? null, + alertHandlerProperties: g.alertHandlerProperties ?? {}, }); const toRawGroup = (g: RetryGroup): RawRetryGroup => ({ @@ -150,10 +163,67 @@ async function fetchDetail(id: number): Promise { name: r.name, groups: (r.groups ?? []).map(toGroup), createdOn: "", + alertHandlerId: r.alertHandlerId ?? null, + alertHandlerProperties: r.alertHandlerProperties ?? {}, integrations: subs.map((s) => ({ id: s.id, name: s.name, type: toIntegrationType(s.type) })), }; } +interface RawUsageRow { + subscriptionId: number; + subscriptionName: string; + groupId: string; + groupName: string; + attemptsUsed: number; + maxAttemptsTotal: number; + exhausted: boolean; + lastAttemptOn: string | null; + exhaustedNotifiedOn: string | null; + alertDelivered: boolean | null; + alertError: string | null; + alertMode: RetryAlertConfig["alertMode"]; + overrideHandlerId: string | null; + overrideHandlerProperties: Record | null; + resolvedHandlerId: string | null; + resolvedHandlerProperties: Record | null; + resolvedFrom: RetryAlertLevel | null; + silencedAt: RetryAlertLevel | null; +} + +const toUsageRow = (r: RawUsageRow): RetryUsageRow => ({ + integrationId: r.subscriptionId, + integrationName: r.subscriptionName, + groupId: r.groupId, + groupName: r.groupName, + used: r.attemptsUsed, + total: r.maxAttemptsTotal, + exhausted: r.exhausted, + lastAttemptOn: r.lastAttemptOn, + resolvedHandlerId: r.resolvedHandlerId, + resolvedHandlerProperties: r.resolvedHandlerProperties ?? {}, + resolvedFrom: r.resolvedFrom, + silencedAt: r.silencedAt, + override: { + alertMode: r.alertMode ?? "Inherit", + alertHandlerId: r.overrideHandlerId, + alertHandlerProperties: r.overrideHandlerProperties ?? {}, + }, + // Only an alert that was actually raised has an outcome. Delivery is reported apart from + // the claim because the two can disagree, and the disagreement is the whole point. + alert: r.exhaustedNotifiedOn + ? { claimedOn: r.exhaustedNotifiedOn, delivered: r.alertDelivered, error: r.alertError } + : null, +}); + +interface RawAttempt { + xchangeId: string; + attemptNumber: number | null; + failedOn: string; + exception: string; + retryPending: boolean; + retryBlockedReason: string | null; +} + export const retryPolicyMethods = { async listRetryPolicies(): Promise { const [res, subs] = await Promise.all([ @@ -178,12 +248,83 @@ export const retryPolicyMethods = { async createRetryPolicy({ name }: { name: string }): Promise { const id = await post("/retrypolicies", { name, groups: [] }); - return { id, name, groups: [], createdOn: "" }; + return { id, name, groups: [], createdOn: "", alertHandlerId: null, alertHandlerProperties: {} }; + }, + + async updateRetryPolicy( + id: number, + changes: { + name: string; + groups: RetryGroup[]; + alertHandlerId: string | null; + alertHandlerProperties: Record; + }, + ): Promise { + // Update replaces the whole policy, so every field it accepts has to be sent back. Omitting + // the alert cleared it on the server on every save — the settings were still on screen, and + // gone from the database. + await post(`/retrypolicies/${id}`, { + name: changes.name, + groups: changes.groups.map(toRawGroup), + alertHandlerId: changes.alertHandlerId, + alertHandlerProperties: changes.alertHandlerProperties, + }); + return { id, ...changes, createdOn: "" }; }, - async updateRetryPolicy(id: number, changes: { name: string; groups: RetryGroup[] }): Promise { - await post(`/retrypolicies/${id}`, { name: changes.name, groups: changes.groups.map(toRawGroup) }); - return { id, name: changes.name, groups: changes.groups, createdOn: "" }; + async getRetryUsage(policyId: number): Promise { + const rows = await post(`/retrypolicies/${policyId}/usage`, {}); + return (rows ?? []).map(toUsageRow); + }, + + async getIntegrationRetryUsage(integrationId: number): Promise { + const rows = await post(`/subscriptions/${integrationId}/retryusage`, {}); + return (rows ?? []).map(toUsageRow); + }, + + async getRetryAttempts( + policyId: number, + pair: { integrationId: number; groupId: string }, + ): Promise { + const res = await post<{ total: number; attempts: RawAttempt[] }>( + `/retrypolicies/${policyId}/attempts`, + { subscriptionId: pair.integrationId, groupId: pair.groupId }, + ); + return { + total: res?.total ?? 0, + attempts: (res?.attempts ?? []).map((a) => ({ + exchangeId: a.xchangeId, + attemptNumber: a.attemptNumber, + failedOn: a.failedOn, + error: a.exception, + retryPending: a.retryPending, + blockedReason: a.retryBlockedReason, + })), + }; + }, + + async resetRetryUsage(policyId: number, pair?: { integrationId?: number; groupId?: string }): Promise { + await post(`/retrypolicies/${policyId}/resetusage`, { + subscriptionId: pair?.integrationId ?? null, + groupId: pair?.groupId ?? null, + }); + }, + + async resetIntegrationRetryUsage(integrationId: number, groupId?: string): Promise { + await post(`/subscriptions/${integrationId}/resetretryusage`, { groupId: groupId ?? null }); + }, + + async saveRetryAlertOverride( + policyId: number, + input: { integrationId: number; groupId: string } & RetryAlertConfig, + ): Promise { + await post(`/retrypolicies/${policyId}/savealertoverride`, { + subscriptionId: input.integrationId, + groupId: input.groupId, + alertMode: input.alertMode, + alertHandlerId: input.alertHandlerId, + alertHandlerProperties: input.alertHandlerProperties, + }); }, async deleteRetryPolicy(id: number): Promise { diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 7727acf3..1afb62e7 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -251,6 +251,26 @@ export type RetryDelay = | { type: "linear"; initialSeconds: number; incrementSeconds: number } | { type: "exponential"; initialSeconds: number; multiplier: number; maxSeconds: number }; +/** + * Whether a level of the alert hierarchy names its own destination or defers upward. + * + * Resolved most-specific-first per integration and group: the pair's own override, then + * the group, then the policy. A level that sends **replaces** the one above rather than + * merging with it, so whichever level wins has to carry the handler and every property + * it needs. + */ +export type RetryAlertMode = "Inherit" | "Send" | "Silent"; + +/** Which level of that hierarchy decided, so a wrong destination can be traced to its source. */ +export type RetryAlertLevel = "SubscriptionGroup" | "Group" | "Policy"; + +/** A destination for budget-exhausted alerts, as configured at one level. */ +export interface RetryAlertConfig { + alertMode: RetryAlertMode; + alertHandlerId: string | null; + alertHandlerProperties: Record; +} + export interface RetryGroup { id: string; name: string; @@ -263,6 +283,10 @@ export interface RetryGroup { action: "Allow" | "Block"; budget?: { maxAttemptsPerError: number; maxAttemptsTotal: number; delay: RetryDelay }; notes?: string; + /** Where this group's budget-exhausted alert goes, for every integration using the policy. */ + alertMode: RetryAlertMode; + alertHandlerId: string | null; + alertHandlerProperties: Record; } export interface RetryPolicy { @@ -270,6 +294,9 @@ export interface RetryPolicy { name: string; groups: RetryGroup[]; createdOn: string; + /** The policy-wide alert destination, inherited by every group that doesn't name its own. */ + alertHandlerId: string | null; + alertHandlerProperties: Record; } export interface RetryPolicyListRow { id: number; @@ -283,6 +310,74 @@ export interface RetryPolicyDetail extends RetryPolicy { integrations: IntegrationSetupRef[]; } +/** + * What became of a budget-exhausted alert. + * + * `claimedOn` is when the alert was raised — all the counter itself records. Whether it then + * reached anyone is a separate fact that can fail, so the two are reported apart: a page showing + * only the claim tells the reader someone was notified when nobody was. + */ +export interface RetryAlertOutcome { + claimedOn: string; + /** Null when the alert was claimed but no delivery attempt was ever recorded. */ + delivered: boolean | null; + /** Why delivery failed, when it did. */ + error: string | null; +} + +/** + * The whole state of one integration-and-group pair: how much of the group's budget that + * integration has spent, and where the pair's budget-exhausted alert would go. + * + * Budgets are counted per pair — a shared policy gives every integration its own separate total + * — so there is no such thing as "this policy's usage". Any single figure on a policy or a group + * would be an aggregate matching nothing anyone can act on, which is why the pair is also what + * resetting and overriding both address. + */ +export interface RetryUsageRow { + integrationId: number; + integrationName: string; + groupId: string; + groupName: string; + used: number; + total: number; + /** Spent out: this integration gets no further automatic retries from this group. */ + exhausted: boolean; + /** Null when the pair has never failed — also how you know there is no counter to reset. */ + lastAttemptOn: string | null; + /** Where the alert actually goes, or null when nothing sends for this pair. */ + resolvedHandlerId: string | null; + resolvedHandlerProperties: Record; + resolvedFrom: RetryAlertLevel | null; + /** Which level deliberately switched the alert off — a decision, as against an oversight. */ + silencedAt: RetryAlertLevel | null; + /** This pair's own override; `Inherit` when it has none. */ + override: RetryAlertConfig; + alert: RetryAlertOutcome | null; +} + +/** One failure a group caught — what a usage row spent its budget on. */ +export interface RetryAttempt { + exchangeId: string; + /** How deep the retry chain was, 0 being the original delivery. Null for older failures. */ + attemptNumber: number | null; + failedOn: string; + error: string; + /** True while another attempt is still scheduled: the one thing here that is not history. */ + retryPending: boolean; + /** Why no further attempt was scheduled, when the policy refused one. */ + blockedReason: string | null; +} + +export interface RetryAttempts { + /** + * Every failure this group has caught for this integration. Failures outlive the counter, + * which is reset, so this is not the counter's value. + */ + total: number; + attempts: RetryAttempt[]; +} + export interface RetryTestAttempt { attempt: number; shouldRetry: boolean; diff --git a/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/Overview.tsx b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/Overview.tsx index 443a9fa6..b5c5d7a0 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/Overview.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/Overview.tsx @@ -10,6 +10,7 @@ import { Panel } from "../../../components/ui/Panel"; import { ExchangesList, HealthBadge, TrailTable } from "../../../components/config/shared"; import { WorkGroupDialog } from "../../../components/config/WorkGroupDialog"; import { formatDate, formatDateTime, formatDurationMs, timeAgo, timeUntil } from "../../../lib/dates"; +import { RetryBudget } from "./RetryBudget"; import type { Draft, EntryPoint } from "./model"; /** Who can feed this integration. Shared with the Trigger stage, which is the same question. */ @@ -262,6 +263,8 @@ export function Overview({ + + {paused && (

Paused since {formatDate(s.pausedOn!)} — incoming work is being held and will be released diff --git a/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/RetryBudget.tsx b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/RetryBudget.tsx new file mode 100644 index 00000000..9fa90788 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/RetryBudget.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { Link } from "react-router"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { RotateCcw } from "lucide-react"; +import { api } from "../../../api"; +import { Button } from "../../../components/ui/basics"; +import { ConfirmDialog } from "../../../components/ui/overlays"; +import { timeAgo } from "../../../lib/dates"; + +/** + * This integration's own retry budgets, asked for from its side rather than its policy's. + * + * Two reasons it is not enough to read this on the retry policy page. An integration can carry + * rules inline instead of a shared policy — those have no policy id, so no policy page reaches + * their counters, and one could sit stopped for good with nothing on screen able to say why or + * hand it back. And even with a shared policy, "why has this stopped retrying?" gets asked + * here, where the integration is, not on a page listing every integration that shares its rules. + * + * Silent until it has something to report: an integration that has never failed does not need + * to be told it has spent none of its budget. + */ +export function RetryBudget({ integrationId, canEdit }: { integrationId: number; canEdit: boolean }) { + const queryClient = useQueryClient(); + const [resetting, setResetting] = useState(false); + + const usage = useQuery({ + queryKey: ["retry-usage", "integration", integrationId], + queryFn: () => api.getIntegrationRetryUsage(integrationId), + }); + + const rows = (usage.data ?? []).filter((r) => r.used > 0); + if (rows.length === 0) return null; + + const stopped = rows.filter((r) => r.exhausted); + const lastFailure = rows + .map((r) => r.lastAttemptOn) + .filter((d): d is string => d !== null) + .sort() + .at(-1); + + return ( +

0 ? "bg-danger-50 text-danger-800" : "bg-ink-50 text-ink-600" + }`} + > +
+

+ {stopped.length > 0 ? ( + <> + Retries have stopped.{" "} + {stopped.map((r) => r.groupName).join(", ")} used up{" "} + {stopped.length === 1 ? "its budget" : "their budgets"} — failures are no longer + retried automatically until the budget is reset, or this integration succeeds again. + + ) : ( + <> + Retry budget:{" "} + {rows.map((r) => `${r.groupName} ${r.used}/${r.total}`).join(", ")} + {lastFailure && ` · last failure ${timeAgo(lastFailure)}`} + + )} +

+ + {stopped.some((r) => r.resolvedHandlerId === null) && ( + Nobody was alerted. + )} + + See failures + + {canEdit && stopped.length > 0 && ( + + )} + +
+ + {resetting && ( + + Retries resume immediately for {stopped.map((r) => r.groupName).join(", ")}. If + whatever they were failing against is still down, the budget will be spent again. + + } + confirmLabel="Reset budgets" + onConfirm={async () => { + // No group id: every group of this integration, which is what the banner reports on. + await api.resetIntegrationRetryUsage(integrationId); + await queryClient.invalidateQueries({ queryKey: ["retry-usage"] }); + }} + onClose={() => setResetting(false)} + /> + )} +
+ ); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/AlertRouting.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/AlertRouting.tsx new file mode 100644 index 00000000..c8fc296c --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/AlertRouting.tsx @@ -0,0 +1,90 @@ +import type { ReactNode } from "react"; +import type { RetryAlertConfig, RetryAlertMode } from "../../api"; +import { AdapterConfig } from "../../components/config/AdapterConfig"; + +/** + * Where a "retry budget ran out" alert goes, as set at one level of the hierarchy. + * + * Three levels decide between them — the policy, a group, and one integration-and-group pair — + * resolved most specific first. A level that sends **replaces** the level above rather than + * merging into it, so whichever one wins has to carry the handler *and* every property it needs. + * That is why each level offers the whole adapter form and not just a handler name: a handler + * copied down without its settings saves an alert that only fails at send time, hours later, + * with nobody watching. + */ + +const MODES: { value: RetryAlertMode; label: string; hint: string }[] = [ + { value: "Inherit", label: "Inherit", hint: "Use whatever the level above sends." }, + { value: "Send", label: "Send here", hint: "Send through this level's own handler instead." }, + { value: "Silent", label: "Silent", hint: "Send nothing, even if a level above would." }, +]; + +export function AlertRouting({ + value, + onChange, + inherited, + disabled = false, +}: { + value: RetryAlertConfig; + onChange: (next: RetryAlertConfig) => void; + /** + * What Inherit resolves to right now, spelled out — "Inherit" alone tells you the rule but + * not the outcome, and the outcome is the thing being decided. + */ + inherited: ReactNode; + disabled?: boolean; +}) { + const setMode = (mode: RetryAlertMode) => { + // Leaving Send keeps the handler in state but stops sending it, so flipping to Silent to + // hush an alert overnight and back again doesn't cost you the configuration. + if (mode === "Send") return onChange({ ...value, alertMode: "Send" }); + onChange({ ...value, alertMode: mode }); + }; + + return ( +
+
+ {MODES.map((m) => { + const active = value.alertMode === m.value; + return ( + + ); + })} +
+ +

+ {value.alertMode === "Inherit" ? inherited : MODES.find((m) => m.value === value.alertMode)!.hint} +

+ + {value.alertMode === "Send" && ( +
+ + onChange({ ...value, alertHandlerId, alertHandlerProperties }) + } + /> +
+ )} +
+ ); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/GroupDialog.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/GroupDialog.tsx index b574f602..c4a14c57 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/GroupDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/GroupDialog.tsx @@ -1,9 +1,10 @@ import { useState, type FormEvent } from "react"; import { Plus, Trash2 } from "lucide-react"; -import type { RetryDelay, RetryGroup, RetryMatcher, RetryResultType } from "../../api"; +import type { RetryAlertConfig, RetryDelay, RetryGroup, RetryMatcher, RetryResultType } from "../../api"; import { Button, FormError } from "../../components/ui/basics"; import { Checkbox, Field, Select, TextInput } from "../../components/ui/forms"; import { Dialog } from "../../components/ui/overlays"; +import { AlertRouting } from "./AlertRouting"; const DEFAULT_MATCHER: RetryMatcher = { type: "contains", value: "", caseSensitive: false }; @@ -153,10 +154,13 @@ export function GroupDialog({ initial, onSubmit, onClose, + policyAlertHandlerId, }: { initial?: RetryGroup; onSubmit: (group: RetryGroup) => void; onClose: () => void; + /** The policy default this group inherits when it doesn't route its own alert. */ + policyAlertHandlerId: string | null; }) { const [name, setName] = useState(initial?.name ?? ""); const [priority, setPriority] = useState(initial?.priority ?? 10); @@ -168,6 +172,11 @@ export function GroupDialog({ initial?.budget ?? { maxAttemptsPerError: 3, maxAttemptsTotal: 10, delay: defaultDelayFor("exponential") }, ); const [notes, setNotes] = useState(initial?.notes ?? ""); + const [alert, setAlert] = useState({ + alertMode: initial?.alertMode ?? "Inherit", + alertHandlerId: initial?.alertHandlerId ?? null, + alertHandlerProperties: initial?.alertHandlerProperties ?? {}, + }); const [error, setError] = useState(""); const toggleAppliesTo = (t: RetryResultType) => @@ -186,6 +195,14 @@ export function GroupDialog({ e.preventDefault(); if (!name.trim()) return setError("Give the group a name."); if (appliesTo.length === 0) return setError("Pick at least one failure kind it applies to."); + // The server refuses a group with no conditions, so catching it here saves a round trip + // that would otherwise fail only once the whole page was saved. Groups stored without any + // — from before the rule existed — still match every failure, which is why the table can + // describe one that way while this refuses to make another. + if (matchers.length === 0) + return setError("Add at least one condition — a group with none is rejected when you save."); + if (alert.alertMode === "Send" && !alert.alertHandlerId) + return setError("Pick how the budget-exhausted alert is delivered, or choose Inherit."); onSubmit({ id: initial?.id ?? crypto.randomUUID(), name: name.trim(), @@ -196,6 +213,11 @@ export function GroupDialog({ action, budget: action === "Allow" ? budget : undefined, notes: notes.trim() || undefined, + // A group that blocks can never spend a budget, so it can never exhaust one and never + // alert. Saving routing for it would leave a setting on screen that cannot ever fire. + ...(action === "Allow" + ? alert + : { alertMode: "Inherit" as const, alertHandlerId: null, alertHandlerProperties: {} }), }); onClose(); }; @@ -236,7 +258,7 @@ export function GroupDialog({
- Conditions — any may match; none means every failure + Conditions — any one may match; at least one is needed
{matchers.map((m, i) => ( @@ -268,7 +290,11 @@ export function GroupDialog({ {action === "Allow" && (
- + setBudget({ ...budget, maxAttemptsPerError: Number(e.target.value) })} /> - + )} + {action === "Allow" && ( +
+ + When the budget runs out + + + Sends through the policy default, {policyAlertHandlerId}. + + ) : ( + "The policy sends no alert, so nothing is sent — unless a single integration overrides it." + ) + } + /> +
+ )} + setNotes(e.target.value)} /> diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx index 1b0d8dd2..7c15e534 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx @@ -6,11 +6,12 @@ import { api, type RetryGroup, type RetryMatcher, type RetryResultType } from ". import { Can, useSessionCan } from "../../auth/guards"; import { Badge, Button, EmptyState, FormError, LoadingBlock } from "../../components/ui/basics"; import { Field, Select, TextInput } from "../../components/ui/forms"; -import { ConfirmDialog } from "../../components/ui/overlays"; +import { ConfirmDialog, Dialog } from "../../components/ui/overlays"; import { EditableTitle, Panel, UnsavedBar } from "../../components/ui/Panel"; import { MiniTable } from "../../components/ui/Table"; -import { SetupList } from "../../components/config/shared"; +import { AdapterConfig } from "../../components/config/AdapterConfig"; import { GroupDialog } from "./GroupDialog"; +import { UsagePanel } from "./UsagePanel"; const matcherSummary = (m: RetryMatcher): string => { switch (m.type) { @@ -111,6 +112,111 @@ function TestPanel({ groups }: { groups: RetryGroup[] }) { ); } +/** + * The policy-wide alert, summarised — with its adapter form behind a dialog. + * + * Left open, a mail handler's thirteen fields filled the whole column and left the groups + * table sitting beside a void; two columns of them inside a 360px rail wrapped every address + * onto three lines. It is also set once and rarely revisited, where everything around it is + * read on every visit, so it had the run of the page on the strength of being the longest + * form rather than the most useful one. + * + * A dialog makes the three levels consistent too: a group routes its own alert in the group + * dialog, one integration-and-group pair in the override dialog, and the policy default here. + * All three stage into the same save bar. + */ +function PolicyAlertCard({ + handlerId, + properties, + groups, + canEdit, + onChange, +}: { + handlerId: string | null; + properties: Record; + groups: RetryGroup[]; + canEdit: boolean; + onChange: (handlerId: string | null, properties: Record) => void; +}) { + const [editing, setEditing] = useState(false); + const [draftId, setDraftId] = useState(handlerId); + const [draftProps, setDraftProps] = useState(properties); + + // Only a group that retries can exhaust a budget, so only those can inherit an alert. + const canAlert = groups.filter((g) => g.action === "Allow"); + const inheriting = canAlert.filter((g) => g.alertMode === "Inherit"); + + const open = () => { + setDraftId(handlerId); + setDraftProps(properties); + setEditing(true); + }; + + return ( + + {handlerId ? "Change" : "Set up"} + + ) : undefined + } + > + {handlerId ? ( + <> +

{handlerId}

+

+ {inheriting.length === 0 + ? "No group inherits it — each one routes its own alert, or is silent." + : `${inheriting.length} of ${canAlert.length} ${canAlert.length === 1 ? "group sends" : "groups send"} here.`} +

+ + ) : ( +

+ No alert. Nothing is sent when a budget runs out, unless a group or a single + integration routes one itself. +

+ )} + + {editing && ( + setEditing(false)} wide> +
+

+ Where this policy sends an alert when any of its groups stops retrying. Saved with + the rest of the page. +

+ { + setDraftId(id); + setDraftProps(props); + }} + /> +
+ + +
+
+
+ )} +
+ ); +} + export function RetryPolicyPage() { const { id = "" } = useParams(); const policyId = Number(id); @@ -126,6 +232,8 @@ export function RetryPolicyPage() { const [name, setName] = useState(""); const [groups, setGroups] = useState(null); + const [alertHandlerId, setAlertHandlerId] = useState(null); + const [alertProps, setAlertProps] = useState>({}); const [editingGroup, setEditingGroup] = useState(null); const [deleting, setDeleting] = useState(false); const [loaded, setLoaded] = useState(false); @@ -134,21 +242,36 @@ export function RetryPolicyPage() { if (!loaded && policy.data) { setName(policy.data.name); setGroups(structuredClone(policy.data.groups)); + setAlertHandlerId(policy.data.alertHandlerId); + setAlertProps(structuredClone(policy.data.alertHandlerProperties)); setLoaded(true); } }, [policy.data, loaded]); const dirty = useMemo(() => { if (!policy.data || groups === null) return false; - return name !== policy.data.name || JSON.stringify(groups) !== JSON.stringify(policy.data.groups); - }, [policy.data, name, groups]); + return ( + name !== policy.data.name || + JSON.stringify(groups) !== JSON.stringify(policy.data.groups) || + alertHandlerId !== policy.data.alertHandlerId || + JSON.stringify(alertProps) !== JSON.stringify(policy.data.alertHandlerProperties) + ); + }, [policy.data, name, groups, alertHandlerId, alertProps]); const save = useMutation({ - mutationFn: () => api.updateRetryPolicy(policyId, { name, groups: groups ?? [] }), + mutationFn: () => + api.updateRetryPolicy(policyId, { + name, + groups: groups ?? [], + alertHandlerId, + alertHandlerProperties: alertProps, + }), onSuccess: async () => { // Await the detail refetch before re-syncing the draft (avoids stale-data race). await queryClient.invalidateQueries({ queryKey: ["retry-policy", policyId] }); void queryClient.invalidateQueries({ queryKey: ["retry-policies"] }); + // Editing a group can change which budgets exist, so the usage report is stale too. + void queryClient.invalidateQueries({ queryKey: ["retry-usage"] }); setLoaded(false); }, }); @@ -264,6 +387,22 @@ export function RetryPolicyPage() { ), }, + { + header: "Alert", + truncate: true, + cell: (g) => + g.action !== "Allow" ? ( + + ) : g.alertMode === "Silent" ? ( + Silent + ) : g.alertMode === "Send" && g.alertHandlerId ? ( + {g.alertHandlerId} + ) : ( + + {alertHandlerId ? "Inherited" : "Nobody"} + + ), + }, { header: "Notes", truncate: true, @@ -303,16 +442,27 @@ export function RetryPolicyPage() { /> -
- - - + { + setAlertHandlerId(id); + setAlertProps(props); + }} + />
+
+ + +
+ {canEdit && dirty && ( setEditingGroup(null)} + policyAlertHandlerId={alertHandlerId} /> )} diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx new file mode 100644 index 00000000..b5ee75e2 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx @@ -0,0 +1,492 @@ +import { useState } from "react"; +import { Link } from "react-router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, BellOff, ChevronDown, ChevronRight, RotateCcw } from "lucide-react"; +import { api, type IntegrationSetupRef, type RetryAlertLevel, type RetryUsageRow } from "../../api"; +import { Badge, Button, FormError, LoadingBlock } from "../../components/ui/basics"; +import { ConfirmDialog, Dialog } from "../../components/ui/overlays"; +import { Panel } from "../../components/ui/Panel"; +import { formatDateTime, timeAgo } from "../../lib/dates"; +import { AlertRouting } from "./AlertRouting"; + +/** + * What each retry budget of this policy has actually spent, and whether anyone was told when + * one ran out. + * + * One row per integration-and-group pair, because that is how a budget is counted: a shared + * policy gives every integration its own separate total. There is no such thing as "this + * policy's usage" — any single figure here would be an aggregate matching nothing anyone can + * act on — and it is also why resetting and overriding both address one pair. + * + * Everything in this panel happens immediately. The groups above it stage into the save bar; + * a reset here does not, and the panel says so rather than leaving the reader to find out. + */ + +const LEVEL_WORD: Record = { + SubscriptionGroup: "this integration", + Group: "the group", + Policy: "the policy", +}; + +type FilterKey = "attention" | "exhausted" | "silent" | "overridden" | "all"; + +/** + * A pair worth looking at: stopped retrying, alerted nobody when it did, or would alert + * nobody without anyone having chosen that. + * + * A silence someone configured is deliberately *not* here. Both a silenced pair and an + * unrouted one send nothing, but only one of them is a mistake, and a filter that cannot + * tell them apart flags every deliberate silence until nobody reads it any more. + */ +const needsAttention = (r: RetryUsageRow) => + r.exhausted || r.alert?.delivered === false || (r.resolvedHandlerId === null && r.silencedAt === null); + +const FILTERS: { key: FilterKey; label: string; match: (r: RetryUsageRow) => boolean; blurb: string }[] = [ + { + key: "attention", + label: "Needs attention", + match: needsAttention, + blurb: + "Budgets that have run out, alerts that did not arrive, and pairs that would alert nobody without anyone having chosen that.", + }, + { + key: "exhausted", + label: "Exhausted", + match: (r) => r.exhausted, + blurb: "No longer retried at all until the budget is reset, or the integration succeeds.", + }, + { + key: "silent", + label: "No alert", + match: (r) => r.resolvedHandlerId === null, + blurb: "Nothing is sent when these run out — whether that was chosen or simply never set.", + }, + { + key: "overridden", + label: "Overridden", + match: (r) => r.override.alertMode !== "Inherit", + blurb: "Pairs whose alert routing is set on the integration itself, not inherited.", + }, + { key: "all", label: "All", match: () => true, blurb: "Every integration and group using this policy." }, +]; + +/** Where this pair's alert ends up, said as a destination rather than a mode. */ +function AlertCell({ row }: { row: RetryUsageRow }) { + if (row.resolvedHandlerId) + return ( + + {row.resolvedHandlerId} + {row.resolvedFrom && · set by {LEVEL_WORD[row.resolvedFrom]}} + + ); + + return ( + + + {row.silencedAt ? ( + Silenced by {LEVEL_WORD[row.silencedAt]} + ) : ( + Nobody + )} + + ); +} + +/** + * Whether the alert reached anyone. + * + * Kept apart from the fact that one was raised, because those are two different things and + * the page has to be able to say when they disagree: the alert is claimed *before* the send is + * attempted, so a refused login or a failed TLS handshake leaves a budget that stopped + * retrying and a team that was never told. + */ +function AlertedCell({ row }: { row: RetryUsageRow }) { + const { alert } = row; + if (!alert) return ; + if (alert.delivered === false) return Not delivered; + if (alert.delivered === null) + return ( + + Unconfirmed + + ); + return ( + + Sent {timeAgo(alert.claimedOn)} + + ); +} + +/** The failures a pair spent its budget on, fetched only once the row is opened. */ +function Attempts({ policyId, row }: { policyId: number; row: RetryUsageRow }) { + const q = useQuery({ + queryKey: ["retry-attempts", policyId, row.integrationId, row.groupId], + queryFn: () => api.getRetryAttempts(policyId, { integrationId: row.integrationId, groupId: row.groupId }), + }); + + if (q.isPending) return ; + if (q.isError) return {q.error.message}; + + const { total, attempts } = q.data; + if (attempts.length === 0) + return ( +

+ No failures recorded against this group for this integration. +

+ ); + + return ( +
+ {row.alert?.error && ( +
+

+ The budget-exhausted alert could not be delivered. +

+

{row.alert.error}

+
+ )} +
    + {attempts.map((a) => ( +
  1. + + {a.exchangeId.slice(0, 8)} + + + {timeAgo(a.failedOn)} + + {a.retryPending ? ( + Retry due + ) : a.blockedReason ? ( + Stopped + ) : null} + + {a.blockedReason ?? a.error} + +
  2. + ))} +
+ {total > attempts.length && ( +

+ Showing the {attempts.length} most recent of {total} failures.{" "} + + See them all + +

+ )} +
+ ); +} + +/** Sets where one pair's alert goes, overriding the group and the policy behind it. */ +function OverrideDialog({ + policyId, + row, + onClose, +}: { + policyId: number; + row: RetryUsageRow; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + // Seeded from what this pair currently sends, not from an empty form: an override replaces + // the level above rather than merging with it, so starting blank would quietly drop the very + // settings the alert needs to arrive. + const [value, setValue] = useState({ + alertMode: row.override.alertMode, + alertHandlerId: row.override.alertHandlerId ?? row.resolvedHandlerId, + alertHandlerProperties: + Object.keys(row.override.alertHandlerProperties).length > 0 + ? row.override.alertHandlerProperties + : row.resolvedHandlerProperties, + }); + + const save = useMutation({ + mutationFn: () => api.saveRetryAlertOverride(policyId, { ...value, integrationId: row.integrationId, groupId: row.groupId }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["retry-usage"] }); + onClose(); + }, + }); + + return ( + +
+

+ Where this one integration's alert goes when this group's budget runs out. The most + specific of the three levels — it wins over both the group and the policy. +

+ + Sends through {row.resolvedHandlerId}, set by{" "} + {row.resolvedFrom ? LEVEL_WORD[row.resolvedFrom] : "a level above"}. + + ) : row.silencedAt ? ( + `Silenced by ${LEVEL_WORD[row.silencedAt]}, so nothing is sent.` + ) : ( + "No level above sends anything, so nothing is sent." + ) + } + /> + {save.error?.message} +
+ + +
+
+
+ ); +} + +export function UsagePanel({ + policyId, + integrations, + canEdit, +}: { + policyId: number; + /** Used to explain an empty report — the integrations are still following the policy. */ + integrations: IntegrationSetupRef[]; + canEdit: boolean; +}) { + const queryClient = useQueryClient(); + const [filter, setFilter] = useState("attention"); + const [open, setOpen] = useState(null); + const [overriding, setOverriding] = useState(null); + const [resetting, setResetting] = useState(null); + + const usage = useQuery({ queryKey: ["retry-usage", policyId], queryFn: () => api.getRetryUsage(policyId) }); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["retry-usage"] }); + + if (usage.isPending) return ; + if (usage.isError) + return ( + + {usage.error.message} + + ); + + const rows = usage.data; + + if (rows.length === 0) + return ( + +

+ {integrations.length === 0 + ? "No integration uses this policy yet." + : "No group in this policy sets a total budget, so there is nothing to spend and nothing that can run out."} +

+
+ ); + + const counts = Object.fromEntries( + FILTERS.map((f) => [f.key, rows.filter(f.match).length]), + ) as Record; + const active = FILTERS.find((f) => f.key === filter)!; + const shown = rows.filter(active.match); + const spent = rows.filter((r) => r.exhausted); + + return ( + 0 ? ( + + ) : undefined + } + > +
+ {FILTERS.map((f) => { + const on = f.key === filter; + return ( + + ); + })} +
+

{active.blurb}

+ + {shown.length === 0 ? ( +

Nothing here — which is the good outcome.

+ ) : ( +
+ + + + + + + + + + + + + {shown.map((r) => { + const key = `${r.integrationId}:${r.groupId}`; + const isOpen = open === key; + return [ + + + + + + + + + + , + isOpen && ( + + {/* + max-w-0 stops this cell reporting an intrinsic width, the same trick the + shared Table uses. Without it a single unwrapped stack trace widened the + whole table, and every column after Integration was pushed out of view — + opening one row hid the data in all the others. + */} + + + ), + ]; + })} + +
+ IntegrationGroupUsedLast failureAlert goes toAlerted +
+ + + + {r.integrationName} + + + {r.groupName} + + + + {r.used} / {r.total} + + {r.exhausted && Exhausted} + + + {r.lastAttemptOn ? ( + {timeAgo(r.lastAttemptOn)} + ) : ( + never failed + )} + + + + + + {canEdit && ( + + {r.lastAttemptOn && ( + + )} + + + )} +
+ +
+
+ )} + + {overriding && ( + setOverriding(null)} /> + )} + + {resetting && ( + + {spent.length} {spent.length === 1 ? "budget starts" : "budgets start"} again from zero, + and {spent.length === 1 ? "its" : "their"} integrations begin retrying immediately. If + the downstream is still down, they will spend it again. + + ) : ( + <> + {resetting.integrationName} starts + again from zero in {resetting.groupName}, + and retries resume immediately. + + ) + } + confirmLabel={resetting === "all" ? "Reset them all" : "Reset budget"} + onConfirm={async () => { + // No pair means every pair — which is why the sweep is scoped to what has actually + // run out rather than sent as one policy-wide reset: a budget partway through is + // spending it for a reason, and handing that back is not what was asked for. + if (resetting === "all") + await Promise.all( + spent.map((r) => + api.resetRetryUsage(policyId, { integrationId: r.integrationId, groupId: r.groupId }), + ), + ); + else + await api.resetRetryUsage(policyId, { + integrationId: resetting.integrationId, + groupId: resetting.groupId, + }); + await invalidate(); + }} + onClose={() => setResetting(null)} + /> + )} + + {counts.attention > 0 && filter !== "attention" && ( +

+ + {counts.attention} {counts.attention === 1 ? "pair needs" : "pairs need"} attention. +

+ )} +
+ ); +} From 209b8ac5ff1fccbb23ac3ccf8fc02cb24aa493ee Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 20 Aug 2026 14:09:40 +0300 Subject: [PATCH 25/54] fix: size adapter fields by their container, not the viewport --- .../src/components/config/AdapterConfig.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx index 920fb102..09275923 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx @@ -503,16 +503,22 @@ export function AdapterConfig({
{adapter && adapter.props.length > 0 && ( -
+ /* + Two columns are decided by how wide *this* box is, not how wide the window is. + A `sm:` breakpoint reads the viewport, so a 360px side panel on a 2000px screen + still got two columns, and every email address wrapped onto three lines. The + container query asks the box instead, so a narrow one simply stacks. + */ +
{requiredProps.length > 0 && ( -
{requiredProps.map(renderProp)}
+
{requiredProps.map(renderProp)}
)} {optionalProps.length > 0 && (

Optional · {optionalProps.length} field{optionalProps.length === 1 ? "" : "s"}

-
{optionalProps.map(renderProp)}
+
{optionalProps.map(renderProp)}
)}
From 67558bc3e3c8aaea4438100008041c81e8ce1524 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 23 Aug 2026 12:05:28 +0300 Subject: [PATCH 26/54] fix: refuse a bus gateway route as a response destination --- .../Resources/Subscriptions/Create.cs | 9 +++- .../ResponseRoutingValidation.cs | 42 +++++++++++++++++++ .../Resources/Subscriptions/Update.cs | 9 ++++ .../pages/bus-gateways/studio/Inspector.tsx | 4 +- .../integrations/studio/ResponseFields.tsx | 24 +++++++++-- 5 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 SW.Bitween.Api/Resources/Subscriptions/ResponseRoutingValidation.cs diff --git a/SW.Bitween.Api/Resources/Subscriptions/Create.cs b/SW.Bitween.Api/Resources/Subscriptions/Create.cs index 74dad73f..0b675d4e 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Create.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Create.cs @@ -89,8 +89,15 @@ public async Task Handle(SubscriptionCreate model) private class Validate : AbstractValidator { - public Validate(AdapterRequirements adapterRequirements) + public Validate(BitweenDbContext dbContext, AdapterRequirements adapterRequirements) { + RuleFor(i => i.ResponseSubscriptionId).CustomAsync(async (responseSubId, context, _) => + { + var failure = await ResponseRoutingValidation.CheckDestination(dbContext, responseSubId); + if (failure != null) + context.AddFailure(nameof(SubscriptionCreate.ResponseSubscriptionId), failure); + }); + RuleFor(i => i.Name).NotEmpty(); RuleFor(i => i.DocumentId).NotEmpty().When(i => i.Type != SubscriptionType.Aggregation); RuleFor(i => i.PartnerId).NotEqual(Partner.SystemId); diff --git a/SW.Bitween.Api/Resources/Subscriptions/ResponseRoutingValidation.cs b/SW.Bitween.Api/Resources/Subscriptions/ResponseRoutingValidation.cs new file mode 100644 index 00000000..82b4e9d6 --- /dev/null +++ b/SW.Bitween.Api/Resources/Subscriptions/ResponseRoutingValidation.cs @@ -0,0 +1,42 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Subscriptions; + +/// +/// Guards what a delivery response may be fed into. +/// +/// A bus gateway's route is defined by the message that runs it, but +/// creates the response exchange against the chosen +/// subscription directly. Picking a route as the response destination therefore runs that one +/// route with the bus skipped entirely: no message published, no route matching, no filter, and +/// none of the other routes bound to the same message. It looks like publishing and is not, so +/// it is refused rather than left as a trap. ResponseMessageTypeName is the field that +/// actually puts a response on the bus. +/// +/// +internal static class ResponseRoutingValidation +{ + public const string BusGatewayCode = "RESPONSE_SUBSCRIPTION_IS_BUS_GATEWAY"; + + /// Returns the failure message, or null when the destination is allowed. + public static async Task CheckDestination(BitweenDbContext dbContext, int? responseSubscriptionId) + { + if (responseSubscriptionId is null) return null; + + var type = await dbContext.Set().AsNoTracking() + .Where(s => s.Id == responseSubscriptionId.Value) + .Select(s => (SubscriptionType?)s.Type) + .SingleOrDefaultAsync(); + + if (type != SubscriptionType.BusGateway) return null; + + return "A bus gateway route cannot receive a response: it would run with the bus skipped, " + + "so no other route bound to the same message would see it. Publish the response on " + + "the bus instead, and let the gateway's routes pick it up."; + } +} diff --git a/SW.Bitween.Api/Resources/Subscriptions/Update.cs b/SW.Bitween.Api/Resources/Subscriptions/Update.cs index 61da23a6..249cdfc6 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Update.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Update.cs @@ -151,6 +151,15 @@ public Validate(BitweenDbContext dbContext, IHttpContextAccessor httpContextAcce }); }); + // Outside the handler check above: a response destination that can never work is + // wrong whether or not this same request also sets a handler. + RuleFor(i => i.ResponseSubscriptionId).CustomAsync(async (responseSubId, context, ct) => + { + var failure = await ResponseRoutingValidation.CheckDestination(dbContext, responseSubId); + if (failure != null) + context.AddFailure(nameof(SubscriptionUpdate.ResponseSubscriptionId), failure); + }); + RuleFor(i => i).CustomAsync(async (model, context, ct) => { var subscription = await GetSub(dbContext, httpContextAccessor); diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx index 1ae9ac10..f2f929de 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx @@ -1,7 +1,7 @@ import { Link } from "react-router"; import { useQuery } from "@tanstack/react-query"; import { ChevronDown, ChevronUp, Plus, X } from "lucide-react"; -import { api } from "../../../api"; +import { api, type IntegrationType } from "../../../api"; import { useSessionCan } from "../../../auth/guards"; import { Badge } from "../../../components/ui/basics"; import { Field, TextInput } from "../../../components/ui/forms"; @@ -337,7 +337,7 @@ export function ResponseBody({ draft: IntegrationDraft; onChange: (patch: Partial) => void; disabled: boolean; - candidates: { id: number; name: string }[]; + candidates: { id: number; name: string; type: IntegrationType }[]; onNewIntegration: () => void; canCreate: boolean; }) { diff --git a/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/ResponseFields.tsx b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/ResponseFields.tsx index 643f99f7..6e7498df 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/ResponseFields.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/ResponseFields.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Plus } from "lucide-react"; -import { api } from "../../../api"; +import { api, type IntegrationType } from "../../../api"; import { useSessionCan } from "../../../auth/guards"; import { Field, TextInput } from "../../../components/ui/forms"; import { SearchSelect } from "../../../components/ui/SearchSelect"; @@ -33,7 +33,7 @@ export function ResponseFields({ }) => void; disabled: boolean; /** Integrations the response can be fed into (excluding this one). */ - candidates: { id: number; name: string }[]; + candidates: { id: number; name: string; type: IntegrationType }[]; idPrefix?: string; }) { if (handlerId === null) @@ -43,12 +43,22 @@ export function ResponseFields({

); + // A bus gateway's routes are chosen by the message that runs them. Feeding a response + // straight into one runs that single route with the bus skipped — nothing published, no + // matching, no filter, and none of the other routes bound to the same message — which + // looks like publishing and isn't. The API refuses it; this keeps it out of reach. + // An already-saved one stays listed so opening this panel can't quietly blank it. + const offered = candidates.filter((x) => x.type !== "BusGateway" || x.id === responseIntegrationId); + const busRouteChosen = candidates.some( + (x) => x.id === responseIntegrationId && x.type === "BusGateway", + ); + return (
onChange({ responseIntegrationId: v === "" ? null : Number(v) })} clearLabel="Nothing — responses are only recorded" - options={candidates.map((x) => ({ value: String(x.id), label: x.name }))} + options={offered.map((x) => ({ value: String(x.id), label: x.name }))} /> + {busRouteChosen && ( +

+ That is a bus gateway route, which saving will refuse — it would run on its own with + the bus skipped. Publish on the bus below instead, and its gateway picks it up. +

+ )}
Date: Sun, 23 Aug 2026 12:05:36 +0300 Subject: [PATCH 27/54] fix: don't draw a response from an integration that delivers nothing --- SW.Bitween.Web/ClientApp/src/api/http/integrations.ts | 1 + SW.Bitween.Web/ClientApp/src/api/types.ts | 6 ++++++ SW.Bitween.Web/ClientApp/src/pages/flow/model.ts | 11 +++++++++++ 3 files changed, 18 insertions(+) diff --git a/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts b/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts index 10cdcb32..b1c305c7 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts @@ -246,6 +246,7 @@ export const integrationMethods = { informationTypeId: raw.documentId, workGroupId: raw.workGroupId ?? null, retryPolicyId: raw.retryPolicyId ?? null, + handlerId: raw.handlerId ?? null, responseMessageTypeName: raw.responseMessageTypeName ?? null, responseIntegrationId: raw.responseSubscriptionId ?? null, // No backend endpoint indexes reference tokens, but the search rows carry diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 1afb62e7..943ae99e 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -143,6 +143,12 @@ export interface IntegrationInfo { informationTypeId: number; workGroupId: number | null; retryPolicyId: number | null; + /** + * Its delivery step. Null means nothing is delivered — and since a response is + * whatever the delivery hands back, null here means the two response fields below + * can never be reached, however they are set. + */ + handlerId: string | null; /** The bus message its delivery response is published as, if any. */ responseMessageTypeName: string | null; /** The integration its delivery response is handed straight to, if any. */ diff --git a/SW.Bitween.Web/ClientApp/src/pages/flow/model.ts b/SW.Bitween.Web/ClientApp/src/pages/flow/model.ts index 17cfb772..a72d4c6d 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/flow/model.ts +++ b/SW.Bitween.Web/ClientApp/src/pages/flow/model.ts @@ -162,6 +162,17 @@ export function buildFlowGraph({ const from = runBy.get(i.id) ?? standalone(i.id); if (from === null) continue; + // A response is whatever the delivery hands back, so with no delivery there is never + // one to route: `XchangeService.RunHandler` returns null without a handler, and both + // response paths are gated on that file being non-null. Drawing the edges anyway would + // make the map assert a flow the runtime cannot produce — the exact kind of lie it + // exists to catch. The integration's own page hides these fields once the delivery is + // cleared, which is what lets the pair drift apart unnoticed. + if (i.handlerId === null) { + warn(from, `${i.name} routes its response but delivers nothing, so no response is ever produced.`); + continue; + } + if (i.responseMessageTypeName !== null && i.responseMessageTypeName !== "") { const to = messageByName.get(i.responseMessageTypeName.toLowerCase()); if (to) edges.push({ id: `pub:${i.id}`, from, to, kind: "publishes" }); From b40a22186c81aed7923ee3e038506e30e4071a29 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 23 Aug 2026 12:05:36 +0300 Subject: [PATCH 28/54] fix: open a retry group from its row, and keep its table in the panel --- .../ClientApp/src/components/ui/Table.tsx | 42 ++++++++----- .../pages/retry-policies/RetryPolicyPage.tsx | 60 +++++++++++-------- 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx b/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx index 0550c9f5..08b34a3d 100644 --- a/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx @@ -102,29 +102,40 @@ export function MiniTable({ rows, rowKey, empty, + onRowClick, + fitWidth = false, }: { columns: Column[]; rows: T[]; rowKey: (row: T) => string | number; /** Shown instead of the table when there are no rows. */ empty: ReactNode; + /** Makes the whole row the way in, as on the page-level table. */ + onRowClick?: (row: T) => void; + /** + * Honour `truncate` and stay inside the panel instead of growing past it. + * + * Off by default because most callers are 2–4 column lists in the ~360px + * sidebar, where collapsing a column to ellipsis its text gives a row of + * "3c8…" — there, growing and scrolling sideways is the better trade. A + * wide table in the main column is the opposite case: it has the room, and + * what it pushes out of reach is the action buttons on the right, which + * nobody thinks to scroll a table sideways to find. + */ + fitWidth?: boolean; }) { if (rows.length === 0) return

{empty}

; + const cell = (c: Column) => + `px-1 ${fitWidth ? widthClass(c) : "whitespace-nowrap"} ${c.align === "right" ? "text-right" : ""} ${c.className ?? ""}`; + return ( - // Unlike the page-level table, this one does NOT honour `truncate`. These - // panels sit in a ~360px sidebar column, where letting a column collapse - // so its text can ellipsis leaves a row of "3c8…" and headers spilling - // over each other. Full values, natural widths, scroll sideways instead.
- +
{columns.map((c, i) => ( - ))} @@ -132,12 +143,15 @@ export function MiniTable({ {rows.map((row) => ( - + onRowClick(row) : undefined} + className={`border-b border-ink-50 last:border-b-0 ${ + onRowClick ? "cursor-pointer hover:bg-ink-50/60" : "" + }`} + > {columns.map((c, i) => ( - ))} diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx index 7c15e534..51726be7 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx @@ -337,6 +337,8 @@ export function RetryPolicyPage() { g.id} + fitWidth + onRowClick={canEdit ? (g) => setEditingGroup(g) : undefined} empty="No groups yet — failures under this policy are never retried." columns={[ { @@ -348,7 +350,10 @@ export function RetryPolicyPage() { header: "Group", truncate: true, cell: (g) => ( - + {g.name} ), @@ -365,23 +370,38 @@ export function RetryPolicyPage() { { header: "Applies to", truncate: true, - cell: (g) => ( - - {g.appliesTo.map((t) => (t === "Error" ? "errors" : "bad results")).join(" and ")} - {g.matchers.length === 0 - ? " — any failure" - : ` matching ${g.matchers.map((m) => matcherSummary(m)).join(" or ")}`} - - ), + cell: (g) => { + // Scope first and short, conditions second: every row in a policy tends to + // share the scope, so leading with "errors matching " spent the column's + // width on the one part that never tells them apart. + const scope = + g.appliesTo.length === 1 && g.appliesTo[0] === "Error" + ? null + : g.appliesTo.map((t) => (t === "Error" ? "Errors" : "Bad results")).join(" + "); + const conditions = + g.matchers.length === 0 + ? "any failure" + : g.matchers.map((m) => matcherSummary(m)).join(" or "); + return ( + + {scope && {scope} · } + {conditions} + + ); + }, }, { + // Bounded text — two numbers and one of three delay names — so it shrinks to + // fit instead of truncating, leaving the slack to the columns that need it. header: "Budget", - truncate: true, cell: (g) => g.action === "Allow" && g.budget ? ( - - {g.budget.maxAttemptsPerError} tries ({g.budget.maxAttemptsTotal} total),{" "} - {g.budget.delay.type} delay + + {g.budget.maxAttemptsPerError} tries ({g.budget.maxAttemptsTotal} total) ·{" "} + {g.budget.delay.type} ) : ( @@ -403,24 +423,12 @@ export function RetryPolicyPage() { ), }, - { - header: "Notes", - truncate: true, - cell: (g) => - g.notes ? ( - - {g.notes} - - ) : ( - - ), - }, { header: "", align: "right", cell: (g) => canEdit ? ( - + e.stopPropagation()}> diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx index b5ee75e2..6e2a0edc 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx @@ -158,9 +158,9 @@ function Attempts({ policyId, row }: { policyId: number; row: RetryUsageRow }) { {timeAgo(a.failedOn)} {a.retryPending ? ( - Retry due + Retry due ) : a.blockedReason ? ( - Stopped + Stopped ) : null} {a.blockedReason ?? a.error} @@ -386,7 +386,14 @@ export function UsagePanel({ {r.used} / {r.total} - {r.exhausted && Exhausted} + {r.exhausted && ( + + Exhausted + + )}
+ {c.header}
+ {c.cell(row)} - + {/* Status and the relationship markers read as one thought: From 51b222664433eb904921c45f09161f655e0fc361 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 23 Aug 2026 13:20:03 +0300 Subject: [PATCH 39/54] fix: keep promoted property values as the payload sent them --- SW.Bitween.Api/Resources/Xchanges/Search.cs | 6 +++++- SW.Bitween.Api/Services/FilterService.cs | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/SW.Bitween.Api/Resources/Xchanges/Search.cs b/SW.Bitween.Api/Resources/Xchanges/Search.cs index 38eb0aa7..cadf7daf 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Search.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Search.cs @@ -151,7 +151,11 @@ from delayedRetry in drGroup.DefaultIfEmpty() { var value = propertyFilter.Value.ToString()!.ToLower(); - query = query.Where(i => i.PromotedPropertiesRaw.Contains(value)); + // Both sides lower-cased at query time. Promoted values keep the case the + // payload had (see FilterService), so the column has to be folded here for + // the search to stay case-insensitive. No index is lost: a Contains is a + // leading-wildcard LIKE, which the b-tree on this column could never serve. + query = query.Where(i => i.PromotedPropertiesRaw.ToLower().Contains(value)); condition.Filters.Remove(propertyFilter); } } diff --git a/SW.Bitween.Api/Services/FilterService.cs b/SW.Bitween.Api/Services/FilterService.cs index 6dd8ecf0..d42aef6b 100644 --- a/SW.Bitween.Api/Services/FilterService.cs +++ b/SW.Bitween.Api/Services/FilterService.cs @@ -38,7 +38,13 @@ public async Task Filter(int documentId, XchangeFile xchangeFile) //TODO check if we need to validate here //if (ppValue is null) // throw new SWValidationException("PROMOTED_PROPERTY_NOT_FOUND", $"The path {pp.Value} is null on the docuemnt"); - filterResult.Properties.Add(pp.Key, ppValue?.ToLower()); + // Stored as the payload sent it. It used to be lower-cased here, which was + // only ever to pair with the lower-cased term in Xchanges/Search — nothing + // matches on this dictionary (match expressions read the payload directly), + // so the one thing it changed was what every screen displays: an order for + // "Acme Retail" listed as "acme retail". Search now lower-cases the column + // instead, which keeps it case-insensitive without rewriting the data. + filterResult.Properties.Add(pp.Key, ppValue); } var subs = await _cache.ListSubscriptionsByDocumentAsync(documentId); From 91975c803cc12c5b6c3b89f8b31fef0f051771c8 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 23 Aug 2026 13:27:32 +0300 Subject: [PATCH 40/54] feat: filter exchanges by one promoted property, not all of them --- .../ClientApp/src/api/http/exchanges.ts | 8 ++++- SW.Bitween.Web/ClientApp/src/api/types.ts | 5 +++ .../src/pages/exchanges/ExchangesPage.tsx | 34 +++++++++++++++++-- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index 0ff6f882..12718ecd 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -134,7 +134,13 @@ function buildExchangeQuery(query: ExchangeQuery): string { params.append("filter", `Id:4:text|${ids.join("|")}`); } if (query.correlationId?.trim()) params.append("filter", `CorrelationId:1:${query.correlationId.trim()}`); - if (query.property?.trim()) params.append("filter", `PromotedPropertiesRaw:4:${query.property.trim()}`); + // PromotedPropertiesRaw is stored as "key:value,key:value", so prefixing the key turns + // the same substring match into a scoped one — no schema or endpoint change needed. + // Typing "merchant:Acme" into the value box has therefore always worked; the picker + // just makes it something you can find. + const propertyValue = query.property?.trim() ?? ""; + const propertyTerm = query.propertyKey ? `${query.propertyKey}:${propertyValue}` : propertyValue; + if (propertyTerm) params.append("filter", `PromotedPropertiesRaw:4:${propertyTerm}`); if (query.from) params.append("filter", `StartedOn:6:${query.from}`); if (query.to) params.append("filter", `StartedOn:8:${query.to}`); params.set("page", String(Math.floor(query.offset / query.limit))); diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 943ae99e..d94a2aa8 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -771,6 +771,11 @@ export interface ExchangeQuery { correlationId?: string; /** Substring match against promoted property keys and values. */ property?: string; + /** + * Narrows `property` to one promoted key. Set on its own it asks "has this key at + * all", which is worth being able to ask. + */ + propertyKey?: string; from?: string; to?: string; offset: number; diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index 47588df6..3e605741 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -25,7 +25,7 @@ const REFRESH_OPTIONS = [ ]; /** Everything except paging counts as "a filter" for the Clear affordance. */ -const FILTER_KEYS = ["status", "integrationId", "partnerId", "informationTypeId", "ids", "correlationId", "property", "from", "to"] as const; +const FILTER_KEYS = ["status", "integrationId", "partnerId", "informationTypeId", "ids", "correlationId", "propertyKey", "property", "from", "to"] as const; const readQuery = (sp: URLSearchParams): ExchangeQuery => ({ status: (sp.get("status") as ExchangeStatus | null) ?? undefined, @@ -34,6 +34,7 @@ const readQuery = (sp: URLSearchParams): ExchangeQuery => ({ informationTypeId: sp.get("informationTypeId") ? Number(sp.get("informationTypeId")) : undefined, ids: sp.get("ids") ?? undefined, correlationId: sp.get("correlationId") ?? undefined, + propertyKey: sp.get("propertyKey") ?? undefined, property: sp.get("property") ?? undefined, from: sp.get("from") ? new Date(sp.get("from")! + "T00:00:00").toISOString() : undefined, to: sp.get("to") ? new Date(sp.get("to")! + "T23:59:59").toISOString() : undefined, @@ -63,6 +64,25 @@ export function ExchangesPage() { const infoTypes = useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }).data ?? []; + /** + * Every promoted key any information type declares, with the types that declare it. + * Read off the list already fetched for the information-type filter, so offering the + * keys costs nothing — and picking from real names beats remembering how one was spelled. + */ + const propertyKeyOptions = useMemo(() => { + const owners = new Map(); + for (const t of infoTypes) + for (const p of t.promotedProperties ?? []) { + const carriers = owners.get(p.key) ?? []; + const name = t.code ?? t.name; + if (!carriers.includes(name)) carriers.push(name); + owners.set(p.key, carriers); + } + return [...owners.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, carriers]) => ({ value: key, label: key, hint: carriers.join(", ") })); + }, [infoTypes]); + /** Set (or drop) one URL param; changing any filter resets paging. */ const setParam = (key: string, value: string | null, resetOffset = true) => { const next = new URLSearchParams(searchParams); @@ -216,10 +236,20 @@ export function ExchangesPage() { } onKeyDown={(e) => e.key === "Enter" && setParam("correlationId", e.currentTarget.value || null)} /> +
+ setParam("propertyKey", v || null)} + /> +
e.target.value !== (query.property ?? "") && setParam("property", e.target.value || null)} From d78e8f2e158e959c2e73466d2afb4c027921ce63 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 23 Aug 2026 13:33:57 +0300 Subject: [PATCH 41/54] chore: put the flow map after the gateways, notifiers after retry policies --- SW.Bitween.Web/ClientApp/src/nav.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/nav.ts b/SW.Bitween.Web/ClientApp/src/nav.ts index 9a7cada7..7012f172 100644 --- a/SW.Bitween.Web/ClientApp/src/nav.ts +++ b/SW.Bitween.Web/ClientApp/src/nav.ts @@ -58,12 +58,13 @@ export const NAV_GROUPS: NavGroup[] = [ // pipelines it runs through, then who it's with. A gateway is not an integration. label: "Integrations", items: [ - // Gated on the bus alone: bus messages are what carry work *between* - // gateways, so without that permission there is no flow left to map. - { label: "Flow map", path: "/flow", icon: Network, permissions: ["bus-gateways.view"] }, { label: "API gateways", path: "/api-gateways", icon: Webhook, permissions: ["api-gateways.view"] }, { label: "Bus gateways", path: "/bus-gateways", icon: Cable, permissions: ["bus-gateways.view"] }, { label: "Scheduled jobs", path: "/scheduled-jobs", icon: CalendarClock, permissions: ["subscriptions.view"] }, + // After the three ways work enters, because it is the picture of how they join up + // rather than a fourth kind of them. Gated on the bus alone: bus messages are what + // carry work *between* gateways, so without that permission there is no flow to map. + { label: "Flow map", path: "/flow", icon: Network, permissions: ["bus-gateways.view"] }, { label: "All integrations", path: "/subscriptions", icon: Workflow, permissions: ["subscriptions.view"] }, { label: "Partners", path: "/partners", icon: Handshake, permissions: ["partners.view"] }, ], @@ -73,9 +74,11 @@ export const NAV_GROUPS: NavGroup[] = [ items: [ { label: "Information types", path: "/information-types", icon: FileText, permissions: ["documents.view"] }, { label: "Global values", path: "/global-values", icon: SlidersHorizontal, permissions: ["global-values.view"] }, - { label: "Notifiers", path: "/notifiers", icon: BellRing, permissions: ["notifiers.view"] }, { label: "Work groups", path: "/work-groups", icon: Layers, permissions: ["workgroups.view"] }, { label: "Retry policies", path: "/retry-policies", icon: RotateCcw, permissions: ["retry-policies.view"] }, + // Directly under retry policies: both are about what happens when something goes + // wrong, and a budget-exhausted alert is delivered by a notifier. + { label: "Notifiers", path: "/notifiers", icon: BellRing, permissions: ["notifiers.view"] }, ], }, { From c6d4c1634db8fa5952bf0943bc32cd53f6675241 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 23 Aug 2026 13:33:57 +0300 Subject: [PATCH 42/54] feat: name a list by its count, with the names one click away --- .../src/components/config/shared.tsx | 288 ++++++++++++------ 1 file changed, 202 insertions(+), 86 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx index 160ee642..c710e0ac 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx @@ -33,7 +33,8 @@ export const INTEGRATION_TYPE_LABELS: Record = { Aggregation: "Aggregation", }; -export const isLegacyType = (type: IntegrationType) => type === "Internal" || type === "ApiCall"; +export const isLegacyType = (type: IntegrationType) => + type === "Internal" || type === "ApiCall"; export function TypeBadge({ type }: { type: IntegrationType }) { return ( @@ -45,7 +46,13 @@ export function TypeBadge({ type }: { type: IntegrationType }) { } /** Enabled/paused pair — an integration can be both enabled and paused. */ -export function IntegrationStatusBadges({ enabled, paused }: { enabled: boolean; paused: boolean }) { +export function IntegrationStatusBadges({ + enabled, + paused, +}: { + enabled: boolean; + paused: boolean; +}) { return ( {enabled ? Active : Disabled} @@ -78,31 +85,36 @@ export function scheduleFault( return { label: "Not scheduled", tone: "danger", - title: "The scheduler has no trigger for this schedule — it will never fire.", + title: + "The scheduler has no trigger for this schedule — it will never fire.", }; case "Error": return { label: "Trigger error", tone: "danger", - title: "The scheduler put this trigger in an error state; it will not fire again until fixed.", + title: + "The scheduler put this trigger in an error state; it will not fire again until fixed.", }; case "Paused": return { label: "Trigger paused", tone: "warn", - title: "Paused inside the scheduler — this is not the integration's own pause.", + title: + "Paused inside the scheduler — this is not the integration's own pause.", }; case "Blocked": return { label: "Blocked", tone: "warn", - title: "A previous run is still going and this job doesn't allow overlap, so fires are being held.", + title: + "A previous run is still going and this job doesn't allow overlap, so fires are being held.", }; case "Complete": return { label: "Schedule ended", tone: "warn", - title: "The schedule has run to completion and has no future fire times.", + title: + "The schedule has run to completion and has no future fire times.", }; default: return null; @@ -126,7 +138,11 @@ export function HealthBadge({ return Idle; } -export function ExchangeStatusBadge({ status }: { status: ExchangeRef["status"] }) { +export function ExchangeStatusBadge({ + status, +}: { + status: ExchangeRef["status"]; +}) { if (status === "success") return Success; if (status === "failed") return Failed; if (status === "badResponse") return Bad response; @@ -171,7 +187,10 @@ export function PromotedProps({ title={entries.map(([k, v]) => `${k}=${v}`).join("\n")} > {shown.map(([k, v]) => ( - + {k}= {v} @@ -219,10 +238,17 @@ export function ExchangesList({ return ( 0 ? `${x.id}\n\n${properties.map(([k, v]) => `${k}=${v}`).join("\n")}` : x.id} + title={ + properties.length > 0 + ? `${x.id}\n\n${properties.map(([k, v]) => `${k}=${v}`).join("\n")}` + : x.id + } className="block hover:opacity-70" > - + ); }, @@ -233,7 +259,9 @@ export function ExchangesList({ { header: "Type", cell: (x: ExchangeRef) => ( - {x.informationTypeCode} + + {x.informationTypeCode} + ), }, ]), @@ -243,20 +271,34 @@ export function ExchangesList({ { header: "Partner", cell: (x: ExchangeRef) => ( - {x.partnerName ?? "—"} + + {x.partnerName ?? "—"} + ), }, ]), - { header: "Status", cell: (x: ExchangeRef) => }, + { + header: "Status", + cell: (x: ExchangeRef) => , + }, { header: "When", align: "right" as const, className: "whitespace-nowrap", - cell: (x: ExchangeRef) => {timeAgo(x.on)}, + cell: (x: ExchangeRef) => ( + {timeAgo(x.on)} + ), }, ]; - return x.id} empty="No exchanges yet." columns={columns} />; + return ( + x.id} + empty="No exchanges yet." + columns={columns} + /> + ); } /** Integrations referencing this entity, each linking to its page. */ @@ -279,7 +321,11 @@ export function SetupList({ items }: { items: IntegrationSetupRef[] }) { ), }, - { header: "Type", align: "right", cell: (s) => }, + { + header: "Type", + align: "right", + cell: (s) => , + }, ]} /> ); @@ -311,9 +357,17 @@ export function usePartnerIntegrations(): Map { const canSeeApi = useSessionCan("api-gateways.view"); const canSeeBus = useSessionCan("bus-gateways.view"); const apiGateways = - useQuery({ queryKey: ["api-gateways"], queryFn: () => api.listApiGateways(), enabled: canSeeApi }).data ?? []; + useQuery({ + queryKey: ["api-gateways"], + queryFn: () => api.listApiGateways(), + enabled: canSeeApi, + }).data ?? []; const busGateways = - useQuery({ queryKey: ["bus-gateways"], queryFn: () => api.listBusGateways(), enabled: canSeeBus }).data ?? []; + useQuery({ + queryKey: ["bus-gateways"], + queryFn: () => api.listBusGateways(), + enabled: canSeeBus, + }).data ?? []; return useMemo(() => { const byId = new Map(integrations.map((s) => [s.id, s])); @@ -328,9 +382,12 @@ export function usePartnerIntegrations(): Map { out.set(partnerId, list); } }; - for (const s of integrations) for (const pid of s.partnerIds) add(pid, s.id); - for (const g of apiGateways) for (const a of g.attachments) add(a.partnerId, a.integrationId); - for (const g of busGateways) for (const r of g.routes) add(r.partnerId, r.integrationId); + for (const s of integrations) + for (const pid of s.partnerIds) add(pid, s.id); + for (const g of apiGateways) + for (const a of g.attachments) add(a.partnerId, a.integrationId); + for (const g of busGateways) + for (const r of g.routes) add(r.partnerId, r.integrationId); return out; }, [integrations, apiGateways, busGateways]); } @@ -343,17 +400,32 @@ export function usePartnerIntegrations(): Map { * the modern types never have — without this, every gateway-fed integration * shows a dash where its partner should be. */ -export function useGatewayPartners(): Map { +export function useGatewayPartners(): Map< + number, + { id: number; name: string }[] +> { const canSeeApi = useSessionCan("api-gateways.view"); const canSeeBus = useSessionCan("bus-gateways.view"); const apiGateways = - useQuery({ queryKey: ["api-gateways"], queryFn: () => api.listApiGateways(), enabled: canSeeApi }).data ?? []; + useQuery({ + queryKey: ["api-gateways"], + queryFn: () => api.listApiGateways(), + enabled: canSeeApi, + }).data ?? []; const busGateways = - useQuery({ queryKey: ["bus-gateways"], queryFn: () => api.listBusGateways(), enabled: canSeeBus }).data ?? []; + useQuery({ + queryKey: ["bus-gateways"], + queryFn: () => api.listBusGateways(), + enabled: canSeeBus, + }).data ?? []; return useMemo(() => { const out = new Map(); - const add = (integrationId: number, partnerId: number | null, partnerName: string | null) => { + const add = ( + integrationId: number, + partnerId: number | null, + partnerName: string | null, + ) => { if (partnerId === null || partnerName === null) return; const list = out.get(integrationId) ?? []; if (!list.some((p) => p.id === partnerId)) { @@ -361,15 +433,23 @@ export function useGatewayPartners(): Map { - const rows = useQuery({ queryKey: ["integration-rows"], queryFn: () => api.listIntegrationRows() }).data ?? []; + const rows = + useQuery({ + queryKey: ["integration-rows"], + queryFn: () => api.listIntegrationRows(), + }).data ?? []; return useMemo(() => new Map(rows.map((r) => [r.id, r])), [rows]); } @@ -377,7 +457,11 @@ export function useIntegrationRowsById(): Map { export function useWorkGroupNames(): Map { const canSee = useSessionCan("workgroups.view"); const groups = - useQuery({ queryKey: ["work-groups"], queryFn: () => api.listWorkGroups(), enabled: canSee }).data ?? []; + useQuery({ + queryKey: ["work-groups"], + queryFn: () => api.listWorkGroups(), + enabled: canSee, + }).data ?? []; return useMemo(() => new Map(groups.map((g) => [g.id, g.name])), [groups]); } @@ -385,8 +469,15 @@ export function useWorkGroupNames(): Map { export function useRetryPolicyNames(): Map { const canSee = useSessionCan("retry-policies.view"); const policies = - useQuery({ queryKey: ["retry-policies"], queryFn: () => api.listRetryPolicies(), enabled: canSee }).data ?? []; - return useMemo(() => new Map(policies.map((p) => [p.id, p.name])), [policies]); + useQuery({ + queryKey: ["retry-policies"], + queryFn: () => api.listRetryPolicies(), + enabled: canSee, + }).data ?? []; + return useMemo( + () => new Map(policies.map((p) => [p.id, p.name])), + [policies], + ); } /** @@ -394,7 +485,13 @@ export function useRetryPolicyNames(): Map { * reporting — it is only as healthy as the pipelines behind it, and "3 partners * attached" says nothing about whether any of them currently works. */ -export function WiredHealthBadge({ rows, empty }: { rows: IntegrationRow[]; empty: string }) { +export function WiredHealthBadge({ + rows, + empty, +}: { + rows: IntegrationRow[]; + empty: string; +}) { if (rows.length === 0) return {empty}; const failing = rows.filter((r) => r.consecutiveFailures > 0).length; if (failing > 0) return {failing} failing; @@ -424,7 +521,10 @@ export function useWiredIntegrationColumns( ): Column[] { const rowsById = useIntegrationRowsById(); const setups = useIntegrationsCache().data ?? []; - const setupById = useMemo(() => new Map(setups.map((s) => [s.id, s])), [setups]); + const setupById = useMemo( + () => new Map(setups.map((s) => [s.id, s])), + [setups], + ); const workGroupNames = useWorkGroupNames(); const retryPolicyNames = useRetryPolicyNames(); const canSeeInfoTypes = useSessionCan("documents.view"); @@ -445,7 +545,9 @@ export function useWiredIntegrationColumns( {r.informationTypeCode} ) : ( - {r.informationTypeCode} + + {r.informationTypeCode} + ); }, }); @@ -459,10 +561,14 @@ export function useWiredIntegrationColumns( // lane, it's `WorkGroup.None` — a real shared queue (`0Ungrouped`) that // every ungrouped integration competes in. Matches the wording the // integration page's work-group picker already uses. - if (id === null) return Ungrouped; + if (id === null) + return Ungrouped; const name = workGroupNames.get(id); return name ? ( - + {name} ) : ( @@ -474,10 +580,14 @@ export function useWiredIntegrationColumns( header: "Retry policy", cell: (row) => { const id = setupById.get(integrationIdOf(row))?.retryPolicyId ?? null; - if (id === null) return None; + if (id === null) + return None; const name = retryPolicyNames.get(id); return name ? ( - + {name} ) : ( @@ -493,7 +603,10 @@ export function useWiredIntegrationColumns( return ( - + ); }, @@ -506,7 +619,10 @@ export function useWiredIntegrationColumns( cell: (row) => { const message = rowsById.get(integrationIdOf(row))?.lastException; return message ? ( - + {message} ) : ( @@ -540,66 +656,59 @@ export interface CellLink { export function LinkListCell({ items, label, - max = 2, }: { items: CellLink[]; /** Plural noun for the popover heading, e.g. "integrations". */ label: string; - max?: number; }) { if (items.length === 0) return ; - const shown = items.slice(0, max); - const rest = items.length - shown.length; + // "1 integrations" reads as a bug, and every label here is a simple plural. + const noun = items.length === 1 ? label.replace(/s$/, "") : label; return ( - s.name).join(", ")}> - {shown.map((s, i) => ( - - {i > 0 && , } - e.stopPropagation()} - className="text-ink-700 hover:text-crimson-700 hover:underline" - > - {s.name} - + s.name).join(", ")} + > + {items.length} - ))} - - {rest > 0 && ( - +{rest} more} - > -

- {items.length} {label} -

-
    - {items.map((s) => ( -
  • - - {s.name} - {s.note} - -
  • - ))} -
- - )} + } + > +

+ {items.length} {noun} +

+
    + {items.map((s) => ( +
  • + + + {s.name} + + {s.note} + +
  • + ))} +
+
); } /** `LinkListCell` for the commonest case: the integrations using something. */ -export function UsedByCell({ items, max = 2 }: { items: IntegrationInfo[]; max?: number }) { +export function UsedByCell({ items }: { items: IntegrationInfo[] }) { return ( ({ key: s.id, name: s.name, @@ -618,7 +727,12 @@ export function TrailTable({ entries }: { entries: TrailEntry[] }) { rowKey={(e) => e.i} empty="Nothing recorded yet." columns={[ - { header: "Action", cell: (e) => {e.action} }, + { + header: "Action", + cell: (e) => ( + {e.action} + ), + }, { header: "By", truncate: true, @@ -638,7 +752,9 @@ export function TrailTable({ entries }: { entries: TrailEntry[] }) { header: "When", align: "right", className: "whitespace-nowrap", - cell: (e) => {formatDate(e.on)}, + cell: (e) => ( + {formatDate(e.on)} + ), }, ]} /> From 34933e7262d7851138e43d38396dde71726871c3 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 23 Aug 2026 13:38:19 +0300 Subject: [PATCH 43/54] feat: keep the name when there is one, count the noun when there are more --- .../src/components/config/shared.tsx | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx index c710e0ac..6c9221c9 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx @@ -662,27 +662,42 @@ export function LinkListCell({ label: string; }) { if (items.length === 0) return ; - // "1 integrations" reads as a bug, and every label here is a simple plural. - const noun = items.length === 1 ? label.replace(/s$/, "") : label; + + // One of something is just that thing. A chip reading "1" would cost the name and + // buy a popover with a single row in it. + if (items.length === 1) { + const only = items[0]; + return ( + e.stopPropagation()} + title={only.name} + className="block truncate text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" + > + {only.name} + + ); + } + return ( s.name).join(", ")} > - {items.length} + {items.length} {label} } >

- {items.length} {noun} + {items.length} {label}

    {items.map((s) => ( From 9eebcee7211b3702dfd8699afd605ea69524b8f8 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 23 Aug 2026 13:51:28 +0300 Subject: [PATCH 44/54] feat: turn a gateway off without deleting it --- .../Controllers/GatewayController.cs | 12 + SW.Bitween.Api/Domain/Gateway/ApiGateway.cs | 7 + SW.Bitween.Api/Domain/Gateway/BusGateway.cs | 6 + .../Resources/ApiGateways/Create.cs | 3 +- SW.Bitween.Api/Resources/ApiGateways/Get.cs | 1 + .../Resources/ApiGateways/Search.cs | 1 + .../Resources/ApiGateways/Update.cs | 1 + .../Resources/BusGateways/Create.cs | 3 +- SW.Bitween.Api/Resources/BusGateways/Get.cs | 1 + .../Resources/BusGateways/Search.cs | 1 + .../Resources/BusGateways/Update.cs | 1 + .../Services/Caching/InMemoryInfolinkCache.cs | 5 +- ...0823104233_GatewayInactiveFlag.Designer.cs | 2104 ++++++++++++++ .../20260823104233_GatewayInactiveFlag.cs | 40 + .../BitweenDbContextModelSnapshot.cs | 6 + ...0823104220_GatewayInactiveFlag.Designer.cs | 2097 ++++++++++++++ .../20260823104220_GatewayInactiveFlag.cs | 40 + .../BitweenDbContextModelSnapshot.cs | 6 + ...0823104201_GatewayInactiveFlag.Designer.cs | 2413 +++++++++++++++++ .../20260823104201_GatewayInactiveFlag.cs | 44 + .../BitweenDbContextModelSnapshot.cs | 8 + SW.Bitween.Sdk/Model/ApiGateway.cs | 3 + SW.Bitween.Sdk/Model/BusGateway.cs | 3 + SW.Bitween.Web/ClientApp/src/api/client.ts | 7 +- .../ClientApp/src/api/http/gateways.ts | 52 +- SW.Bitween.Web/ClientApp/src/api/types.ts | 4 + .../src/pages/api-gateways/ApiGatewayPage.tsx | 64 +- .../pages/api-gateways/ApiGatewaysPage.tsx | 12 +- .../src/pages/bus-gateways/BusGatewayPage.tsx | 43 +- .../pages/bus-gateways/BusGatewaysPage.tsx | 12 +- 30 files changed, 6971 insertions(+), 29 deletions(-) create mode 100644 SW.Bitween.MsSql/Migrations/20260823104233_GatewayInactiveFlag.Designer.cs create mode 100644 SW.Bitween.MsSql/Migrations/20260823104233_GatewayInactiveFlag.cs create mode 100644 SW.Bitween.MySql/Migrations/20260823104220_GatewayInactiveFlag.Designer.cs create mode 100644 SW.Bitween.MySql/Migrations/20260823104220_GatewayInactiveFlag.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260823104201_GatewayInactiveFlag.Designer.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260823104201_GatewayInactiveFlag.cs diff --git a/SW.Bitween.Api/Controllers/GatewayController.cs b/SW.Bitween.Api/Controllers/GatewayController.cs index 2debf48e..a4372580 100644 --- a/SW.Bitween.Api/Controllers/GatewayController.cs +++ b/SW.Bitween.Api/Controllers/GatewayController.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net.Mime; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain; @@ -56,6 +57,17 @@ private async Task ProcessAsync([FromRoute] string gatewayApiName if (apiGatewayPartner == null) return Unauthorized(); + // After authorisation on purpose: whether a gateway exists and is switched off is + // something only an attached partner should learn — checking it earlier would + // answer that for anyone who guessed the url. + // + // 503 rather than 404 because the url is right and the partner should keep it: a + // 404 reads as "wrong address" and sends someone hunting for a new one, where this + // is a gateway somebody switched off and will switch back on. + if (apiGateway.Inactive) + return StatusCode(StatusCodes.Status503ServiceUnavailable, + $"The '{apiGateway.Name}' gateway is currently deactivated."); + var subscription = await cache.SubscriptionByIdAsync(apiGatewayPartner.SubscriptionId); if (subscription == null) diff --git a/SW.Bitween.Api/Domain/Gateway/ApiGateway.cs b/SW.Bitween.Api/Domain/Gateway/ApiGateway.cs index 7f304f7f..4f1aef35 100644 --- a/SW.Bitween.Api/Domain/Gateway/ApiGateway.cs +++ b/SW.Bitween.Api/Domain/Gateway/ApiGateway.cs @@ -8,6 +8,13 @@ public class ApiGateway : BaseEntity,IAudited { public string Name { get; set; } public string UrlName { get; set; } + + /// + /// Turns the gateway off without deleting it. Deleting is the only alternative today, + /// and it takes the partner attachments with it — so a gateway that needs stopping for + /// an afternoon gets rebuilt by hand afterwards, or left running. + /// + public bool Inactive { get; set; } public ICollection Partners { get; set; } public DateTime CreatedOn { get; set; } public string CreatedBy { get; set; } diff --git a/SW.Bitween.Api/Domain/Gateway/BusGateway.cs b/SW.Bitween.Api/Domain/Gateway/BusGateway.cs index d2c83d41..0e61eaa5 100644 --- a/SW.Bitween.Api/Domain/Gateway/BusGateway.cs +++ b/SW.Bitween.Api/Domain/Gateway/BusGateway.cs @@ -8,6 +8,12 @@ public class BusGateway : BaseEntity, IAudited { public string Name { get; set; } public int DocumentId { get; set; } + + /// + /// Turns the gateway off without deleting it — its routes stop being offered the + /// message. See . + /// + public bool Inactive { get; set; } public ICollection Routes { get; set; } public DateTime CreatedOn { get; set; } public string CreatedBy { get; set; } diff --git a/SW.Bitween.Api/Resources/ApiGateways/Create.cs b/SW.Bitween.Api/Resources/ApiGateways/Create.cs index cca8abb6..4462994c 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/Create.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/Create.cs @@ -25,7 +25,8 @@ public async Task Handle(ApiGatewayCreate model) var entity = new ApiGateway { Name = model.Name, - UrlName = model.UrlName + UrlName = model.UrlName, + Inactive = model.Inactive }; _dbContext.Add(entity); diff --git a/SW.Bitween.Api/Resources/ApiGateways/Get.cs b/SW.Bitween.Api/Resources/ApiGateways/Get.cs index 6d8ce6aa..23397cea 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/Get.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/Get.cs @@ -38,6 +38,7 @@ public async Task Handle(int key) Id = gateway.Id, Name = gateway.Name, UrlName = gateway.UrlName, + Inactive = gateway.Inactive, PartnersCount = gateway.Partners.Count, Partners = gateway.Partners.Select(p => new ApiGatewayPartnerDto { diff --git a/SW.Bitween.Api/Resources/ApiGateways/Search.cs b/SW.Bitween.Api/Resources/ApiGateways/Search.cs index 12db87cf..ca00a616 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/Search.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/Search.cs @@ -32,6 +32,7 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa Id = gateway.Id, Name = gateway.Name, UrlName = gateway.UrlName, + Inactive = gateway.Inactive, PartnersCount = gateway.Partners.Count }; diff --git a/SW.Bitween.Api/Resources/ApiGateways/Update.cs b/SW.Bitween.Api/Resources/ApiGateways/Update.cs index 1c7e0bb8..489cc936 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/Update.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/Update.cs @@ -34,6 +34,7 @@ public async Task Handle(int key, ApiGatewayUpdate model) entity.Name = model.Name; entity.UrlName = model.UrlName; + entity.Inactive = model.Inactive; await _dbContext.SaveChangesAsync(); return null; diff --git a/SW.Bitween.Api/Resources/BusGateways/Create.cs b/SW.Bitween.Api/Resources/BusGateways/Create.cs index ddf94b56..fb85fa61 100644 --- a/SW.Bitween.Api/Resources/BusGateways/Create.cs +++ b/SW.Bitween.Api/Resources/BusGateways/Create.cs @@ -32,7 +32,8 @@ public async Task Handle(BusGatewayCreate model) var entity = new BusGateway { Name = model.Name, - DocumentId = model.DocumentId + DocumentId = model.DocumentId, + Inactive = model.Inactive }; _dbContext.Add(entity); diff --git a/SW.Bitween.Api/Resources/BusGateways/Get.cs b/SW.Bitween.Api/Resources/BusGateways/Get.cs index b9faeff7..ccaf6aa8 100644 --- a/SW.Bitween.Api/Resources/BusGateways/Get.cs +++ b/SW.Bitween.Api/Resources/BusGateways/Get.cs @@ -44,6 +44,7 @@ public async Task Handle(int key) Id = gateway.Id, Name = gateway.Name, DocumentId = gateway.DocumentId, + Inactive = gateway.Inactive, DocumentName = documentName, RoutesCount = gateway.Routes.Count, Routes = gateway.Routes.Select(r => new BusGatewayRouteDto diff --git a/SW.Bitween.Api/Resources/BusGateways/Search.cs b/SW.Bitween.Api/Resources/BusGateways/Search.cs index 62d8442c..d59daa5b 100644 --- a/SW.Bitween.Api/Resources/BusGateways/Search.cs +++ b/SW.Bitween.Api/Resources/BusGateways/Search.cs @@ -35,6 +35,7 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa Id = gateway.Id, Name = gateway.Name, DocumentId = gateway.DocumentId, + Inactive = gateway.Inactive, DocumentName = documents.Where(d => d.Id == gateway.DocumentId) .Select(d => d.Name).FirstOrDefault(), RoutesCount = gateway.Routes.Count diff --git a/SW.Bitween.Api/Resources/BusGateways/Update.cs b/SW.Bitween.Api/Resources/BusGateways/Update.cs index 1235636f..51b0dcaa 100644 --- a/SW.Bitween.Api/Resources/BusGateways/Update.cs +++ b/SW.Bitween.Api/Resources/BusGateways/Update.cs @@ -32,6 +32,7 @@ public async Task Handle(int key, BusGatewayUpdate model) // Name only; the bound document is fixed at creation (routes' subscriptions belong to it). entity.Name = model.Name; + entity.Inactive = model.Inactive; await _dbContext.SaveChangesAsync(); await _cache.BroadcastRevoke(); diff --git a/SW.Bitween.Api/Services/Caching/InMemoryInfolinkCache.cs b/SW.Bitween.Api/Services/Caching/InMemoryInfolinkCache.cs index 61dd86e7..df5be0f7 100644 --- a/SW.Bitween.Api/Services/Caching/InMemoryInfolinkCache.cs +++ b/SW.Bitween.Api/Services/Caching/InMemoryInfolinkCache.cs @@ -71,8 +71,11 @@ public async Task ListBusGatewayRoutesByDocumentAsync(int doc cachedBusGateways = _cache.Get(nameof(BusGateway)); } + // A deactivated gateway offers no routes, which is the whole of what deactivating + // one means on the bus side: the message still publishes, this gateway just stops + // being one of the places it lands. return cachedBusGateways - .Where(g => g.DocumentId == documentId) + .Where(g => g.DocumentId == documentId && !g.Inactive) .SelectMany(g => g.Routes ?? Enumerable.Empty()) .ToArray(); } diff --git a/SW.Bitween.MsSql/Migrations/20260823104233_GatewayInactiveFlag.Designer.cs b/SW.Bitween.MsSql/Migrations/20260823104233_GatewayInactiveFlag.Designer.cs new file mode 100644 index 00000000..7f28c1c5 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260823104233_GatewayInactiveFlag.Designer.cs @@ -0,0 +1,2104 @@ +// +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("20260823104233_GatewayInactiveFlag")] + partial class GatewayInactiveFlag + { + /// + 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.HasSequence("DocumentIds"); + + 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("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.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + 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.Accounts.Role", 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("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Permissions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + 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") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValueSql("NEXT VALUE FOR [DocumentIds]"); + + SqlServerPropertyBuilderExtensions.UseSequence(b.Property("Id"), "DocumentIds"); + + b.Property("BusEnabled") + .HasColumnType("bit"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + 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("Code") + .IsUnique() + .HasFilter("[Code] 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("Inactive") + .HasColumnType("bit"); + + 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("Inactive") + .HasColumnType("bit"); + + 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.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Settings", (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("ManualRetry") + .HasColumnType("bit"); + + 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.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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/20260823104233_GatewayInactiveFlag.cs b/SW.Bitween.MsSql/Migrations/20260823104233_GatewayInactiveFlag.cs new file mode 100644 index 00000000..129b1d34 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260823104233_GatewayInactiveFlag.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class GatewayInactiveFlag : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Inactive", + table: "BusGateways", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Inactive", + table: "ApiGateways", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Inactive", + table: "BusGateways"); + + migrationBuilder.DropColumn( + name: "Inactive", + table: "ApiGateways"); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs index 3b072f70..a30467a7 100644 --- a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -346,6 +346,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedOn") .HasColumnType("datetime2"); + b.Property("Inactive") + .HasColumnType("bit"); + b.Property("ModifiedBy") .HasColumnType("nvarchar(max)"); @@ -419,6 +422,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DocumentId") .HasColumnType("int"); + b.Property("Inactive") + .HasColumnType("bit"); + b.Property("ModifiedBy") .HasColumnType("nvarchar(max)"); diff --git a/SW.Bitween.MySql/Migrations/20260823104220_GatewayInactiveFlag.Designer.cs b/SW.Bitween.MySql/Migrations/20260823104220_GatewayInactiveFlag.Designer.cs new file mode 100644 index 00000000..217041ea --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260823104220_GatewayInactiveFlag.Designer.cs @@ -0,0 +1,2097 @@ +// +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("20260823104220_GatewayInactiveFlag")] + partial class GatewayInactiveFlag + { + /// + 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("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.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + 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.Accounts.Role", 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("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsSystem") + .HasColumnType("tinyint(1)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + 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") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + 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("Code") + .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("Inactive") + .HasColumnType("tinyint(1)"); + + 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("Inactive") + .HasColumnType("tinyint(1)"); + + 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.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Settings", (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("ManualRetry") + .HasColumnType("tinyint(1)"); + + 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.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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/20260823104220_GatewayInactiveFlag.cs b/SW.Bitween.MySql/Migrations/20260823104220_GatewayInactiveFlag.cs new file mode 100644 index 00000000..30574d06 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260823104220_GatewayInactiveFlag.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class GatewayInactiveFlag : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Inactive", + table: "BusGateways", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Inactive", + table: "ApiGateways", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Inactive", + table: "BusGateways"); + + migrationBuilder.DropColumn( + name: "Inactive", + table: "ApiGateways"); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs index f0bcfaed..f8970906 100644 --- a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs @@ -340,6 +340,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedOn") .HasColumnType("datetime(6)"); + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + b.Property("ModifiedBy") .HasColumnType("longtext"); @@ -413,6 +416,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DocumentId") .HasColumnType("int"); + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + b.Property("ModifiedBy") .HasColumnType("longtext"); diff --git a/SW.Bitween.PgSql/Migrations/20260823104201_GatewayInactiveFlag.Designer.cs b/SW.Bitween.PgSql/Migrations/20260823104201_GatewayInactiveFlag.Designer.cs new file mode 100644 index 00000000..dccb27e0 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260823104201_GatewayInactiveFlag.Designer.cs @@ -0,0 +1,2413 @@ +// +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("20260823104201_GatewayInactiveFlag")] + partial class GatewayInactiveFlag + { + /// + 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("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.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("RoleId") + .HasColumnType("integer") + .HasColumnName("role_id"); + + b.HasKey("AccountId", "RoleId") + .HasName("pk_account_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_account_roles_role_id"); + + b.ToTable("AccountRoles", "infolink"); + }); + + 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.Accounts.Role", 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("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_roles_name"); + + b.ToTable("Roles", "infolink"); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + 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") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("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("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + 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("Code") + .IsUnique() + .HasDatabaseName("ix_document_code"); + + 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("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + 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("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + 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.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("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("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_settings"); + + b.ToTable("Settings", "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("ManualRetry") + .HasColumnType("boolean") + .HasColumnName("manual_retry"); + + 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.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_accounts_account_id"); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_roles_role_id"); + }); + + 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/20260823104201_GatewayInactiveFlag.cs b/SW.Bitween.PgSql/Migrations/20260823104201_GatewayInactiveFlag.cs new file mode 100644 index 00000000..459832fe --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260823104201_GatewayInactiveFlag.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + public partial class GatewayInactiveFlag : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "inactive", + schema: "infolink", + table: "bus_gateway", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "inactive", + schema: "infolink", + table: "api_gateway", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "inactive", + schema: "infolink", + table: "bus_gateway"); + + migrationBuilder.DropColumn( + name: "inactive", + schema: "infolink", + table: "api_gateway"); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs index 64cafda9..daf3df1d 100644 --- a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -406,6 +406,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("created_on"); + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + b.Property("ModifiedBy") .HasColumnType("text") .HasColumnName("modified_by"); @@ -499,6 +503,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("document_id"); + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + b.Property("ModifiedBy") .HasColumnType("text") .HasColumnName("modified_by"); diff --git a/SW.Bitween.Sdk/Model/ApiGateway.cs b/SW.Bitween.Sdk/Model/ApiGateway.cs index dfb8a562..7ebdf0b9 100644 --- a/SW.Bitween.Sdk/Model/ApiGateway.cs +++ b/SW.Bitween.Sdk/Model/ApiGateway.cs @@ -7,6 +7,9 @@ public class ApiGatewayCreate : IName { public string Name { get; set; } public string UrlName { get; set; } + + /// Off but kept, with its partner attachments. Calls to it are refused. + public bool Inactive { get; set; } } public class ApiGatewayRow : ApiGatewayUpdate diff --git a/SW.Bitween.Sdk/Model/BusGateway.cs b/SW.Bitween.Sdk/Model/BusGateway.cs index 9240b24a..745ff918 100644 --- a/SW.Bitween.Sdk/Model/BusGateway.cs +++ b/SW.Bitween.Sdk/Model/BusGateway.cs @@ -7,6 +7,9 @@ public class BusGatewayCreate : IName { public string Name { get; set; } public int DocumentId { get; set; } + + /// Off but kept, with its routes. Messages stop reaching them. + public bool Inactive { get; set; } } public class BusGatewayUpdate : BusGatewayCreate diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index f76ddeb5..8878f362 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -231,7 +231,10 @@ export interface ApiClient { listApiGateways(): Promise; getApiGateway(id: number): Promise; createApiGateway(input: { name: string; urlName: string }): Promise; - updateApiGateway(id: number, changes: { name: string; urlName: string }): Promise; + updateApiGateway( + id: number, + changes: { name: string; urlName: string; inactive: boolean }, + ): Promise; deleteApiGateway(id: number): Promise; attachGatewayPartner(id: number, input: { partnerId: number; integrationId: number }): Promise; updateGatewayAttachment(id: number, input: { partnerId: number; integrationId: number }): Promise; @@ -241,7 +244,7 @@ export interface ApiClient { listBusGateways(): Promise; getBusGateway(id: number): Promise; createBusGateway(input: { name: string; informationTypeId: number }): Promise; - updateBusGateway(id: number, changes: { name: string }): Promise; + updateBusGateway(id: number, changes: { name: string; inactive: boolean }): Promise; deleteBusGateway(id: number): Promise; addBusRoute( id: number, diff --git a/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts b/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts index 0c5d25b7..9f35a7ac 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts @@ -29,6 +29,7 @@ interface RawApiGateway { name: string; urlName: string; partnersCount: number | null; + inactive: boolean | null; // Search's list projection includes this too (backend change made alongside // this batch) — but keep it optional since Create's bare POST response has none. partners: RawApiGatewayPartner[] | null; @@ -47,6 +48,7 @@ interface RawBusGateway { documentId: number; documentName: string | null; routesCount: number | null; + inactive: boolean | null; routes: RawBusGatewayRoute[] | null; } @@ -61,6 +63,7 @@ const toApiGatewayRow = (raw: RawApiGateway): ApiGatewayRow => ({ id: raw.id, name: raw.name, urlName: raw.urlName, + inactive: raw.inactive ?? false, createdOn: "", partnerCount: raw.partnersCount ?? raw.partners?.length ?? 0, attachments: (raw.partners ?? []).map(toApiGatewayAttachment), @@ -70,6 +73,7 @@ const toApiGatewayDetail = (raw: RawApiGateway): ApiGatewayDetail => ({ id: raw.id, name: raw.name, urlName: raw.urlName, + inactive: raw.inactive ?? false, createdOn: "", attachments: (raw.partners ?? []).map(toApiGatewayAttachment), }); @@ -87,6 +91,7 @@ const toBusGatewayRow = (raw: RawBusGateway): BusGatewayRow => ({ id: raw.id, name: raw.name, informationTypeId: raw.documentId, + inactive: raw.inactive ?? false, createdOn: "", informationTypeCode: raw.documentName ?? "UNKNOWN", routeCount: raw.routesCount ?? raw.routes?.length ?? 0, @@ -97,6 +102,7 @@ const toBusGatewayDetail = (raw: RawBusGateway): BusGatewayDetail => ({ id: raw.id, name: raw.name, informationTypeId: raw.documentId, + inactive: raw.inactive ?? false, createdOn: "", informationTypeCode: raw.documentName ?? "UNKNOWN", informationTypeName: raw.documentName ?? "Unknown", @@ -116,13 +122,22 @@ export const gatewayMethods = { }, async createApiGateway({ name, urlName }: { name: string; urlName: string }): Promise { - const id = await post("/apigateways", { name, urlName }); - return { id, name, urlName, createdOn: "" }; + const id = await post("/apigateways", { name, urlName, inactive: false }); + return { id, name, urlName, inactive: false, createdOn: "" }; }, - async updateApiGateway(id: number, changes: { name: string; urlName: string }): Promise { - await post(`/apigateways/${id}`, { name: changes.name, urlName: changes.urlName }); - return { id, name: changes.name, urlName: changes.urlName, createdOn: "" }; + async updateApiGateway( + id: number, + changes: { name: string; urlName: string; inactive: boolean }, + ): Promise { + // Update replaces the record, so every field it accepts has to be sent back — + // omitting `inactive` would quietly reactivate a paused gateway on a rename. + await post(`/apigateways/${id}`, { + name: changes.name, + urlName: changes.urlName, + inactive: changes.inactive, + }); + return { id, ...changes, createdOn: "" }; }, async deleteApiGateway(id: number): Promise { @@ -164,17 +179,34 @@ export const gatewayMethods = { name: string; informationTypeId: number; }): Promise { - const id = await post("/busgateways", { name, documentId: informationTypeId }); - return { id, name, informationTypeId, createdOn: "" }; + const id = await post("/busgateways", { + name, + documentId: informationTypeId, + inactive: false, + }); + return { id, name, informationTypeId, inactive: false, createdOn: "" }; }, - async updateBusGateway(id: number, changes: { name: string }): Promise { + async updateBusGateway( + id: number, + changes: { name: string; inactive: boolean }, + ): Promise { // The bound information type is fixed at creation — Update.cs silently // ignores documentId — but the request DTO still requires a value, so // fetch the current one to round-trip it rather than sending a bogus 0. const current = await get(`/busgateways/${id}`); - await post(`/busgateways/${id}`, { name: changes.name, documentId: current.documentId }); - return { id, name: changes.name, informationTypeId: current.documentId, createdOn: "" }; + await post(`/busgateways/${id}`, { + name: changes.name, + documentId: current.documentId, + inactive: changes.inactive, + }); + return { + id, + name: changes.name, + informationTypeId: current.documentId, + inactive: changes.inactive, + createdOn: "", + }; }, async deleteBusGateway(id: number): Promise { diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index d94a2aa8..63680587 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -632,6 +632,8 @@ export interface ApiGateway { id: number; name: string; urlName: string; + /** Off but kept, with its attachments. Partners calling it get a 503. */ + inactive: boolean; createdOn: string; } export interface ApiGatewayRow extends ApiGateway { @@ -657,6 +659,8 @@ export interface BusGateway { id: number; name: string; informationTypeId: number; + /** Off but kept, with its routes. The message stops being offered to them. */ + inactive: boolean; createdOn: string; } export interface BusGatewayRow extends BusGateway { diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx index 05ceaab6..5de1b31c 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx @@ -1,11 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate, useParams } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Pencil, Plus, Trash2 } from "lucide-react"; +import { Pause, Pencil, Play, Plus, Trash2 } from "lucide-react"; import { api, type ApiGatewayAttachment } from "../../api"; import { Can, useSessionCan } from "../../auth/guards"; import { finishUrlName, toUrlName } from "../../lib/identifiers"; -import { Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; +import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { Field, TextInput } from "../../components/ui/forms"; import { ConfirmDialog } from "../../components/ui/overlays"; import { CopyField } from "../../components/ui/CopyField"; @@ -32,6 +32,7 @@ export function ApiGatewayPage() { const [urlName, setUrlName] = useState(""); const [removing, setRemoving] = useState<{ partnerId: number; partnerName: string } | null>(null); const [deleting, setDeleting] = useState(false); + const [confirmingActive, setConfirmingActive] = useState(false); const [loaded, setLoaded] = useState(false); useEffect(() => { @@ -48,7 +49,14 @@ export function ApiGatewayPage() { ); const save = useMutation({ - mutationFn: () => api.updateApiGateway(gatewayId, { name, urlName: finishUrlName(urlName) }), + mutationFn: () => + api.updateApiGateway(gatewayId, { + name, + urlName: finishUrlName(urlName), + // Round-tripped, never edited here — Update replaces the record, so leaving it + // out would reactivate a deactivated gateway on an unrelated rename. + inactive: gateway.data?.inactive ?? false, + }), onSuccess: async () => { // Await the detail refetch before re-syncing the draft (avoids stale-data race). await queryClient.invalidateQueries({ queryKey: ["api-gateway", gatewayId] }); @@ -75,15 +83,31 @@ export function ApiGatewayPage() {
    -

    +

    + {g.inactive && Deactivated}

    - - - +
    + {canEdit && ( + + )} + + + +
    {/* Endpoint above rather than beside: the attachments table below carries a @@ -201,6 +225,28 @@ export function ApiGatewayPage() { /> )} + {confirmingActive && ( + { + await api.updateApiGateway(gatewayId, { + name: g.name, + urlName: g.urlName, + inactive: !g.inactive, + }); + await queryClient.invalidateQueries({ queryKey: ["api-gateway", gatewayId] }); + void queryClient.invalidateQueries({ queryKey: ["api-gateways"] }); + }} + onClose={() => setConfirmingActive(false)} + /> + )} + {deleting && ( {g.name}, + cell: (g) => ( + + + {g.name} + + {/* Beside the name, not in the Health column: health reports on what the + gateway feeds, and a deactivated one feeds nothing, so it would read + as healthy. */} + {g.inactive && Off} + + ), }, { header: "URL", diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx index 62fb4777..ea203557 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx @@ -1,10 +1,10 @@ import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate, useParams, useSearchParams } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { PanelLeftClose, PanelLeftOpen, Trash2 } from "lucide-react"; +import { Pause, PanelLeftClose, PanelLeftOpen, Play, Trash2 } from "lucide-react"; import { api, type BusGatewayDetail, type IntegrationDetail } from "../../api"; import { Can, useSessionCan } from "../../auth/guards"; -import { Button, EmptyState, FormError, LoadingBlock } from "../../components/ui/basics"; +import { Badge, Button, EmptyState, FormError, LoadingBlock } from "../../components/ui/basics"; import { ConfirmDialog, dialogsOpen } from "../../components/ui/overlays"; import { CodeBadge, EditableTitle } from "../../components/ui/Panel"; import { SearchSelect } from "../../components/ui/SearchSelect"; @@ -107,6 +107,7 @@ export function BusGatewayPage() { const [partnerDialog, setPartnerDialog] = useState(undefined); const [removingRoute, setRemovingRoute] = useState(null); const [deletingGateway, setDeletingGateway] = useState(false); + const [confirmingActive, setConfirmingActive] = useState(false); /** A move the user asked for that would drop unsaved edits. */ const [guarded, setGuarded] = useState void }>(null); @@ -280,7 +281,10 @@ export function BusGatewayPage() { const save = useMutation({ mutationFn: async () => { - if (nameDirty && name !== null) await api.updateBusGateway(gatewayId, { name }); + // `inactive` is round-tripped, not edited here: Update replaces the record, so + // omitting it would reactivate a deactivated gateway on a rename. + if (nameDirty && name !== null) + await api.updateBusGateway(gatewayId, { name, inactive: g.inactive }); // The integration first: if the route write then fails, what was saved is // the part that stands on its own. if (edit && intIsDirty) await api.updateIntegration(edit.integrationId, edit.draft); @@ -449,6 +453,7 @@ export function BusGatewayPage() { placeholder="Gateway name" /> + {g.inactive && Deactivated} {/* The information type, its bus message name, and whether it is even on the bus. This was a canvas node, but it is a property of the gateway, not of any one route — repeating it on every route's diagram said otherwise. */} @@ -496,6 +501,20 @@ export function BusGatewayPage() { Remove route )} + {canEdit && ( + + )}
- {c.isBackpressured && Backpressure} + {c.isBackpressured && ( + + Backpressure + + )}
diff --git a/SW.Bitween.Web/ClientApp/src/pages/work-groups/LiveQueueStats.tsx b/SW.Bitween.Web/ClientApp/src/pages/work-groups/LiveQueueStats.tsx index cb51dece..edd0f658 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/work-groups/LiveQueueStats.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/work-groups/LiveQueueStats.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { api } from "../../api"; import { Badge } from "../../components/ui/basics"; +import { queueHealthTitle } from "../../components/config/shared"; function LiveStat({ label, value, tone }: { label: string; value: ReactNode; tone?: "warn" | "danger" }) { return ( @@ -39,13 +40,20 @@ export function LiveQueueStats({ groupId }: { groupId: number }) {
{consumer.health === "critical" ? ( - Critical + Critical ) : consumer.health === "warning" ? ( - Warning + Warning ) : ( - Healthy + Healthy + )} + {consumer.isBackpressured && ( + + Backpressure + )} - {consumer.isBackpressured && Backpressure} {consumer.queueName}
From 022644fe7e5977aa973a660b4d4abf637c18c0be Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 24 Aug 2026 14:41:45 +0300 Subject: [PATCH 52/54] feat: page and filter every main table, and search gateway attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listX() calls across partners, information types, integrations, work groups, API/bus gateways, retry policies and notifiers fetched everything and filtered client-side — Work groups broke past 20 rows, and notifiers fetched every row's full detail one at a time to build a list. All nine now page and filter server-side through the existing Searchy endpoints, plus a new paged/searched endpoint for one gateway's attachments. Notifiers' search row also carries RunOnSubscriptions directly, closing the N+1 that full-detail fetch was covering for. --- .../ApiGateways/SearchAttachments.cs | 64 ++++++ SW.Bitween.Api/Resources/Notifiers/Search.cs | 3 +- SW.Bitween.Api/Resources/WorkGroups/Search.cs | 4 +- SW.Bitween.Sdk/Model/ApiGateway.cs | 8 + SW.Bitween.Sdk/Model/Notifier.cs | 2 + SW.Bitween.Sdk/Model/Workgroups.cs | 1 + SW.Bitween.Web/ClientApp/src/api/client.ts | 59 +++++- .../ClientApp/src/api/http/documents.ts | 67 ++++--- .../ClientApp/src/api/http/gateways.ts | 49 +++++ .../ClientApp/src/api/http/integrations.ts | 183 +++++++++++++----- .../ClientApp/src/api/http/notifiers.ts | 45 ++++- .../ClientApp/src/api/http/partners.ts | 25 ++- .../ClientApp/src/api/http/retryPolicies.ts | 33 ++++ .../ClientApp/src/api/http/searchQuery.ts | 38 ++++ .../ClientApp/src/api/http/workGroups.ts | 44 ++++- .../components/config/IntegrationDialog.tsx | 1 + .../src/components/config/WorkGroupDialog.tsx | 1 + .../src/components/ui/Pagination.tsx | 36 ++++ .../src/pages/api-gateways/ApiGatewayPage.tsx | 173 +++++++++++------ .../pages/api-gateways/ApiGatewaysPage.tsx | 42 ++-- .../pages/api-gateways/EditAttachmentPage.tsx | 1 + .../pages/bus-gateways/BusGatewaysPage.tsx | 107 +++++++--- .../InformationTypesPage.tsx | 40 ++-- .../pages/integrations/IntegrationsPage.tsx | 104 +++++++--- .../src/pages/notifiers/NotifiersPage.tsx | 37 +++- .../src/pages/partners/PartnersPage.tsx | 41 ++-- .../retry-policies/RetryPoliciesPage.tsx | 35 +++- .../scheduled-jobs/ScheduledJobsPage.tsx | 43 ++-- .../src/pages/work-groups/WorkGroupPage.tsx | 2 + .../src/pages/work-groups/WorkGroupsPage.tsx | 39 ++-- 30 files changed, 1033 insertions(+), 294 deletions(-) create mode 100644 SW.Bitween.Api/Resources/ApiGateways/SearchAttachments.cs create mode 100644 SW.Bitween.Web/ClientApp/src/api/http/searchQuery.ts create mode 100644 SW.Bitween.Web/ClientApp/src/components/ui/Pagination.tsx diff --git a/SW.Bitween.Api/Resources/ApiGateways/SearchAttachments.cs b/SW.Bitween.Api/Resources/ApiGateways/SearchAttachments.cs new file mode 100644 index 00000000..5590e6b3 --- /dev/null +++ b/SW.Bitween.Api/Resources/ApiGateways/SearchAttachments.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain.Gateway; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using System.Linq; +using System.Threading.Tasks; + +namespace SW.Bitween.Resources.ApiGateways +{ + /// + /// Paged, searched view of one gateway's attachments — the full list lives on + /// for callers that need every attached partner id (e.g. the + /// attach-partner picker's exclude list), this is only for the gateway page's own table. + /// + [HandlerName("attachments")] + public class SearchAttachments : IQueryHandler + { + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public SearchAttachments(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(SearchApiGatewayAttachmentsModel request) + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.ApiGateways.View); + + var offset = request.Offset ?? 0; + var limit = request.Limit ?? 25; + var term = request.Search?.Trim(); + + var query = _dbContext.Set() + .AsNoTracking() + .Where(p => p.ApiGatewayId == request.ApiGatewayId); + + if (!string.IsNullOrEmpty(term)) + query = query.Where(p => p.Partner.Name.Contains(term) || p.Subscription.Name.Contains(term)); + + var totalCount = await query.CountAsync(); + + var result = await query + .OrderBy(p => p.Partner.Name) + .Skip(offset) + .Take(limit) + .Select(p => new ApiGatewayPartnerDto + { + PartnerId = p.PartnerId, + SubscriptionId = p.SubscriptionId, + PartnerName = p.Partner.Name, + SubscriptionName = p.Subscription.Name + }) + .ToListAsync(); + + return new + { + Result = result, + TotalCount = totalCount + }; + } + } +} diff --git a/SW.Bitween.Api/Resources/Notifiers/Search.cs b/SW.Bitween.Api/Resources/Notifiers/Search.cs index d1171ec4..b1dad100 100644 --- a/SW.Bitween.Api/Resources/Notifiers/Search.cs +++ b/SW.Bitween.Api/Resources/Notifiers/Search.cs @@ -35,7 +35,8 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa RunOnBadResult = notifier.RunOnBadResult, RunOnFailedResult = notifier.RunOnFailedResult, RunOnSuccessfulResult = notifier.RunOnSuccessfulResult, - Inactive = notifier.Inactive + Inactive = notifier.Inactive, + RunOnSubscriptions = notifier.RunOnSubscriptions }; query = query.AsNoTracking(); diff --git a/SW.Bitween.Api/Resources/WorkGroups/Search.cs b/SW.Bitween.Api/Resources/WorkGroups/Search.cs index 8a9868ae..d5c85e7d 100644 --- a/SW.Bitween.Api/Resources/WorkGroups/Search.cs +++ b/SW.Bitween.Api/Resources/WorkGroups/Search.cs @@ -32,7 +32,9 @@ public async Task Handle(SearchWorkGroupModel request) // until the cache's own TTL expires whenever that broadcast doesn't // land. GlobalAdapterValuesSets and RetryPolicies already read the DB // directly for the same reason. - var workGroups = await dbContext.Set().AsNoTracking().ToArrayAsync(); + var workGroups = await dbContext.Set().AsNoTracking() + .Where(w => request.Name == null || w.Name.Contains(request.Name)) + .ToArrayAsync(); var consumerCounts = Array.Empty(); try diff --git a/SW.Bitween.Sdk/Model/ApiGateway.cs b/SW.Bitween.Sdk/Model/ApiGateway.cs index 7ebdf0b9..0ef7eeec 100644 --- a/SW.Bitween.Sdk/Model/ApiGateway.cs +++ b/SW.Bitween.Sdk/Model/ApiGateway.cs @@ -36,5 +36,13 @@ public class ApiGatewayPartnerCreate public int PartnerId { get; set; } public int SubscriptionId { get; set; } } + + public class SearchApiGatewayAttachmentsModel + { + public int ApiGatewayId { get; set; } + public string Search { get; set; } + public int? Offset { get; set; } + public int? Limit { get; set; } + } } diff --git a/SW.Bitween.Sdk/Model/Notifier.cs b/SW.Bitween.Sdk/Model/Notifier.cs index dc388f59..c238a207 100644 --- a/SW.Bitween.Sdk/Model/Notifier.cs +++ b/SW.Bitween.Sdk/Model/Notifier.cs @@ -34,6 +34,8 @@ public class NotifierSearch public bool? RunOnFailedResult { get; set; } public string HandlerId { get; set; } public bool? Inactive { get; set; } + /// So the list page can show a watched-integration count without a per-row detail fetch. + public int[] RunOnSubscriptions { get; set; } } diff --git a/SW.Bitween.Sdk/Model/Workgroups.cs b/SW.Bitween.Sdk/Model/Workgroups.cs index 5a37696a..1cc88d44 100644 --- a/SW.Bitween.Sdk/Model/Workgroups.cs +++ b/SW.Bitween.Sdk/Model/Workgroups.cs @@ -41,6 +41,7 @@ public class SearchWorkGroupModel { public int? Limit { get; set; } public int? Offset { get; set; } + public string Name { get; set; } } public class UpdateWorkGroupModel : CreateWorkGroupModel diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 8878f362..9fc0f641 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -2,6 +2,7 @@ import type { AdapterInfo, AdapterKind, ApiGateway, + ApiGatewayAttachment, ApiGatewayDetail, ApiGatewayRow, BusGateway, @@ -14,7 +15,6 @@ import type { GlobalValuesSetRow, InformationType, InformationTypeDetail, - InformationTypeFormat, InformationTypeRow, Integration, IntegrationDetail, @@ -33,6 +33,8 @@ import type { PermissionArea, PermissionKey, QueueHealthSnapshot, + ReceiveAttemptRow, + ReceiveOutcome, RetryGroup, RetryAlertConfig, RetryAttempts, @@ -108,6 +110,7 @@ export interface ApiClient { // — partners — listPartners(): Promise; + searchPartners(query: { search: string; offset: number; limit: number }): Promise>; getPartner(id: number): Promise; /** Light fetch used by the mapper editor's test-partner selector. */ getPartnerAdapterProperties(id: number): Promise>; @@ -123,14 +126,16 @@ export interface ApiClient { // — information types — listInformationTypes(): Promise; + searchInformationTypes(query: { + search: string; + offset: number; + limit: number; + }): Promise>; getInformationType(id: number): Promise; - createInformationType(input: { - name: string; - code: string; - format: InformationTypeFormat; - busEnabled?: boolean; - busMessageTypeName?: string; - }): Promise; + /** Same payload as update: a new type arrives complete, promoted properties included. */ + createInformationType( + input: Omit, + ): Promise; updateInformationType( id: number, changes: Omit, @@ -156,12 +161,23 @@ export interface ApiClient { // — integrations — listIntegrationRows(): Promise; + searchIntegrationRows(query: { + search: string; + type: IntegrationType | null; + informationTypeId?: number | null; + partnerId?: number | null; + inactive?: boolean | null; + offset: number; + limit: number; + }): Promise>; getIntegration(id: number): Promise; - /** Only Receiving / GatewayApiCall / BusGateway — always from a create page. */ + /** One call, one transaction: the integration exists as asked for, or not at all. */ createIntegration(input: { type: IntegrationType; name: string; informationTypeId: number; + /** Required by the types that carry their own partner — Internal and ApiCall. */ + partnerId?: number | null; receiverId?: string | null; receiverProperties?: Record; validatorId?: string | null; @@ -206,6 +222,10 @@ export interface ApiClient { receiveNow(id: number): Promise; /** Run history for one scheduled integration, newest first. Empty for unscheduled types. */ listIntegrationRuns(id: number, limit?: number): Promise; + searchReceiveAttempts( + subscriptionId: number, + query: { outcome: ReceiveOutcome | null; offset: number; limit: number }, + ): Promise>; /** Newest run of every scheduled integration — one request for a whole list. */ listLastRuns(): Promise; /** Will these schedules actually fire? Asks the scheduler, not the integration record. */ @@ -214,6 +234,7 @@ export interface ApiClient { // — work groups — listWorkGroups(): Promise; + searchWorkGroups(query: { search: string; offset: number; limit: number }): Promise>; getWorkGroup(id: number): Promise; createWorkGroup(input: { name: string; @@ -229,7 +250,12 @@ export interface ApiClient { // — API gateways — listApiGateways(): Promise; + searchApiGateways(query: { search: string; offset: number; limit: number }): Promise>; getApiGateway(id: number): Promise; + searchGatewayAttachments( + apiGatewayId: number, + query: { search: string; offset: number; limit: number }, + ): Promise>; createApiGateway(input: { name: string; urlName: string }): Promise; updateApiGateway( id: number, @@ -242,6 +268,13 @@ export interface ApiClient { // — bus gateways — listBusGateways(): Promise; + searchBusGateways(query: { + search: string; + informationTypeId?: number | null; + inactive?: boolean | null; + offset: number; + limit: number; + }): Promise>; getBusGateway(id: number): Promise; createBusGateway(input: { name: string; informationTypeId: number }): Promise; updateBusGateway(id: number, changes: { name: string; inactive: boolean }): Promise; @@ -259,6 +292,11 @@ export interface ApiClient { // — retry policies — listRetryPolicies(): Promise; + searchRetryPolicies(query: { + search: string; + offset: number; + limit: number; + }): Promise>; getRetryPolicy(id: number): Promise; createRetryPolicy(input: { name: string }): Promise; updateRetryPolicy( @@ -307,10 +345,11 @@ export interface ApiClient { // — notifiers — // No backend delete/test-send endpoint exists yet (BACKEND_WIRING_PLAN.md G8) — hidden in the UI. // Channel choices come from listAdapters("handler") — same catalog as any other handler slot. - listNotifiers(): Promise; + searchNotifiers(query: { search: string; offset: number; limit: number }): Promise>; getNotifier(id: number): Promise; createNotifier(input: { name: string }): Promise; updateNotifier(id: number, changes: Omit): Promise; + deleteNotifier(id: number): Promise; // — exchanges — searchExchanges(query: ExchangeQuery): Promise>; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/documents.ts b/SW.Bitween.Web/ClientApp/src/api/http/documents.ts index cceb2053..9e1652ee 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/documents.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/documents.ts @@ -5,11 +5,13 @@ import type { InformationTypeFormat, InformationTypeRow, IntegrationType, + Paged, TrailEntry, } from "../types"; import { exchangeMethods } from "./exchanges"; import { gatewayMethods } from "./gateways"; import { get, getEnrichment, post, request } from "./request"; +import { buildListQuery, SEARCHY_RULE } from "./searchQuery"; interface SearchyResponse { result: T[]; @@ -122,6 +124,18 @@ async function fetchDetail(id: number): Promise { }; } +/** One wire shape for both create and update, so they cannot drift apart. */ +const documentBody = (t: Omit) => ({ + code: t.code?.trim() || undefined, + name: t.name, + documentFormat: t.format, + busEnabled: t.busEnabled, + busMessageTypeName: t.busEnabled ? t.busMessageTypeName : undefined, + duplicateInterval: t.duplicateIntervalMinutes, + disregardsUnfilteredMessages: t.disregardsUnfilteredMessages, + promotedProperties: t.promotedProperties.map((p) => ({ key: p.key, value: p.path })), +}); + export const documentMethods = { async listInformationTypes(): Promise { const [res, subs] = await Promise.all([ @@ -134,22 +148,35 @@ export const documentMethods = { return (res.result ?? []).map((d) => ({ ...toInformationType(d), usedByCount: countByDocument.get(d.id) ?? 0 })); }, + async searchInformationTypes(query: { + search: string; + offset: number; + limit: number; + }): Promise> { + const qs = buildListQuery({ + filters: [["Name", SEARCHY_RULE.contains, query.search.trim()]], + offset: query.offset, + limit: query.limit, + }); + const [res, subs] = await Promise.all([ + get>(`/documents?${qs}`), + getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), + ]); + const countByDocument = new Map(); + for (const s of subs.result ?? []) + countByDocument.set(s.documentId, (countByDocument.get(s.documentId) ?? 0) + 1); + return { + total: res.totalCount, + result: (res.result ?? []).map((d) => ({ ...toInformationType(d), usedByCount: countByDocument.get(d.id) ?? 0 })), + }; + }, + getInformationType: fetchDetail, - async createInformationType(input: { - name: string; - code?: string; - format: InformationTypeFormat; - busEnabled?: boolean; - busMessageTypeName?: string; - }): Promise { - const id = await post("/documents", { - code: input.code?.trim() || undefined, - name: input.name, - documentFormat: input.format, - busEnabled: input.busEnabled ?? false, - busMessageTypeName: input.busEnabled ? input.busMessageTypeName : undefined, - }); + async createInformationType( + input: Omit, + ): Promise { + const id = await post("/documents", documentBody(input)); return fetchDetail(id); }, @@ -157,17 +184,7 @@ export const documentMethods = { id: number, changes: Omit, ): Promise { - await post(`/documents/${id}`, { - id, - code: changes.code?.trim() || undefined, - name: changes.name, - documentFormat: changes.format, - busEnabled: changes.busEnabled, - busMessageTypeName: changes.busEnabled ? changes.busMessageTypeName : undefined, - duplicateInterval: changes.duplicateIntervalMinutes, - disregardsUnfilteredMessages: changes.disregardsUnfilteredMessages, - promotedProperties: changes.promotedProperties.map((p) => ({ key: p.key, value: p.path })), - }); + await post(`/documents/${id}`, { id, ...documentBody(changes) }); return fetchDetail(id); }, diff --git a/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts b/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts index 9f35a7ac..fdf5c1fe 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts @@ -9,9 +9,11 @@ import type { BusGatewayRoute, BusGatewayRow, MatchGroup, + Paged, } from "../types"; import { toMatchGroup, toRawMatchExpression, type RawMatchSpec } from "./matchExpression"; import { get, post, request } from "./request"; +import { buildListQuery, SEARCHY_RULE } from "./searchQuery"; // ——— backend shapes (camelCase over the wire) ——— interface SearchyResponse { @@ -117,10 +119,37 @@ export const gatewayMethods = { return (res.result ?? []).map(toApiGatewayRow); }, + async searchApiGateways(query: { search: string; offset: number; limit: number }): Promise> { + const qs = buildListQuery({ + filters: [["Name", SEARCHY_RULE.contains, query.search.trim()]], + offset: query.offset, + limit: query.limit, + }); + const res = await get>(`/apigateways?${qs}`); + return { total: res.totalCount, result: (res.result ?? []).map(toApiGatewayRow) }; + }, + async getApiGateway(id: number): Promise { return toApiGatewayDetail(await get(`/apigateways/${id}`)); }, + /** Paged, searched view of one gateway's attachments, for the gateway page's own + * table — `getApiGateway` keeps returning the full list, still needed by the + * attach-partner picker's exclude list. */ + async searchGatewayAttachments( + apiGatewayId: number, + query: { search: string; offset: number; limit: number }, + ): Promise> { + const params = new URLSearchParams({ + apiGatewayId: String(apiGatewayId), + offset: String(query.offset), + limit: String(query.limit), + }); + if (query.search.trim()) params.set("search", query.search.trim()); + const res = await get>(`/apigateways/attachments?${params.toString()}`); + return { total: res.totalCount, result: (res.result ?? []).map(toApiGatewayAttachment) }; + }, + async createApiGateway({ name, urlName }: { name: string; urlName: string }): Promise { const id = await post("/apigateways", { name, urlName, inactive: false }); return { id, name, urlName, inactive: false, createdOn: "" }; @@ -168,6 +197,26 @@ export const gatewayMethods = { return (res.result ?? []).map(toBusGatewayRow); }, + async searchBusGateways(query: { + search: string; + informationTypeId?: number | null; + inactive?: boolean | null; + offset: number; + limit: number; + }): Promise> { + const qs = buildListQuery({ + filters: [ + ["Name", SEARCHY_RULE.contains, query.search.trim()], + ["DocumentId", SEARCHY_RULE.equalsTo, query.informationTypeId ?? ""], + ["Inactive", SEARCHY_RULE.equalsTo, query.inactive == null ? "" : String(query.inactive)], + ], + offset: query.offset, + limit: query.limit, + }); + const res = await get>(`/busgateways?${qs}`); + return { total: res.totalCount, result: (res.result ?? []).map(toBusGatewayRow) }; + }, + async getBusGateway(id: number): Promise { return toBusGatewayDetail(await get(`/busgateways/${id}`)); }, diff --git a/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts b/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts index b1c305c7..910ce616 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts @@ -8,34 +8,58 @@ import { type IntegrationRow, type IntegrationRun, type IntegrationType, + type InformationTypeRow, + type Paged, + type PartnerRow, + type ReceiveAttemptRow, + type ReceiveOutcome, type Schedule, type ScheduleHealth, } from "../types"; import { schedulesSummary } from "../../lib/schedules"; import { documentMethods } from "./documents"; -import { exchangeMethods } from "./exchanges"; +import { deriveStatus, exchangeMethods } from "./exchanges"; import { gatewayMethods } from "./gateways"; import { partnerMethods } from "./partners"; import { scanReferenceTokens } from "./references"; import { get, post, request } from "./request"; +import { buildListQuery, SEARCHY_RULE, SEARCHY_SORT } from "./searchQuery"; import { toMatchGroup, toRawMatchExpression, type RawMatchSpec } from "./matchExpression"; +import { + toKvArray, + toRawSchedules, + type RawKeyAndValue, + type RawSchedule, +} from "./subscriptionBody"; // ——— backend shapes (camelCase over the wire) ——— interface SearchyResponse { result: T[]; totalCount: number; } -interface RawKeyAndValue { - key: string; - value: string; + +interface RawReceiveAttemptExchange { + id: string; + status: boolean | null; + responseBad: boolean | null; + promotedProperties: Record | null; } -interface RawSchedule { - recurrence: Schedule["recurrence"]; - days: number; - hours: number; - minutes: number; - backwards: boolean; +interface RawReceiveAttempt { + id: number; + startedOn: string; + finishedOn: string; + // Enums may arrive as the numeric value or the name, depending on the endpoint. + outcome: number | string; + errorMessage: string | null; + exchanges: RawReceiveAttemptExchange[]; } +const RECEIVE_OUTCOME_BY_NUM: Record = { + 0: "Failed", + 1: "NoNewData", + 2: "Received", +}; +const toReceiveOutcome = (o: number | string): ReceiveOutcome => + typeof o === "number" ? (RECEIVE_OUTCOME_BY_NUM[o] ?? "Failed") : (o as ReceiveOutcome); interface RawSubscription { id?: number; @@ -99,8 +123,6 @@ const toIntegrationType = (t: number | string): IntegrationType => // stored data that never had the key — leaving the Save bar up after an undo. const toRecord = (kvs: RawKeyAndValue[] | null): Record => Object.fromEntries((kvs ?? []).filter((kv) => kv.value !== "").map((kv) => [kv.key, kv.value])); -const toKvArray = (record: Record): RawKeyAndValue[] => - Object.entries(record).map(([key, value]) => ({ key, value })); const toSchedules = (raw: RawSchedule[] | null): Schedule[] => (raw ?? []).map((s) => ({ @@ -110,14 +132,6 @@ const toSchedules = (raw: RawSchedule[] | null): Schedule[] => minutes: s.minutes, backwards: s.backwards, })); -const toRawSchedules = (schedules: Schedule[]): RawSchedule[] => - schedules.map((s) => ({ - recurrence: s.recurrence, - days: s.days, - hours: s.hours, - minutes: s.minutes, - backwards: s.backwards, - })); function toIntegration(raw: RawSubscription, idOverride?: number): Integration { return { @@ -235,6 +249,41 @@ async function applyChanges(id: number, current: RawSubscription, changes: Updat }); } +function toIntegrationRow( + raw: RawSubscription, + infoTypeById: Map, + partnerById: Map, +): IntegrationRow { + const type = toIntegrationType(raw.type); + const infoType = infoTypeById.get(raw.documentId); + const partner = raw.partnerId !== null ? partnerById.get(raw.partnerId) : undefined; + const schedules = toSchedules(raw.schedules); + return { + id: raw.id!, + name: raw.name, + type, + informationTypeId: raw.documentId, + informationTypeCode: infoType?.code ?? infoType?.name ?? "", + // Gateway-derived partners (GatewayApiCall/BusGateway) land in Batch 3. + partners: partner ? [{ id: partner.id, name: partner.name }] : [], + enabled: !raw.inactive, + paused: raw.pausedOn !== null, + isRunning: raw.isRunning ?? false, + consecutiveFailures: raw.consecutiveFailures ?? 0, + lastException: raw.lastException ?? null, + // Search.cs can't select Schedules in this joined query without + // breaking SQL translation (Postgres date_part type mismatch), so + // schedules is always empty here — showing "No schedule" would be + // actively wrong for a job that has one. Leave it unset instead. + scheduleSummary: + schedules.length > 0 && (type === "Receiving" || type === "Aggregation") + ? schedulesSummary(schedules) + : undefined, + nextReceiveOn: raw.receiveOn ?? null, + createdOn: "", + }; +} + export const integrationMethods = { async listIntegrations(): Promise { const rows = await fetchAllRaw(); @@ -270,37 +319,41 @@ export const integrationMethods = { ]); const infoTypeById = new Map(infoTypes.map((t) => [t.id, t])); const partnerById = new Map(partners.map((p) => [p.id, p])); + return rows.map((raw) => toIntegrationRow(raw, infoTypeById, partnerById)); + }, - return rows.map((raw) => { - const type = toIntegrationType(raw.type); - const infoType = infoTypeById.get(raw.documentId); - const partner = raw.partnerId !== null ? partnerById.get(raw.partnerId) : undefined; - const schedules = toSchedules(raw.schedules); - return { - id: raw.id!, - name: raw.name, - type, - informationTypeId: raw.documentId, - informationTypeCode: infoType?.code ?? infoType?.name ?? "", - // Gateway-derived partners (GatewayApiCall/BusGateway) land in Batch 3. - partners: partner ? [{ id: partner.id, name: partner.name }] : [], - enabled: !raw.inactive, - paused: raw.pausedOn !== null, - isRunning: raw.isRunning ?? false, - consecutiveFailures: raw.consecutiveFailures ?? 0, - lastException: raw.lastException ?? null, - // Search.cs can't select Schedules in this joined query without - // breaking SQL translation (Postgres date_part type mismatch), so - // schedules is always empty here — showing "No schedule" would be - // actively wrong for a job that has one. Leave it unset instead. - scheduleSummary: - schedules.length > 0 && (type === "Receiving" || type === "Aggregation") - ? schedulesSummary(schedules) - : undefined, - nextReceiveOn: raw.receiveOn ?? null, - createdOn: "", - }; + async searchIntegrationRows(query: { + search: string; + type: IntegrationType | null; + informationTypeId?: number | null; + partnerId?: number | null; + inactive?: boolean | null; + offset: number; + limit: number; + }): Promise> { + const qs = buildListQuery({ + filters: [ + ["Name", SEARCHY_RULE.contains, query.search.trim()], + ["Type", SEARCHY_RULE.equalsTo, query.type ?? ""], + ["DocumentId", SEARCHY_RULE.equalsTo, query.informationTypeId ?? ""], + ["PartnerId", SEARCHY_RULE.equalsTo, query.partnerId ?? ""], + ["Inactive", SEARCHY_RULE.equalsTo, query.inactive == null ? "" : String(query.inactive)], + ], + sort: ["Name", SEARCHY_SORT.asc], + offset: query.offset, + limit: query.limit, }); + const [res, infoTypes, partners] = await Promise.all([ + get>(`/subscriptions?${qs}`), + documentMethods.listInformationTypes(), + partnerMethods.listPartners(), + ]); + const infoTypeById = new Map(infoTypes.map((t) => [t.id, t])); + const partnerById = new Map(partners.map((p) => [p.id, p])); + return { + total: res.totalCount, + result: (res.result ?? []).map((raw) => toIntegrationRow(raw, infoTypeById, partnerById)), + }; }, async getIntegration(id: number): Promise { @@ -350,6 +403,8 @@ export const integrationMethods = { type: IntegrationType; name: string; informationTypeId: number; + /** Required by the types that carry their own partner — Internal and ApiCall. */ + partnerId?: number | null; receiverId?: string | null; receiverProperties?: Record; validatorId?: string | null; @@ -371,7 +426,7 @@ export const integrationMethods = { name: input.name, documentId: input.informationTypeId, type: input.type, - partnerId: null, + partnerId: input.partnerId ?? null, aggregationForId: null, receiverId: input.receiverId ?? null, receiverProperties: toKvArray(input.receiverProperties ?? {}), @@ -419,6 +474,34 @@ export const integrationMethods = { return get(`/subscriptions/runs?subscriptionId=${id}&limit=${limit}`); }, + async searchReceiveAttempts( + subscriptionId: number, + query: { outcome: ReceiveOutcome | null; offset: number; limit: number }, + ): Promise> { + const params = new URLSearchParams({ + subscriptionId: String(subscriptionId), + offset: String(query.offset), + limit: String(query.limit), + }); + if (query.outcome) params.set("outcome", query.outcome); + const res = await get>(`/subscriptions/receiveattempts?${params.toString()}`); + return { + total: res.totalCount, + result: (res.result ?? []).map((a) => ({ + id: a.id, + startedOn: a.startedOn, + finishedOn: a.finishedOn, + outcome: toReceiveOutcome(a.outcome), + errorMessage: a.errorMessage, + exchanges: a.exchanges.map((x) => ({ + id: x.id, + status: deriveStatus(x), + promotedProperties: x.promotedProperties, + })), + })), + }; + }, + async listLastRuns(): Promise { const rows = await get<(Omit & { subscriptionId: number })[]>( diff --git a/SW.Bitween.Web/ClientApp/src/api/http/notifiers.ts b/SW.Bitween.Web/ClientApp/src/api/http/notifiers.ts index d26f242d..b55a54fb 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/notifiers.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/notifiers.ts @@ -1,6 +1,7 @@ import type { ApiClient } from "../client"; -import { ApiRequestError, type NotificationEntry, type Notifier, type NotifierDetail } from "../types"; -import { get, post } from "./request"; +import { ApiRequestError, type NotificationEntry, type Notifier, type NotifierDetail, type Paged } from "../types"; +import { get, post, request } from "./request"; +import { buildListQuery, SEARCHY_RULE } from "./searchQuery"; interface SearchyResponse { result: T[]; @@ -21,6 +22,17 @@ interface RawNotifier { runOnFailedResult: boolean; runOnSubscriptions: { id: number; name: string | null }[] | null; } +/** Shape of a search-endpoint row — lighter than `RawNotifier`, no adapter properties. */ +interface RawNotifierRow { + id: number; + name: string; + inactive: boolean | null; + handlerId: string | null; + runOnSuccessfulResult: boolean | null; + runOnBadResult: boolean | null; + runOnFailedResult: boolean | null; + runOnSubscriptions: number[] | null; +} interface RawNotification { xchangeId: string; success: boolean; @@ -68,9 +80,28 @@ async function fetchDetail(id: number): Promise { } export const notifierMethods = { - async listNotifiers(): Promise { - const res = await get>("/notifiers"); - return Promise.all((res.result ?? []).map((r) => fetchDetail(r.id))); + async searchNotifiers(query: { search: string; offset: number; limit: number }): Promise> { + const qs = buildListQuery({ + filters: [["Name", SEARCHY_RULE.contains, query.search.trim()]], + offset: query.offset, + limit: query.limit, + }); + const res = await get>(`/notifiers?${qs}`); + return { + total: res.totalCount, + result: (res.result ?? []).map((r) => ({ + id: r.id, + name: r.name, + enabled: !r.inactive, + onFailed: !!r.runOnFailedResult, + onBadResult: !!r.runOnBadResult, + onSuccess: !!r.runOnSuccessfulResult, + channelId: r.handlerId ?? "", + channelProperties: {}, + integrationIds: r.runOnSubscriptions ?? [], + createdOn: "", + })), + }; }, getNotifier: fetchDetail, @@ -104,4 +135,8 @@ export const notifierMethods = { }); return { id, createdOn: "", ...changes }; }, + + async deleteNotifier(id: number): Promise { + await request(`/notifiers/${id}`, { method: "DELETE" }); + }, } satisfies Partial; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/partners.ts b/SW.Bitween.Web/ClientApp/src/api/http/partners.ts index a949ebaa..567da971 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/partners.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/partners.ts @@ -1,9 +1,10 @@ import type { ApiClient } from "../client"; -import { ApiRequestError, type Partner, type PartnerDetail, type PartnerRow } from "../types"; +import { ApiRequestError, type Paged, type Partner, type PartnerDetail, type PartnerRow } from "../types"; import { exchangeMethods } from "./exchanges"; import { gatewayMethods } from "./gateways"; import { matchSummary } from "../../lib/match"; import { get, post, request } from "./request"; +import { buildListQuery, SEARCHY_RULE } from "./searchQuery"; // The built-in SYSTEM partner (Partner.SystemId) can't be renamed or deleted. const SYSTEM_PARTNER_ID = 1; @@ -77,6 +78,28 @@ export const partnerMethods = { })); }, + async searchPartners(query: { search: string; offset: number; limit: number }): Promise> { + const qs = buildListQuery({ + filters: [["Name", SEARCHY_RULE.contains, query.search.trim()]], + offset: query.offset, + limit: query.limit, + }); + const res = await get>(`/partners?${qs}`); + return { + total: res.totalCount, + result: (res.result ?? []).map((p) => ({ + id: p.id, + name: p.name, + adapterProperties: {}, + propertyKeys: p.propertyKeys ?? [], + isSystem: p.id === SYSTEM_PARTNER_ID, + createdOn: "", + credentialCount: p.keys, + usedByCount: p.subscriptionsCount, + })), + }; + }, + /** Light single-field fetch for the mapper editor's test-partner selector — avoids getPartner's gateway/exchange lookups. */ async getPartnerAdapterProperties(id: number): Promise> { const d = await requireDetail(id); diff --git a/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts index d8694a8a..ade1a22e 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts @@ -13,8 +13,10 @@ import { type RetryResultType, type RetryTestAttempt, type RetryUsageRow, + type Paged, } from "../types"; import { get, getEnrichment, post, request } from "./request"; +import { buildListQuery, SEARCHY_RULE } from "./searchQuery"; interface SearchyResponse { result: T[]; @@ -244,6 +246,37 @@ export const retryPolicyMethods = { })); }, + async searchRetryPolicies(query: { + search: string; + offset: number; + limit: number; + }): Promise> { + const qs = buildListQuery({ + filters: [["Name", SEARCHY_RULE.contains, query.search.trim()]], + offset: query.offset, + limit: query.limit, + }); + const [res, subs] = await Promise.all([ + get>(`/retrypolicies?${qs}`), + getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), + ]); + const countByRetryPolicy = new Map(); + for (const s of subs.result ?? []) { + if (s.retryPolicyId == null) continue; + countByRetryPolicy.set(s.retryPolicyId, (countByRetryPolicy.get(s.retryPolicyId) ?? 0) + 1); + } + return { + total: res.totalCount, + result: (res.result ?? []).map((p) => ({ + id: p.id, + name: p.name, + groupCount: p.groupCount, + createdOn: "", + usedByCount: countByRetryPolicy.get(p.id) ?? 0, + })), + }; + }, + getRetryPolicy: fetchDetail, async createRetryPolicy({ name }: { name: string }): Promise { diff --git a/SW.Bitween.Web/ClientApp/src/api/http/searchQuery.ts b/SW.Bitween.Web/ClientApp/src/api/http/searchQuery.ts new file mode 100644 index 00000000..ffeba36a --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/searchQuery.ts @@ -0,0 +1,38 @@ +/** + * Builds a Searchy query string — the `filter=Field:Rule:Value` + `page`/`size` + * convention every list endpoint in this API already speaks (see `buildExchangeQuery` + * for the hand-written original this generalizes). + * + * `page` and `size` are always sent together, never one without the other: at least + * one backend handler (`Subscriptions/Search.cs`, filtering by name) diverts a text + * filter through an in-memory `Skip(size * page).Take(size)` with no "size == 0 means + * unbounded" guard, so an omitted size silently returns zero rows despite a correct + * total count. Always supplying both sidesteps that regardless of which handler runs. + */ +export function buildListQuery(opts: { + /** [Field, Rule, Value] triples — Rule 1 = EqualsTo, 4 = Contains. Skipped when Value is "". */ + filters?: [string, number, string | number][]; + /** [Field, Order] — Order 1 = ascending, 2 = descending. */ + sort?: [string, number]; + offset: number; + limit: number; +}): string { + const params = new URLSearchParams(); + for (const [field, rule, value] of opts.filters ?? []) { + if (value !== "" && value !== undefined && value !== null) params.append("filter", `${field}:${rule}:${value}`); + } + if (opts.sort) params.append("sort", `${opts.sort[0]}:${opts.sort[1]}`); + params.set("page", String(Math.floor(opts.offset / opts.limit))); + params.set("size", String(opts.limit)); + return params.toString(); +} + +export const SEARCHY_RULE = { + equalsTo: 1, + contains: 4, +} as const; + +export const SEARCHY_SORT = { + asc: 1, + desc: 2, +} as const; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts b/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts index 76749ae4..51b9a303 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts @@ -5,6 +5,7 @@ import { type WorkGroup, type WorkGroupDetail, type WorkGroupRow, + type Paged, } from "../types"; import { get, getEnrichment, post } from "./request"; @@ -70,11 +71,28 @@ const toWorkGroup = (w: RawWorkGroup): WorkGroup => ({ createdOn: "", }); +// The backend's own default kicks in only when the caller omits the param +// entirely (`request.Limit ??= 20`), so passing an explicit, generously large +// one is how "everything" is asked for — there is no separate "unbounded" +// value the way the shared Searchy endpoints have with size=0. +const EVERYTHING = 1_000_000; + async function fetchRows(): Promise { - const res = await get>("/workgroups"); + const res = await get>(`/workgroups?offset=0&limit=${EVERYTHING}`); return res.result ?? []; } +async function fetchPagedRows(query: { + search: string; + offset: number; + limit: number; +}): Promise<{ rows: RawWorkGroup[]; total: number }> { + const params = new URLSearchParams({ offset: String(query.offset), limit: String(query.limit) }); + if (query.search.trim()) params.set("name", query.search.trim()); + const res = await get>(`/workgroups?${params.toString()}`); + return { rows: res.result ?? [], total: res.totalCount }; +} + export const workGroupMethods = { async listWorkGroups(): Promise { const [rows, subs] = await Promise.all([ @@ -93,6 +111,30 @@ export const workGroupMethods = { })); }, + async searchWorkGroups(query: { + search: string; + offset: number; + limit: number; + }): Promise> { + const [{ rows, total }, subs] = await Promise.all([ + fetchPagedRows(query), + getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), + ]); + const countByWorkGroup = new Map(); + for (const s of subs.result ?? []) { + if (s.workGroupId == null) continue; + countByWorkGroup.set(s.workGroupId, (countByWorkGroup.get(s.workGroupId) ?? 0) + 1); + } + return { + total, + result: rows.map((w) => ({ + ...toWorkGroup(w), + usedByCount: countByWorkGroup.get(w.id) ?? 0, + consumerCount: w.processorNodeCount ?? 0, + })), + }; + }, + async getWorkGroup(id: number): Promise { const [rows, subs] = await Promise.all([fetchRows(), fetchSubscriptionsByWorkGroup(id)]); const w = rows.find((x) => x.id === id); diff --git a/SW.Bitween.Web/ClientApp/src/components/config/IntegrationDialog.tsx b/SW.Bitween.Web/ClientApp/src/components/config/IntegrationDialog.tsx index 84bdcec8..b10a4879 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/IntegrationDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/IntegrationDialog.tsx @@ -63,6 +63,7 @@ export function IntegrationDialog({ onSuccess: (created) => { void queryClient.invalidateQueries({ queryKey: ["integrations"] }); void queryClient.invalidateQueries({ queryKey: ["integration-rows"] }); + void queryClient.invalidateQueries({ queryKey: ["integration-rows-search"] }); onCreated(created.id); onClose(); }, diff --git a/SW.Bitween.Web/ClientApp/src/components/config/WorkGroupDialog.tsx b/SW.Bitween.Web/ClientApp/src/components/config/WorkGroupDialog.tsx index 5dfa3eb2..3d49b1ae 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/WorkGroupDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/WorkGroupDialog.tsx @@ -151,6 +151,7 @@ export function WorkGroupDialog({ }, onSuccess: (id) => { void queryClient.invalidateQueries({ queryKey: ["work-groups"] }); + void queryClient.invalidateQueries({ queryKey: ["work-groups-search"] }); void queryClient.invalidateQueries({ queryKey: ["work-group", id] }); onSaved?.(id); onClose(); diff --git a/SW.Bitween.Web/ClientApp/src/components/ui/Pagination.tsx b/SW.Bitween.Web/ClientApp/src/components/ui/Pagination.tsx new file mode 100644 index 00000000..a9c743d1 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/ui/Pagination.tsx @@ -0,0 +1,36 @@ +import { Button } from "./basics"; + +/** + * The "Showing X–Y of Z" + Previous/Next footer, lifted out of the Exchanges + * page so every other table gets the same server-paged behavior without + * hand-rolling it again. Fixed page size, no jump-to-page — matches the one + * table that already did this. + */ +export function Pagination({ + offset, + limit, + total, + onOffsetChange, +}: { + offset: number; + limit: number; + total: number; + onOffsetChange: (offset: number) => void; +}) { + if (total === 0) return null; + return ( +
+ + Showing {offset + 1}–{Math.min(offset + limit, total)} of {total} + + + + + +
+ ); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx index 5de1b31c..5b4dc2cd 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate, useParams } from "react-router"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Pause, Pencil, Play, Plus, Trash2 } from "lucide-react"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router"; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Pause, Pencil, Play, Plus, Search, Trash2 } from "lucide-react"; import { api, type ApiGatewayAttachment } from "../../api"; import { Can, useSessionCan } from "../../auth/guards"; import { finishUrlName, toUrlName } from "../../lib/identifiers"; @@ -11,14 +11,18 @@ import { ConfirmDialog } from "../../components/ui/overlays"; import { CopyField } from "../../components/ui/CopyField"; import { EditableTitle, Panel, UnsavedBar } from "../../components/ui/Panel"; import { MiniTable } from "../../components/ui/Table"; +import { Pagination } from "../../components/ui/Pagination"; import { useWiredIntegrationColumns } from "../../components/config/shared"; import { BackLink } from "../../components/ui/BackLink"; +const ATTACHMENTS_PAGE_SIZE = 10; + export function ApiGatewayPage() { const { id = "" } = useParams(); const gatewayId = Number(id); const navigate = useNavigate(); const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); const canEdit = useSessionCan("api-gateways.edit"); const wiredColumns = useWiredIntegrationColumns((a) => a.integrationId); @@ -28,6 +32,31 @@ export function ApiGatewayPage() { retry: false, }); + const attachmentsQuery = searchParams.get("aq") ?? ""; + const attachmentsOffset = searchParams.get("aoffset") ? Number(searchParams.get("aoffset")) : 0; + const attachments = useQuery({ + queryKey: ["api-gateway-attachments-search", gatewayId, attachmentsQuery, attachmentsOffset], + queryFn: () => + api.searchGatewayAttachments(gatewayId, { + search: attachmentsQuery, + offset: attachmentsOffset, + limit: ATTACHMENTS_PAGE_SIZE, + }), + placeholderData: keepPreviousData, + }); + + const setAttachmentsParam = (key: "aq" | "aoffset", value: string | null, resetOffset = key === "aq") => + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (value) next.set(key, value); + else next.delete(key); + if (resetOffset) next.delete("aoffset"); + return next; + }, + { replace: key === "aq" }, + ); + const [name, setName] = useState(""); const [urlName, setUrlName] = useState(""); const [removing, setRemoving] = useState<{ partnerId: number; partnerName: string } | null>(null); @@ -131,7 +160,7 @@ export function ApiGatewayPage() { - - - - ), - }, - ]} - /> +
+ + setAttachmentsParam("aq", e.target.value || null)} + placeholder="Search attached partners" + aria-label="Search attached partners" + className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" + /> +
+ {attachments.isPending ? ( + + ) : ( + a.partnerId} + empty={ + attachmentsQuery + ? "No attached partners match." + : "No partners attached — the gateway answers 401 to everyone. Attach a partner to bring it to life." + } + columns={[ + { + header: "Partner", + cell: (a) => ( + + {a.partnerName} + + ), + }, + { + header: "Runs", + cell: (a) => ( + + {a.integrationName} + + ), + }, + ...wiredColumns, + { + header: "", + align: "right", + cell: (a) => ( + + + + + + + ), + }, + ]} + /> + )} +
+ setAttachmentsParam("aoffset", String(o), false)} + /> +
@@ -219,6 +275,7 @@ export function ApiGatewayPage() { onConfirm={async () => { await api.removeGatewayAttachment(gatewayId, removing.partnerId); void queryClient.invalidateQueries({ queryKey: ["api-gateway", gatewayId] }); + void queryClient.invalidateQueries({ queryKey: ["api-gateway-attachments-search"] }); void queryClient.invalidateQueries({ queryKey: ["integrations"] }); }} onClose={() => setRemoving(null)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx index 7de4bb44..f26d1f5e 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx @@ -1,11 +1,11 @@ -import { useMemo } from "react"; import { useNavigate, useSearchParams } from "react-router"; -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Plus, Search, Webhook } from "lucide-react"; import { api, type IntegrationRow } from "../../api"; import { Can } from "../../auth/guards"; import { PageHeader } from "../../components/layout/PageHeader"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; +import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; import { LinkListCell, @@ -18,31 +18,35 @@ import { * gateway entity, not per pipeline: the pipelines behind it are reached through * the attachment that names them. */ +const PAGE_SIZE = 25; + export function ApiGatewaysPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const q = searchParams.get("q") ?? ""; + const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; - const gateways = useQuery({ queryKey: ["api-gateways"], queryFn: () => api.listApiGateways() }); + const gateways = useQuery({ + queryKey: ["api-gateways-search", q, offset], + queryFn: () => api.searchApiGateways({ search: q, offset, limit: PAGE_SIZE }), + placeholderData: keepPreviousData, + }); const integrationsById = useIntegrationRowsById(); - const setQ = (value: string) => + const setParam = (key: string, value: string | null, resetOffset = true) => setSearchParams( (prev) => { const next = new URLSearchParams(prev); - if (value) next.set("q", value); - else next.delete("q"); + if (value) next.set(key, value); + else next.delete(key); + if (resetOffset) next.delete("offset"); return next; }, { replace: true }, ); - const filtered = useMemo(() => { - const needle = q.trim().toLowerCase(); - return (gateways.data ?? []).filter( - (g) => !needle || g.name.toLowerCase().includes(needle) || g.urlName.toLowerCase().includes(needle), - ); - }, [gateways.data, q]); + const rows = gateways.data?.result ?? []; + const total = gateways.data?.total ?? 0; return (
@@ -63,7 +67,7 @@ export function ApiGatewaysPage() { setQ(e.target.value)} + onChange={(e) => setParam("q", e.target.value || null)} placeholder="Search gateways" aria-label="Search API gateways" className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" @@ -72,16 +76,24 @@ export function ApiGatewaysPage() { {gateways.isPending ? ( - ) : filtered.length === 0 ? ( + ) : rows.length === 0 ? ( } title={q ? "No gateways match" : "No API gateways yet"}> {q ? "Try a different search." : "Create a gateway to give partners a URL to push documents to."} ) : ( g.id} minWidth="min-w-200" onRowClick={(g) => navigate(`/api-gateways/${g.id}`)} + footer={ + setParam("offset", String(o), false)} + /> + } columns={[ { header: "Gateway", diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/EditAttachmentPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/EditAttachmentPage.tsx index 7ee4ec1d..6112c0b8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/EditAttachmentPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/EditAttachmentPage.tsx @@ -51,6 +51,7 @@ export function EditAttachmentPage() { onSuccess: () => { clear(); void queryClient.invalidateQueries({ queryKey: ["api-gateway", gatewayId] }); + void queryClient.invalidateQueries({ queryKey: ["api-gateway-attachments-search"] }); void queryClient.invalidateQueries({ queryKey: ["integrations"] }); navigate(`/api-gateways/${gatewayId}`); }, diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx index 5799b631..dd3aaf1c 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx @@ -1,11 +1,14 @@ import { useMemo } from "react"; import { Link, useNavigate, useSearchParams } from "react-router"; -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Cable, Network, Plus, Search } from "lucide-react"; import { api, type BusGatewayRow, type IntegrationRow } from "../../api"; import { Can, useSessionCan } from "../../auth/guards"; import { PageHeader } from "../../components/layout/PageHeader"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; +import { Select } from "../../components/ui/forms"; +import { Pagination } from "../../components/ui/Pagination"; +import { SearchSelect } from "../../components/ui/SearchSelect"; import { Table } from "../../components/ui/Table"; import { LinkListCell, @@ -18,13 +21,31 @@ import { matchSummary } from "../../lib/match"; * Bus gateways — messages picked off the bus. A gateway listens for one * information type; its routes decide which integration handles which message. */ +const PAGE_SIZE = 25; + +const STATUS_OPTIONS = [ + { value: "", label: "Any status" }, + { value: "false", label: "Active" }, + { value: "true", label: "Deactivated" }, +]; + export function BusGatewaysPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const q = searchParams.get("q") ?? ""; + const informationTypeId = searchParams.get("informationTypeId") + ? Number(searchParams.get("informationTypeId")) + : null; + const inactiveParam = searchParams.get("inactive"); + const inactive = inactiveParam === "true" ? true : inactiveParam === "false" ? false : null; + const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; const canSeeInfoTypes = useSessionCan("documents.view"); - const gateways = useQuery({ queryKey: ["bus-gateways"], queryFn: () => api.listBusGateways() }); + const gateways = useQuery({ + queryKey: ["bus-gateways-search", q, informationTypeId, inactive, offset], + queryFn: () => api.searchBusGateways({ search: q, informationTypeId, inactive, offset, limit: PAGE_SIZE }), + placeholderData: keepPreviousData, + }); const integrationsById = useIntegrationRowsById(); const infoTypes = useQuery({ @@ -34,26 +55,20 @@ export function BusGatewaysPage() { }).data ?? []; const infoTypeById = useMemo(() => new Map(infoTypes.map((t) => [t.id, t])), [infoTypes]); - const setQ = (value: string) => + const setParam = (key: string, value: string | null, resetOffset = true) => setSearchParams( (prev) => { const next = new URLSearchParams(prev); - if (value) next.set("q", value); - else next.delete("q"); + if (value) next.set(key, value); + else next.delete(key); + if (resetOffset) next.delete("offset"); return next; }, { replace: true }, ); - const filtered = useMemo(() => { - const needle = q.trim().toLowerCase(); - return (gateways.data ?? []).filter( - (g) => - !needle || - g.name.toLowerCase().includes(needle) || - g.informationTypeCode.toLowerCase().includes(needle), - ); - }, [gateways.data, q]); + const rows = gateways.data?.result ?? []; + const total = gateways.data?.total ?? 0; return (
@@ -76,30 +91,66 @@ export function BusGatewaysPage() { } /> -
- - setQ(e.target.value)} - placeholder="Search gateways" - aria-label="Search bus gateways" - className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" - /> +
+
+ + setParam("q", e.target.value || null)} + placeholder="Search gateways" + aria-label="Search bus gateways" + className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" + /> +
+ {canSeeInfoTypes && ( +
+ setParam("informationTypeId", v || null)} + options={infoTypes.map((t) => ({ value: String(t.id), label: t.name, code: t.code }))} + /> +
+ )} +
+
g.id} minWidth="min-w-200" onRowClick={(g) => navigate(`/bus-gateways/${g.id}`)} + footer={ + setParam("offset", String(o), false)} + /> + } columns={[ { header: "Gateway", diff --git a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx index 4ed8a0a8..672ffcf9 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { FileText, Plus, Search } from "lucide-react"; import { api } from "../../api"; import { Can } from "../../auth/guards"; @@ -8,36 +8,40 @@ import { InformationTypeDialog } from "../../components/config/InformationTypeDi import { PageHeader } from "../../components/layout/PageHeader"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { CodeBadge } from "../../components/ui/Panel"; +import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; import { UsedByCell, useIntegrationsCache } from "../../components/config/shared"; +const PAGE_SIZE = 25; + export function InformationTypesPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const [creating, setCreating] = useState(false); const q = searchParams.get("q") ?? ""; + const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; - const types = useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }); + const types = useQuery({ + queryKey: ["information-types-search", q, offset], + queryFn: () => api.searchInformationTypes({ search: q, offset, limit: PAGE_SIZE }), + placeholderData: keepPreviousData, + }); const integrations = useIntegrationsCache().data ?? []; - const setParam = (key: string, value: string | null) => + const setParam = (key: string, value: string | null, resetOffset = true) => setSearchParams( (prev) => { const next = new URLSearchParams(prev); if (value) next.set(key, value); else next.delete(key); + if (resetOffset) next.delete("offset"); return next; }, { replace: key === "q" }, ); - const filtered = useMemo(() => { - const needle = q.trim().toLowerCase(); - return (types.data ?? []).filter( - (t) => - !needle || t.name.toLowerCase().includes(needle) || (t.code ?? "").toLowerCase().includes(needle), - ); - }, [types.data, q]); + const rows = types.data?.result ?? []; + const total = types.data?.total ?? 0; return (
@@ -76,7 +80,7 @@ export function InformationTypesPage() { type="search" value={q} onChange={(e) => setParam("q", e.target.value || null)} - placeholder="Search by name or code" + placeholder="Search by name" aria-label="Search information types" className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" /> @@ -84,16 +88,24 @@ export function InformationTypesPage() { {types.isPending ? ( - ) : filtered.length === 0 ? ( + ) : rows.length === 0 ? ( } title={q ? "No information types match" : "No information types yet"}> {q ? "Try a different search." : "Define the first kind of document your integrations will carry."} ) : (
t.id} minWidth="min-w-220" onRowClick={(t) => navigate(`/information-types/${t.id}`)} + footer={ + setParam("offset", String(o), false)} + /> + } columns={[ { header: "Code", cell: (t) => }, { header: "Name", cell: (t) => {t.name} }, diff --git a/SW.Bitween.Web/ClientApp/src/pages/integrations/IntegrationsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/integrations/IntegrationsPage.tsx index 682b8478..7a1e1f09 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/integrations/IntegrationsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/integrations/IntegrationsPage.tsx @@ -1,11 +1,13 @@ -import { useMemo } from "react"; import { Link, useNavigate, useSearchParams } from "react-router"; -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Search, Workflow } from "lucide-react"; import { api, type IntegrationRow, type IntegrationType } from "../../api"; import { useSessionCan } from "../../auth/guards"; import { PageHeader } from "../../components/layout/PageHeader"; import { EmptyState, LoadingBlock } from "../../components/ui/basics"; +import { Pagination } from "../../components/ui/Pagination"; +import { SearchSelect } from "../../components/ui/SearchSelect"; +import { Select } from "../../components/ui/forms"; import { Table } from "../../components/ui/Table"; import { HealthBadge, @@ -16,6 +18,12 @@ import { useGatewayPartners, } from "../../components/config/shared"; +const STATUS_OPTIONS = [ + { value: "", label: "Any status" }, + { value: "false", label: "Active" }, + { value: "true", label: "Disabled" }, +]; + /** Filter order: what you'll have most of first, legacy last. */ const TYPE_ORDER: IntegrationType[] = [ "Receiving", @@ -36,15 +44,39 @@ const TYPE_ORDER: IntegrationType[] = [ * Scheduled jobs, deliberately: here for the complete picture, there for the * schedule-specific columns. */ +const PAGE_SIZE = 25; + export function IntegrationsPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const q = searchParams.get("q") ?? ""; const type = searchParams.get("type") as IntegrationType | null; + const informationTypeId = searchParams.get("informationTypeId") + ? Number(searchParams.get("informationTypeId")) + : null; + const partnerId = searchParams.get("partnerId") ? Number(searchParams.get("partnerId")) : null; + const inactiveParam = searchParams.get("inactive"); + const inactive = inactiveParam === "true" ? true : inactiveParam === "false" ? false : null; + const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; const canSeeInfoTypes = useSessionCan("documents.view"); - const rows = useQuery({ queryKey: ["integration-rows"], queryFn: () => api.listIntegrationRows() }); + const rows = useQuery({ + queryKey: ["integration-rows-search", q, type, informationTypeId, partnerId, inactive, offset], + queryFn: () => + api.searchIntegrationRows({ + search: q, + type, + informationTypeId, + partnerId, + inactive, + offset, + limit: PAGE_SIZE, + }), + placeholderData: keepPreviousData, + }); const gatewayPartners = useGatewayPartners(); + const infoTypes = useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }).data ?? []; + const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners() }).data ?? []; /** Its own partner (legacy types) plus any reached through a gateway. */ const partnersFor = (r: IntegrationRow) => { @@ -53,35 +85,20 @@ export function IntegrationsPage() { return [...own, ...viaGateway]; }; - const setParam = (key: string, value: string | null) => + const setParam = (key: string, value: string | null, resetOffset = true) => setSearchParams( (prev) => { const next = new URLSearchParams(prev); if (value) next.set(key, value); else next.delete(key); + if (resetOffset) next.delete("offset"); return next; }, { replace: key === "q" }, ); - // Only offer a type you actually have — an empty filter teaches nothing. - const presentTypes = useMemo(() => { - const present = new Set((rows.data ?? []).map((r) => r.type)); - return TYPE_ORDER.filter((t) => present.has(t)); - }, [rows.data]); - - const filtered = useMemo(() => { - const needle = q.trim().toLowerCase(); - return (rows.data ?? []) - .filter((r) => !type || r.type === type) - .filter( - (r) => - !needle || - r.name.toLowerCase().includes(needle) || - r.informationTypeCode.toLowerCase().includes(needle), - ) - .sort((a, b) => a.name.localeCompare(b.name)); - }, [rows.data, type, q]); + const filtered = rows.data?.result ?? []; + const total = rows.data?.total ?? 0; return (
@@ -123,7 +140,7 @@ export function IntegrationsPage() { > All - {presentTypes.map((t) => ( + {TYPE_ORDER.map((t) => (
+
+ setParam("informationTypeId", v || null)} + options={infoTypes.map((t) => ({ value: String(t.id), label: t.name, code: t.code }))} + /> + setParam("partnerId", v || null)} + options={partners.map((p) => ({ value: String(p.id), label: p.name }))} + /> +
p.id} minWidth="min-w-200" onRowClick={(p) => navigate(`/partners/${p.id}`)} + footer={ + setParam("offset", String(o), false)} + /> + } columns={[ { header: "Partner", cell: (p) => ( {p.name} - {p.isSystem && Built-in} + {p.isSystem && ( + + Built-in + + )} ), }, diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx index c7e897df..95fa788b 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState, type FormEvent } from "react"; +import { useState, type FormEvent } from "react"; import { useNavigate, useSearchParams } from "react-router"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus, RotateCcw, Search } from "lucide-react"; import { api } from "../../api"; import { Can } from "../../auth/guards"; @@ -8,6 +8,7 @@ import { PageHeader } from "../../components/layout/PageHeader"; import { Button, EmptyState, FormError, LoadingBlock } from "../../components/ui/basics"; import { Field, TextInput } from "../../components/ui/forms"; import { Dialog } from "../../components/ui/overlays"; +import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; import { UsedByCell, useIntegrationsCache } from "../../components/config/shared"; @@ -54,30 +55,36 @@ function CreateRetryPolicyDialog({ onClose }: { onClose: () => void }) { ); } +const PAGE_SIZE = 25; + export function RetryPoliciesPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const q = searchParams.get("q") ?? ""; const creating = searchParams.get("new") === "1"; + const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; - const policies = useQuery({ queryKey: ["retry-policies"], queryFn: () => api.listRetryPolicies() }); + const policies = useQuery({ + queryKey: ["retry-policies-search", q, offset], + queryFn: () => api.searchRetryPolicies({ search: q, offset, limit: PAGE_SIZE }), + placeholderData: keepPreviousData, + }); const integrations = useIntegrationsCache().data ?? []; - const setParam = (key: string, value: string | null) => + const setParam = (key: string, value: string | null, resetOffset = key === "q") => setSearchParams( (prev) => { const next = new URLSearchParams(prev); if (value) next.set(key, value); else next.delete(key); + if (resetOffset) next.delete("offset"); return next; }, { replace: key === "q" }, ); - const filtered = useMemo(() => { - const needle = q.trim().toLowerCase(); - return (policies.data ?? []).filter((p) => !needle || p.name.toLowerCase().includes(needle)); - }, [policies.data, q]); + const rows = policies.data?.result ?? []; + const total = policies.data?.total ?? 0; return (
@@ -123,16 +130,24 @@ export function RetryPoliciesPage() { {policies.isPending ? ( - ) : filtered.length === 0 ? ( + ) : rows.length === 0 ? ( } title={q ? "No policies match" : "No retry policies yet"}> {q ? "Try a different search." : "Create a policy to control what happens after failures."} ) : (
p.id} minWidth="min-w-130" onRowClick={(p) => navigate(`/retry-policies/${p.id}`)} + footer={ + setParam("offset", String(o), false)} + /> + } columns={[ { header: "Policy", cell: (p) => {p.name} }, { diff --git a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx index 173a58f4..7d6689dd 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx @@ -1,12 +1,13 @@ import { useMemo, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CalendarClock, DownloadCloud, Plus, Search } from "lucide-react"; import { api, type IntegrationRow, type ScheduleHealth } from "../../api"; import { Can, useSessionCan } from "../../auth/guards"; import { PageHeader } from "../../components/layout/PageHeader"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { ConfirmDialog } from "../../components/ui/overlays"; +import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; import { HealthBadge, @@ -26,6 +27,7 @@ function ReceiveNowButton({ job }: { job: IntegrationRow }) { mutationFn: () => api.receiveNow(job.id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["integration-rows"] }); + void queryClient.invalidateQueries({ queryKey: ["integration-rows-search"] }); void queryClient.invalidateQueries({ queryKey: ["last-runs"] }); }, }); @@ -81,14 +83,21 @@ function ScheduleFault({ health }: { health: ScheduleHealth | undefined }) { * Last run comes from the scheduler's own execution history (kept ~30 days); * next run is Bitween's own `ReceiveOn`. */ +const PAGE_SIZE = 25; + export function ScheduledJobsPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const q = searchParams.get("q") ?? ""; + const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; const canOperate = useSessionCan("subscriptions.operate"); const canSeeInfoTypes = useSessionCan("documents.view"); - const rows = useQuery({ queryKey: ["integration-rows"], queryFn: () => api.listIntegrationRows() }); + const rows = useQuery({ + queryKey: ["integration-rows-search", "Receiving", q, offset], + queryFn: () => api.searchIntegrationRows({ search: q, type: "Receiving", offset, limit: PAGE_SIZE }), + placeholderData: keepPreviousData, + }); // The list rows don't carry work group or retry policy; the integrations // cache does, and every page already holds it. const setups = useIntegrationsCache().data ?? []; @@ -102,28 +111,20 @@ export function ScheduledJobsPage() { useQuery({ queryKey: ["schedule-health"], queryFn: () => api.listScheduleHealth() }).data ?? []; const healthById = useMemo(() => new Map(health.map((h) => [h.integrationId, h])), [health]); - const setQ = (value: string) => + const setParam = (key: string, value: string | null, resetOffset = true) => setSearchParams( (prev) => { const next = new URLSearchParams(prev); - if (value) next.set("q", value); - else next.delete("q"); + if (value) next.set(key, value); + else next.delete(key); + if (resetOffset) next.delete("offset"); return next; }, { replace: true }, ); - const filtered = useMemo(() => { - const needle = q.trim().toLowerCase(); - return (rows.data ?? []) - .filter((r) => r.type === "Receiving") - .filter( - (r) => - !needle || - r.name.toLowerCase().includes(needle) || - r.informationTypeCode.toLowerCase().includes(needle), - ); - }, [rows.data, q]); + const filtered = rows.data?.result ?? []; + const total = rows.data?.total ?? 0; return (
@@ -144,7 +145,7 @@ export function ScheduledJobsPage() { setQ(e.target.value)} + onChange={(e) => setParam("q", e.target.value || null)} placeholder="Search jobs" aria-label="Search scheduled jobs" className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" @@ -163,6 +164,14 @@ export function ScheduledJobsPage() { rowKey={(r) => r.id} minWidth="min-w-270" onRowClick={(r) => navigate(`/subscriptions/${r.id}`)} + footer={ + setParam("offset", String(o), false)} + /> + } columns={[ { header: "Job", diff --git a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupPage.tsx index 1079ffb3..52feed74 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupPage.tsx @@ -74,6 +74,7 @@ export function WorkGroupPage() { // Await the detail refetch before re-syncing the draft (avoids stale-data race). await queryClient.invalidateQueries({ queryKey: ["work-group", groupId] }); void queryClient.invalidateQueries({ queryKey: ["work-groups"] }); + void queryClient.invalidateQueries({ queryKey: ["work-groups-search"] }); setLoaded(false); }, }); @@ -146,6 +147,7 @@ export function WorkGroupPage() { onConfirm={async () => { await api.deleteWorkGroup(groupId); void queryClient.invalidateQueries({ queryKey: ["work-groups"] }); + void queryClient.invalidateQueries({ queryKey: ["work-groups-search"] }); navigate("/work-groups"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx index db59a1cf..a10ea624 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { ArrowUpRight, Layers, Plus, Search } from "lucide-react"; @@ -7,8 +7,9 @@ import { Can, useSessionCan } from "../../auth/guards"; import { WorkGroupDialog } from "../../components/config/WorkGroupDialog"; import { PageHeader } from "../../components/layout/PageHeader"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; +import { Pagination } from "../../components/ui/Pagination"; import { Table, type Column } from "../../components/ui/Table"; -import { UsedByCell, useIntegrationsCache } from "../../components/config/shared"; +import { UsedByCell, queueHealthTitle, useIntegrationsCache } from "../../components/config/shared"; /** * The live RabbitMQ numbers, as columns rather than a per-row drill-down. @@ -28,11 +29,11 @@ function liveColumns(snapshot: QueueHealthSnapshot | undefined): Column—; return c.health === "critical" ? ( - Critical + Critical ) : c.health === "warning" ? ( - Warning + Warning ) : ( - Healthy + Healthy ); }, }, @@ -46,14 +47,21 @@ function liveColumns(snapshot: QueueHealthSnapshot | undefined): Column api.listWorkGroups() }); + const groups = useQuery({ + queryKey: ["work-groups-search", q, offset], + queryFn: () => api.searchWorkGroups({ search: q, offset, limit: PAGE_SIZE }), + placeholderData: keepPreviousData, + }); const integrations = useIntegrationsCache().data ?? []; const live = useQuery({ queryKey: ["queue-health"], @@ -63,23 +71,20 @@ export function WorkGroupsPage() { enabled: canMonitor, }); - const setParam = (key: string, value: string | null) => + const setParam = (key: string, value: string | null, resetOffset = true) => setSearchParams( (prev) => { const next = new URLSearchParams(prev); if (value) next.set(key, value); else next.delete(key); + if (resetOffset) next.delete("offset"); return next; }, { replace: true }, ); - const filtered = useMemo(() => { - const needle = q.trim().toLowerCase(); - return (groups.data ?? []).filter( - (g) => !needle || g.name.toLowerCase().includes(needle) || g.busMessageName.toLowerCase().includes(needle), - ); - }, [groups.data, q]); + const filtered = groups.data?.result ?? []; + const total = groups.data?.total ?? 0; return (
@@ -133,6 +138,14 @@ export function WorkGroupsPage() { rowKey={(g) => g.id} minWidth="min-w-220" onRowClick={(g) => navigate(`/work-groups/${g.id}`)} + footer={ + setParam("offset", String(o), false)} + /> + } columns={[ { header: "Name", cell: (g) => {g.name} }, { From a0f5fe2f1df5baeba56457d352f4c721dd53802f Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 24 Aug 2026 14:43:25 +0300 Subject: [PATCH 53/54] feat: define the integration a gateway attachment or route points at, inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attaching a partner or adding a route used to need the integration to already exist — a modal dialog on the bus gateway canvas, or a plain picker on the API gateway side, either way a worse copy of the integration's own pipeline for anyone who wanted more than a name and a delivery. Both now offer "define it here" instead: the full studio pipeline, staged in the same request and committed in one transaction with whatever points at it, so a failure can't leave an integration nothing points at, or a route pointing at nothing. --- .../Resources/ApiGateways/AddPartner.cs | 63 ++-- .../Resources/ApiGateways/UpdatePartner.cs | 8 +- .../Resources/BusGateways/AddRoute.cs | 26 +- .../Resources/BusGateways/UpdateRoute.cs | 10 +- .../Subscriptions/InlineIntegration.cs | 123 +++++++ SW.Bitween.Sdk/Model/ApiGateway.cs | 9 +- SW.Bitween.Sdk/Model/BusGateway.cs | 16 +- SW.Bitween.Sdk/Model/Subscription.cs | 14 + SW.Bitween.Web/ClientApp/src/api/client.ts | 10 +- .../ClientApp/src/api/http/gateways.ts | 35 +- .../src/api/http/subscriptionBody.ts | 61 ++++ SW.Bitween.Web/ClientApp/src/api/types.ts | 24 ++ .../src/components/config/AdapterConfig.tsx | 3 +- .../src/components/config/pickers.tsx | 18 +- .../pages/api-gateways/AttachPartnerPage.tsx | 49 ++- .../NewGatewayIntegrationPage.tsx | 318 ++++++++++++++++++ .../src/pages/bus-gateways/BusGatewayPage.tsx | 122 +++++-- .../src/pages/bus-gateways/studio/Canvas.tsx | 23 +- .../pages/bus-gateways/studio/Inspector.tsx | 25 +- .../pages/bus-gateways/studio/QuickCreate.tsx | 117 ------- .../pages/bus-gateways/studio/RouteList.tsx | 4 +- .../src/pages/bus-gateways/studio/model.ts | 9 + .../src/pages/integrations/studio/faces.ts | 20 +- .../src/pages/integrations/studio/model.ts | 34 ++ SW.Bitween.Web/ClientApp/src/router.tsx | 9 + 25 files changed, 918 insertions(+), 232 deletions(-) create mode 100644 SW.Bitween.Api/Resources/Subscriptions/InlineIntegration.cs create mode 100644 SW.Bitween.Web/ClientApp/src/api/http/subscriptionBody.ts create mode 100644 SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewayIntegrationPage.tsx delete mode 100644 SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/QuickCreate.tsx diff --git a/SW.Bitween.Api/Resources/ApiGateways/AddPartner.cs b/SW.Bitween.Api/Resources/ApiGateways/AddPartner.cs index 764460ec..55edeed6 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/AddPartner.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/AddPartner.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using System.Linq; using SW.Bitween.Domain; +using SW.Bitween.Resources.Subscriptions; namespace SW.Bitween.Resources.ApiGateways { @@ -13,11 +14,14 @@ public class AddPartner : ICommandHandler { private readonly BitweenDbContext _dbContext; private readonly RequestContext _requestContext; + private readonly AdapterRequirements _adapterRequirements; - public AddPartner(BitweenDbContext dbContext, RequestContext requestContext) + public AddPartner(BitweenDbContext dbContext, RequestContext requestContext, + AdapterRequirements adapterRequirements) { _dbContext = dbContext; _requestContext = requestContext; + _adapterRequirements = adapterRequirements; } public async Task Handle(int gatewayId, ApiGatewayPartnerCreate model) @@ -31,31 +35,50 @@ public async Task Handle(int gatewayId, ApiGatewayPartnerCreate model) if (gateway == null) throw new SWNotFoundException($"ApiGateway with Id {gatewayId} not found"); - // Validate subscription exists and is of type GatewayApiCall - var subscription = await _dbContext.Set() - .FirstOrDefaultAsync(s => s.Id == model.SubscriptionId); - - if (subscription == null) - throw new SWNotFoundException($"Subscription with Id {model.SubscriptionId} not found"); - - if (subscription.Type != SubscriptionType.GatewayApiCall) - throw new SWException($"Subscription must be of type GatewayApiCall. Current type: {subscription.Type}"); - - // Check if partner already exists - var existingPartner = gateway.Partners != null - ? gateway.Partners.FirstOrDefault(p => p.PartnerId == model.PartnerId && p.SubscriptionId == model.SubscriptionId) - : null; - - if (existingPartner != null) - throw new SWException("Partner already exists in this gateway"); + InlineIntegration.EnsureExactlyOne(model.SubscriptionId, model.NewIntegration); var partnerLink = new ApiGatewayPartner { ApiGatewayId = gatewayId, - PartnerId = model.PartnerId, - SubscriptionId = model.SubscriptionId + PartnerId = model.PartnerId }; + if (model.NewIntegration != null) + { + // Staged, not saved — the attachment takes its foreign key from the subscription EF + // is tracking, so the pair lands on the one SaveChangesAsync below or not at all. + // Nothing to check for a duplicate against: an integration that does not exist yet + // cannot already be attached. + // An API gateway is not bound to an information type the way a bus gateway is, + // so this one comes from the caller. + var integration = await InlineIntegration.Stage( + _dbContext, _adapterRequirements, model.NewIntegration, + model.NewIntegration.DocumentId, SubscriptionType.GatewayApiCall); + partnerLink.Subscription = integration; + } + else + { + // Validate subscription exists and is of type GatewayApiCall + var subscription = await _dbContext.Set() + .FirstOrDefaultAsync(s => s.Id == model.SubscriptionId.Value); + + if (subscription == null) + throw new SWNotFoundException($"Subscription with Id {model.SubscriptionId} not found"); + + if (subscription.Type != SubscriptionType.GatewayApiCall) + throw new SWException($"Subscription must be of type GatewayApiCall. Current type: {subscription.Type}"); + + // Check if partner already exists + var existingPartner = gateway.Partners != null + ? gateway.Partners.FirstOrDefault(p => p.PartnerId == model.PartnerId && p.SubscriptionId == model.SubscriptionId) + : null; + + if (existingPartner != null) + throw new SWException("Partner already exists in this gateway"); + + partnerLink.SubscriptionId = model.SubscriptionId.Value; + } + _dbContext.Add(partnerLink); await _dbContext.SaveChangesAsync(); diff --git a/SW.Bitween.Api/Resources/ApiGateways/UpdatePartner.cs b/SW.Bitween.Api/Resources/ApiGateways/UpdatePartner.cs index ac5b0733..03f543b0 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/UpdatePartner.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/UpdatePartner.cs @@ -32,6 +32,12 @@ public async Task Handle(int gatewayId, ApiGatewayPartnerCreate model) throw new SWNotFoundException($"ApiGateway with Id {gatewayId} not found"); // Validate subscription exists and is of type GatewayApiCall + // Repointing an existing attachment always names an integration that already + // exists; defining one inline is only for the attachment being created. + if (!model.SubscriptionId.HasValue) + throw new SWValidationException(GatewayLinkTarget.NeitherGiven, + "Pick the integration this partner runs."); + var subscription = await _dbContext.Set() .FirstOrDefaultAsync(s => s.Id == model.SubscriptionId); @@ -47,7 +53,7 @@ public async Task Handle(int gatewayId, ApiGatewayPartnerCreate model) if (partnerLink == null) throw new SWNotFoundException($"Partner with Id {model.PartnerId} not found in gateway {gatewayId}"); - partnerLink.SubscriptionId = model.SubscriptionId; + partnerLink.SubscriptionId = model.SubscriptionId.Value; await _dbContext.SaveChangesAsync(); diff --git a/SW.Bitween.Api/Resources/BusGateways/AddRoute.cs b/SW.Bitween.Api/Resources/BusGateways/AddRoute.cs index 57ac2bfd..3d713c97 100644 --- a/SW.Bitween.Api/Resources/BusGateways/AddRoute.cs +++ b/SW.Bitween.Api/Resources/BusGateways/AddRoute.cs @@ -2,6 +2,7 @@ using SW.Bitween.Domain; using SW.Bitween.Domain.Gateway; using SW.Bitween.Model; +using SW.Bitween.Resources.Subscriptions; using SW.PrimitiveTypes; using System.Threading.Tasks; @@ -14,11 +15,15 @@ public class AddRoute : ICommandHandler private readonly RequestContext _requestContext; private readonly IInfolinkCache _cache; - public AddRoute(BitweenDbContext dbContext, RequestContext requestContext, IInfolinkCache cache) + private readonly AdapterRequirements _adapterRequirements; + + public AddRoute(BitweenDbContext dbContext, RequestContext requestContext, IInfolinkCache cache, + AdapterRequirements adapterRequirements) { _dbContext = dbContext; _requestContext = requestContext; _cache = cache; + _adapterRequirements = adapterRequirements; } public async Task Handle(int gatewayId, BusGatewayRouteCreate model) @@ -31,17 +36,32 @@ public async Task Handle(int gatewayId, BusGatewayRouteCreate model) if (gateway == null) throw new SWNotFoundException($"BusGateway with Id {gatewayId} not found"); - await ValidateSubscription(_dbContext, model.SubscriptionId, gateway.DocumentId); + InlineIntegration.EnsureExactlyOne(model.SubscriptionId, model.NewIntegration); await ValidatePartner(_dbContext, model.PartnerId); var route = new BusGatewayRoute { BusGatewayId = gatewayId, - SubscriptionId = model.SubscriptionId, PartnerId = model.PartnerId, MatchExpression = model.MatchExpression }; + if (model.NewIntegration != null) + { + // Staged, not saved: EF fills the route's foreign key from the subscription it is + // tracking, so both rows go in on the one SaveChangesAsync below. A route pointing + // at an integration that was never committed is not a state that can happen. + var integration = await InlineIntegration.Stage( + _dbContext, _adapterRequirements, model.NewIntegration, gateway.DocumentId, + SubscriptionType.BusGateway); + route.Subscription = integration; + } + else + { + await ValidateSubscription(_dbContext, model.SubscriptionId.Value, gateway.DocumentId); + route.SubscriptionId = model.SubscriptionId.Value; + } + _dbContext.Add(route); await _dbContext.SaveChangesAsync(); await _cache.BroadcastRevoke(); diff --git a/SW.Bitween.Api/Resources/BusGateways/UpdateRoute.cs b/SW.Bitween.Api/Resources/BusGateways/UpdateRoute.cs index 32b6597d..5cb4eeb1 100644 --- a/SW.Bitween.Api/Resources/BusGateways/UpdateRoute.cs +++ b/SW.Bitween.Api/Resources/BusGateways/UpdateRoute.cs @@ -36,10 +36,16 @@ public async Task Handle(int gatewayId, BusGatewayRouteUpdate model) if (route == null) throw new SWNotFoundException($"Route with Id {model.RouteId} not found in gateway {gatewayId}"); - await AddRoute.ValidateSubscription(_dbContext, model.SubscriptionId, gateway.DocumentId); + // Repointing an existing route always names an integration that already exists; + // defining one inline is only for the route being created. + if (!model.SubscriptionId.HasValue) + throw new SWValidationException(GatewayLinkTarget.NeitherGiven, + "Pick the integration this route runs."); + + await AddRoute.ValidateSubscription(_dbContext, model.SubscriptionId.Value, gateway.DocumentId); await AddRoute.ValidatePartner(_dbContext, model.PartnerId); - route.SubscriptionId = model.SubscriptionId; + route.SubscriptionId = model.SubscriptionId.Value; route.PartnerId = model.PartnerId; route.MatchExpression = model.MatchExpression; diff --git a/SW.Bitween.Api/Resources/Subscriptions/InlineIntegration.cs b/SW.Bitween.Api/Resources/Subscriptions/InlineIntegration.cs new file mode 100644 index 00000000..212c86eb --- /dev/null +++ b/SW.Bitween.Api/Resources/Subscriptions/InlineIntegration.cs @@ -0,0 +1,123 @@ +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using System.Text.RegularExpressions; +using SW.Bitween.Domain; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Subscriptions +{ + /// + /// Turns an integration defined inline — while a gateway route or attachment is being made — + /// into a subscription, ready to be saved alongside whatever points at it. + /// + /// Nothing here duplicates : the pipeline is applied by the very same + /// , and the caller does the single + /// SaveChangesAsync, so the integration and its link either both exist or neither does. + /// + /// + public static class InlineIntegration + { + /// + /// Which integration a gateway link points at. Exactly one of an existing id and an + /// inline definition has to be given — both, or neither, is a mistake worth naming. + /// + public static void EnsureExactlyOne(int? subscriptionId, InlineIntegrationCreate inline) + { + if (subscriptionId.HasValue && inline != null) + throw new SWValidationException(GatewayLinkTarget.BothGiven, + "Give either an existing integration or a new one to create, not both."); + + if (!subscriptionId.HasValue && inline == null) + throw new SWValidationException(GatewayLinkTarget.NeitherGiven, + "Pick the integration this runs, or define a new one."); + } + + + /// + /// The rules an ordinary create enforces through FluentValidation, applied to an integration + /// arriving this way. Without them this door was a hole in the same validation: a response + /// message name with a space in it went straight to a RabbitMQ routing key nothing can + /// answer. Each check calls the one implementation the create handler calls. + /// + private static async Task CheckConfiguration( + BitweenDbContext dbContext, + AdapterRequirements adapterRequirements, + InlineIntegrationCreate model) + { + if (!string.IsNullOrEmpty(model.ResponseMessageTypeName) + && Regex.IsMatch(model.ResponseMessageTypeName, @"\s")) + throw new SWValidationException("INVALID_BUS_TYPE_NAME", + "A bus message name cannot contain spaces."); + + var responseFailure = await ResponseRoutingValidation.CheckDestination( + dbContext, model.ResponseSubscriptionId); + if (responseFailure != null) + throw new SWValidationException( + ResponseRoutingValidation.BusGatewayCode, responseFailure); + + // Neither gateway type carries its own partner — a partner reaches them through the + // attachment or the route, which is the very thing being made. + if (model.PartnerId.HasValue) + throw new SWValidationException("PARTNER_NOT_ALLOWED", + "A gateway integration does not carry its own partner."); + + // A named adapter has to be usable. Naming none is still fine; a half-configured one + // is exactly what committing here would make permanent. + foreach (var (kind, adapterId, provided) in new[] + { + ("receiver", model.ReceiverId, model.ReceiverProperties), + ("validator", model.ValidatorId, model.ValidatorProperties), + ("mapper", model.MapperId, model.MapperProperties), + ("handler", model.HandlerId, model.HandlerProperties), + }) + { + var missing = await adapterRequirements.MissingFor(adapterId, provided); + if (missing.Count > 0) + throw new SWValidationException("ADAPTER_INCOMPLETE", + $"The {kind} is missing {string.Join(", ", missing)}."); + } + } + + /// + /// Builds the subscription and adds it to the change tracker, so its id is available to + /// the link being created in the same transaction. Does not save. + /// + public static async Task Stage( + BitweenDbContext dbContext, + AdapterRequirements adapterRequirements, + InlineIntegrationCreate model, + int documentId, + SubscriptionType type) + { + if (string.IsNullOrWhiteSpace(model.Name)) + throw new SWValidationException("INVALID_NAME", "Give the integration a name."); + + await CheckConfiguration(dbContext, adapterRequirements, model); + + // Who chooses the information type differs by gateway kind, so the caller passes it: + // a bus gateway is bound to one and imposes it, an API gateway is not and the caller + // picks. Either way it is settled before Apply runs. + if (!await dbContext.Set().AnyAsync(d => d.Id == documentId)) + throw new SWValidationException("INVALID_DOCUMENT", + "Choose the information type this integration carries."); + + model.DocumentId = documentId; + + var entity = new Subscription(model.Name, documentId, type); + var trail = new SubscriptionTrail(SubscriptionTrialCode.Created, entity, true); + dbContext.Add(trail); + + // The same code an ordinary create runs, so a field cannot work through one door + // and not the other. + await SubscriptionConfigurationApplier.Apply(dbContext, entity, model); + + // Neither gateway type runs on its own — a GatewayApiCall waits for an attachment, a + // BusGateway for a route — and the one being made is in this same transaction. + entity.Inactive = false; + + dbContext.Add(entity); + return entity; + } + } +} diff --git a/SW.Bitween.Sdk/Model/ApiGateway.cs b/SW.Bitween.Sdk/Model/ApiGateway.cs index 0ef7eeec..2bd43160 100644 --- a/SW.Bitween.Sdk/Model/ApiGateway.cs +++ b/SW.Bitween.Sdk/Model/ApiGateway.cs @@ -34,7 +34,14 @@ public class ApiGatewayPartnerDto public class ApiGatewayPartnerCreate { public int PartnerId { get; set; } - public int SubscriptionId { get; set; } + + /// An integration that already exists. Exactly one of this and + /// is given. + public int? SubscriptionId { get; set; } + + /// Define the integration here instead of creating it first. It is created as a + /// GatewayApiCall in the same transaction as the attachment. + public InlineIntegrationCreate NewIntegration { get; set; } } public class SearchApiGatewayAttachmentsModel diff --git a/SW.Bitween.Sdk/Model/BusGateway.cs b/SW.Bitween.Sdk/Model/BusGateway.cs index 745ff918..9e103156 100644 --- a/SW.Bitween.Sdk/Model/BusGateway.cs +++ b/SW.Bitween.Sdk/Model/BusGateway.cs @@ -36,7 +36,14 @@ public class BusGatewayRouteDto public class BusGatewayRouteCreate { - public int SubscriptionId { get; set; } + /// An integration that already exists. Exactly one of this and + /// is given. + public int? SubscriptionId { get; set; } + + /// Define the integration here instead of creating it first. It is created + /// carrying the gateway's own information type, in the same transaction as the route. + public InlineIntegrationCreate NewIntegration { get; set; } + public int? PartnerId { get; set; } public IPropertyMatchSpecification MatchExpression { get; set; } } @@ -46,6 +53,13 @@ public class BusGatewayRouteUpdate : BusGatewayRouteCreate public int RouteId { get; set; } } + /// Shared by the two gateway kinds: which integration a link points at. + public static class GatewayLinkTarget + { + public const string BothGiven = "INTEGRATION_AMBIGUOUS"; + public const string NeitherGiven = "INTEGRATION_REQUIRED"; + } + public class RemoveRouteRequest { public int RouteId { get; set; } diff --git a/SW.Bitween.Sdk/Model/Subscription.cs b/SW.Bitween.Sdk/Model/Subscription.cs index 614facf7..1ab28e63 100644 --- a/SW.Bitween.Sdk/Model/Subscription.cs +++ b/SW.Bitween.Sdk/Model/Subscription.cs @@ -163,6 +163,20 @@ public abstract class SubscriptionConfiguration : SubscriptionCreateUpdateBase /// and is optional — a caller /// that sends only those still gets the empty, inactive subscription it always did. /// + /// + /// An integration defined while it is being wired up, so the integration and the thing + /// that points at it land in one transaction instead of two calls that can half-succeed. + /// + /// Deriving from is the point: the whole pipeline + /// is applied by the same code an ordinary create uses. The type is always the gateway's. + /// DocumentId is ignored for a bus gateway, which is bound to one information type and + /// imposes it; an API gateway is not bound to one, so there it is required. + /// + /// + public class InlineIntegrationCreate : SubscriptionConfiguration + { + } + public class SubscriptionCreate : SubscriptionConfiguration { public SubscriptionType Type { get; set; } diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 9fc0f641..c861fbae 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -1,3 +1,4 @@ +import type { AddBusRouteInput, AttachPartnerInput } from "./http/gateways"; import type { AdapterInfo, AdapterKind, @@ -262,7 +263,8 @@ export interface ApiClient { changes: { name: string; urlName: string; inactive: boolean }, ): Promise; deleteApiGateway(id: number): Promise; - attachGatewayPartner(id: number, input: { partnerId: number; integrationId: number }): Promise; + /** The integration is either an existing id or defined inline; the endpoint commits both as one. */ + attachGatewayPartner(id: number, input: AttachPartnerInput): Promise; updateGatewayAttachment(id: number, input: { partnerId: number; integrationId: number }): Promise; removeGatewayAttachment(id: number, partnerId: number): Promise; @@ -279,10 +281,8 @@ export interface ApiClient { createBusGateway(input: { name: string; informationTypeId: number }): Promise; updateBusGateway(id: number, changes: { name: string; inactive: boolean }): Promise; deleteBusGateway(id: number): Promise; - addBusRoute( - id: number, - input: { integrationId: number; partnerId: number | null; matchExpression: MatchGroup | null }, - ): Promise; + /** The integration is either an existing id or defined inline; the endpoint commits both as one. */ + addBusRoute(id: number, input: AddBusRouteInput): Promise; updateBusRoute( id: number, routeId: number, diff --git a/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts b/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts index fdf5c1fe..ef76bcdd 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts @@ -8,10 +8,12 @@ import type { BusGatewayDetail, BusGatewayRoute, BusGatewayRow, + InlineIntegrationDraft, MatchGroup, Paged, } from "../types"; import { toMatchGroup, toRawMatchExpression, type RawMatchSpec } from "./matchExpression"; +import { inlineIntegrationBody } from "./subscriptionBody"; import { get, post, request } from "./request"; import { buildListQuery, SEARCHY_RULE } from "./searchQuery"; @@ -111,6 +113,21 @@ const toBusGatewayDetail = (raw: RawBusGateway): BusGatewayDetail => ({ routes: (raw.routes ?? []).map(toBusGatewayRoute), }); +/** The attachment always points at an integration that already exists — a new one is + * created on its own page first, not inline here (unlike a bus gateway route, which + * still creates one in the same transaction — see `AddBusRouteInput`). */ +export type AttachPartnerInput = { partnerId: number; integrationId: number }; + +/** + * A route points at an integration that already exists, or defines one. Exactly one, + * which the endpoint enforces — the union makes that unrepresentable rather than + * merely wrong. + */ +export type AddBusRouteInput = { + partnerId: number | null; + matchExpression: MatchGroup | null; +} & ({ integrationId: number } | { newIntegration: InlineIntegrationDraft }); + export const gatewayMethods = { // ——— API gateways ——— @@ -173,8 +190,11 @@ export const gatewayMethods = { await request(`/apigateways/${id}`, { method: "DELETE" }); }, - async attachGatewayPartner(id: number, input: { partnerId: number; integrationId: number }): Promise { - await post(`/apigateways/${id}/addpartner`, { partnerId: input.partnerId, subscriptionId: input.integrationId }); + async attachGatewayPartner(id: number, input: AttachPartnerInput): Promise { + await post(`/apigateways/${id}/addpartner`, { + partnerId: input.partnerId, + subscriptionId: input.integrationId, + }); }, async updateGatewayAttachment(id: number, input: { partnerId: number; integrationId: number }): Promise { @@ -262,12 +282,13 @@ export const gatewayMethods = { await request(`/busgateways/${id}`, { method: "DELETE" }); }, - async addBusRoute( - id: number, - input: { integrationId: number; partnerId: number | null; matchExpression: MatchGroup | null }, - ): Promise { + async addBusRoute(id: number, input: AddBusRouteInput): Promise { await post(`/busgateways/${id}/addroute`, { - subscriptionId: input.integrationId, + // Exactly one of the two, which is what the endpoint enforces. An integration + // defined here is created in the same transaction as the route. + ...("newIntegration" in input + ? { newIntegration: inlineIntegrationBody(input.newIntegration) } + : { subscriptionId: input.integrationId }), partnerId: input.partnerId, matchExpression: toRawMatchExpression(input.matchExpression), }); diff --git a/SW.Bitween.Web/ClientApp/src/api/http/subscriptionBody.ts b/SW.Bitween.Web/ClientApp/src/api/http/subscriptionBody.ts new file mode 100644 index 00000000..a8feb501 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/subscriptionBody.ts @@ -0,0 +1,61 @@ +import type { InlineIntegrationDraft, Schedule } from "../types"; +import { toRawMatchExpression } from "./matchExpression"; + +/** + * The subscription wire shape, in the one place both the integration endpoints and + * the gateway endpoints can reach. + * + * It lives here rather than in `integrations.ts` because `gateways.ts` needs it too, + * and `integrations.ts` already imports `gateways.ts` — putting it there would make + * that cycle mutual. + */ + +export interface RawKeyAndValue { + key: string; + value: string; +} + +export interface RawSchedule { + recurrence: Schedule["recurrence"]; + days: number; + hours: number; + minutes: number; + backwards: boolean; +} + +export const toKvArray = (record: Record): RawKeyAndValue[] => + Object.entries(record).map(([key, value]) => ({ key, value })); + +export const toRawSchedules = (schedules: Schedule[]): RawSchedule[] => + schedules.map((s) => ({ + recurrence: s.recurrence, + days: s.days, + hours: s.hours, + minutes: s.minutes, + backwards: s.backwards, + })); + +/** + * An integration defined on a gateway's canvas, in the shape the gateway endpoints + * take. No `documentId`: a bus gateway imposes its own, and the API-gateway caller + * adds the one its picker chose. + */ +export const inlineIntegrationBody = (d: InlineIntegrationDraft) => ({ + name: d.name.trim(), + inactive: !d.enabled, + workGroupId: d.workGroupId, + retryPolicyId: d.retryPolicyId, + customRetryPolicy: null, + receiverId: d.receiverId, + receiverProperties: toKvArray(d.receiverProperties), + validatorId: d.validatorId, + validatorProperties: toKvArray(d.validatorProperties), + mapperId: d.mapperId, + mapperProperties: toKvArray(d.mapperProperties), + handlerId: d.handlerId, + handlerProperties: toKvArray(d.handlerProperties), + matchExpression: toRawMatchExpression(d.matchExpression), + schedules: toRawSchedules(d.schedules), + responseSubscriptionId: d.responseIntegrationId, + responseMessageTypeName: d.responseMessageTypeName, +}); diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 63680587..8ddc36ef 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -428,6 +428,30 @@ export interface NotifierDetail extends Notifier { * Backend Subscription.Type. Aggregation exists in data but is deferred in * this UI; Internal and ApiCall are legacy — shown and editable, never created. */ +/** + * The editable fields of an integration being defined inline, while whatever points + * at it is being made. Mirrors the studio's own draft — deliberately, so the canvas + * can hand its draft straight to the client. + */ +export interface InlineIntegrationDraft { + name: string; + enabled: boolean; + workGroupId: number | null; + retryPolicyId: number | null; + receiverId: string | null; + receiverProperties: Record; + validatorId: string | null; + validatorProperties: Record; + mapperId: string | null; + mapperProperties: Record; + handlerId: string | null; + handlerProperties: Record; + matchExpression: MatchGroup | null; + schedules: Schedule[]; + responseIntegrationId: number | null; + responseMessageTypeName: string | null; +} + export type IntegrationType = | "Receiving" | "GatewayApiCall" diff --git a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx index 09275923..0156c04a 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx @@ -420,7 +420,8 @@ export function AdapterConfig({ /** What "no adapter" means here, e.g. "None — payload passes through unchanged". */ noneLabel?: string; /** When the native JSON mapper is selected, where its visual editor lives. */ - mapperEditorHref?: string; + /** Null while the integration is still a draft — there is no page to open yet. */ + mapperEditorHref?: string | null; }) { const catalog = useAdapterCatalog(kind); const adapter = catalog.data?.find((a) => a.id === adapterId); diff --git a/SW.Bitween.Web/ClientApp/src/components/config/pickers.tsx b/SW.Bitween.Web/ClientApp/src/components/config/pickers.tsx index 7bbd1b19..9f23d49c 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/pickers.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/pickers.tsx @@ -132,6 +132,11 @@ export function IntegrationPicker({ value, onChange, id, + /** + * Given, "New integration" defines one where the caller stands instead of opening a + * dialog — the caller renders its fields and saves it with whatever points at it. + */ + onDefineHere, }: { type: "GatewayApiCall" | "BusGateway"; /** Bus routes only run integrations carrying the gateway's own information type. */ @@ -139,6 +144,7 @@ export function IntegrationPicker({ value: number | null; onChange: (id: number) => void; id?: string; + onDefineHere?: () => void; }) { const integrations = useIntegrationsCache(); const infoTypes = useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }); @@ -169,7 +175,17 @@ export function IntegrationPicker({ setCreating(true) }] : []} + actions={ + canCreate + ? [ + { + label: "New integration", + icon: true, + onAct: () => (onDefineHere ? onDefineHere() : setCreating(true)), + }, + ] + : [] + } /> {creating && ( (initial: T) { return [draft, update, clear] as const; } - interface Draft { partnerId: number | null; integrationId: number | null; @@ -25,12 +24,17 @@ interface Draft { * Routed create page for one gateway attachment — who calls, and what runs when * they do. Deliberately shaped like `EditAttachmentPage`, its edit twin: two * questions on one form, not a guided flow. + * + * "New integration" leaves this page rather than opening in place — see + * `NewGatewayIntegrationPage` — so `?picked=`/`?partnerId=` restore the choices + * this page had on the way out. */ export function AttachPartnerPage() { const { id = "" } = useParams(); const gatewayId = Number(id); const navigate = useNavigate(); const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); const gateway = useQuery({ queryKey: ["api-gateway", gatewayId], @@ -39,10 +43,16 @@ export function AttachPartnerPage() { }); const [draft, update, clear] = useDraft({ - partnerId: null, - integrationId: null, + partnerId: searchParams.get("partnerId") ? Number(searchParams.get("partnerId")) : null, + integrationId: searchParams.get("picked") ? Number(searchParams.get("picked")) : null, }); + // Consumed once, on the way back from creating an integration — cleared so a + // refresh of this page doesn't keep re-seeding the same values. + useEffect(() => { + if (searchParams.has("picked") || searchParams.has("partnerId")) setSearchParams({}, { replace: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const create = useMutation({ mutationFn: () => @@ -68,7 +78,13 @@ export function AttachPartnerPage() { ); const g = gateway.data; - const valid = draft.partnerId !== null && draft.integrationId !== null; + + // The same rule the server enforces, said before the button is pressed rather than + // after: an integration cannot be attached half-made. + const missing = [ + draft.partnerId === null && "a partner", + draft.integrationId === null && "an integration", + ].filter((m): m is string => typeof m === "string"); return (
@@ -78,7 +94,8 @@ export function AttachPartnerPage() { Attach a partner to {g.name}

- Anything you don't have yet — the partner, the integration — is created right here. + Who calls in, and what runs when they do. No partner yet, create one right here; no + integration yet, its own page opens next and brings you back.

@@ -101,16 +118,30 @@ export function AttachPartnerPage() { type="GatewayApiCall" value={draft.integrationId} onChange={(integrationId) => update({ integrationId })} + onDefineHere={() => + navigate( + `/api-gateways/${gatewayId}/attach/new-integration${ + draft.partnerId ? `?partnerId=${draft.partnerId}` : "" + }`, + ) + } /> {create.error?.message} -
+
+ {missing.length > 0 && ( +

+ Still needs {missing.slice(0, -1).join(", ")} + {missing.length > 1 ? " and " : ""} + {missing.at(-1)}. +

+ )} +
+ )} + +
+ {missing.length > 0 && ( +

+ Still needs {missing.slice(0, -1).join(", ")} + {missing.length > 1 ? " and " : ""} + {missing.at(-1)}. +

+ )} + + +
+ {create.error?.message} +
+ ); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx index ea203557..ee6d5948 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx @@ -10,7 +10,8 @@ import { CodeBadge, EditableTitle } from "../../components/ui/Panel"; import { SearchSelect } from "../../components/ui/SearchSelect"; import { useAdapterCatalog } from "../../components/config/AdapterConfig"; import { useIntegrationRowsById, useIntegrationsCache } from "../../components/config/shared"; -import { draftOf } from "../integrations/studio/model"; +import { EMPTY_INTEGRATION, NEW_INTEGRATION_ID, draftOf } from "../integrations/studio/model"; +import { adapterIncomplete } from "../integrations/studio/faces"; import { Canvas, type Hop } from "./studio/Canvas"; import { DeliveryBody, @@ -21,7 +22,6 @@ import { TransformationBody, } from "./studio/Inspector"; import { PartnerDialog } from "../../components/config/PartnerDialog"; -import { NewIntegrationDialog } from "./studio/QuickCreate"; import { RouteList, type Selection } from "./studio/RouteList"; import { BackLink } from "../../components/ui/BackLink"; import { @@ -71,7 +71,6 @@ export function BusGatewayPage() { const queryClient = useQueryClient(); const canEdit = useSessionCan("bus-gateways.edit"); const canEditIntegration = useSessionCan("subscriptions.edit"); - const canCreateIntegration = useSessionCan("subscriptions.create"); const [params, setParams] = useSearchParams(); const gateway = useQuery({ @@ -102,7 +101,6 @@ export function BusGatewayPage() { const [edit, setEdit] = useState(null); const [collapsedInspector, setCollapsedInspector] = useState(false); const [listOpen, setListOpen] = useState(() => localStorage.getItem(LIST_KEY) !== "0"); - const [creating, setCreating] = useState(null); /** undefined = closed, null = creating, number = editing that partner's values. */ const [partnerDialog, setPartnerDialog] = useState(undefined); const [removingRoute, setRemovingRoute] = useState(null); @@ -164,7 +162,8 @@ export function BusGatewayPage() { const q0 = useQuery({ queryKey: ["integration", id0], queryFn: () => api.getIntegration(id0!), - enabled: id0 !== null, + // The integration being defined here has no server side to fetch yet. + enabled: id0 !== null && id0 !== NEW_INTEGRATION_ID, }); const d0 = useHopDraft(edit, id0, q0.data); const id1 = d0?.responseIntegrationId ?? null; @@ -200,6 +199,16 @@ export function BusGatewayPage() { return; } if (edit?.integrationId === active.id) return; + if (active.id === NEW_INTEGRATION_ID) { + // Blank, and `saved` blank too: every field the user fills counts as a change, + // so the save bar names them the same way it does for an existing integration. + setEdit({ + integrationId: NEW_INTEGRATION_ID, + draft: structuredClone(EMPTY_INTEGRATION), + saved: structuredClone(EMPTY_INTEGRATION), + }); + return; + } if (!activeData || activeData.id !== active.id) return; const seeded = draftOf(activeData); setEdit({ integrationId: active.id, draft: seeded, saved: structuredClone(seeded) }); @@ -285,21 +294,35 @@ export function BusGatewayPage() { // omitting it would reactivate a deactivated gateway on a rename. if (nameDirty && name !== null) await api.updateBusGateway(gatewayId, { name, inactive: g.inactive }); - // The integration first: if the route write then fails, what was saved is - // the part that stands on its own. - if (edit && intIsDirty) await api.updateIntegration(edit.integrationId, edit.draft); + // An integration being defined here is not written on its own: it goes with the + // route, in the one call the endpoint commits as a single transaction, so a + // failure can't leave an integration nothing points at. + const definingIntegration = routeEdit?.draft.integrationId === NEW_INTEGRATION_ID; + const integrationDraft = edit?.draft; + + // Otherwise the integration first: if the route write then fails, what was saved + // is the part that stands on its own. + if (edit && intIsDirty && !definingIntegration) + await api.updateIntegration(edit.integrationId, integrationDraft!); + if (routeEdit && (isNewRoute || routeIsDirty)) { - const input = { - integrationId: routeEdit.draft.integrationId!, - partnerId: routeEdit.draft.partner === "none" ? null : routeEdit.draft.partner, - matchExpression: routeEdit.draft.matchExpression, - }; + const partnerId = routeEdit.draft.partner === "none" ? null : routeEdit.draft.partner; if (isNewRoute) { const before = new Set((g?.routes ?? []).map((r) => r.id)); - await api.addBusRoute(gatewayId, input); + await api.addBusRoute(gatewayId, { + ...(definingIntegration + ? { newIntegration: integrationDraft! } + : { integrationId: routeEdit.draft.integrationId! }), + partnerId, + matchExpression: routeEdit.draft.matchExpression, + }); return before; } - await api.updateBusRoute(gatewayId, routeEdit.routeId as number, input); + await api.updateBusRoute(gatewayId, routeEdit.routeId as number, { + integrationId: routeEdit.draft.integrationId!, + partnerId, + matchExpression: routeEdit.draft.matchExpression, + }); } return null; }, @@ -310,10 +333,12 @@ export function BusGatewayPage() { queryKey: ["bus-gateway", gatewayId], queryFn: () => api.getBusGateway(gatewayId), }); - if (edit) await queryClient.invalidateQueries({ queryKey: ["integration", edit.integrationId] }); + if (edit && edit.integrationId !== NEW_INTEGRATION_ID) + await queryClient.invalidateQueries({ queryKey: ["integration", edit.integrationId] }); void queryClient.invalidateQueries({ queryKey: ["bus-gateways"] }); void queryClient.invalidateQueries({ queryKey: ["integrations"] }); void queryClient.invalidateQueries({ queryKey: ["integration-rows"] }); + void queryClient.invalidateQueries({ queryKey: ["integration-rows-search"] }); setRouteEdit(null); setEdit(null); setName(fresh.name); @@ -342,7 +367,10 @@ export function BusGatewayPage() { const hops: Hop[] = chain.map((h) => ({ integrationId: h.id, - name: h.draft?.name ?? h.data?.name ?? `#${h.id}`, + name: + h.draft?.name?.trim() || + h.data?.name || + (h.id === NEW_INTEGRATION_ID ? "New integration" : `#${h.id}`), draft: h.draft, saved: edit?.integrationId === h.id ? edit.saved : h.draft, row: rowsById.get(h.id), @@ -352,6 +380,22 @@ export function BusGatewayPage() { : null, })); + // What still blocks a save, said the way the modal used to say it at its Create + // button. The rule outlives the modal: an integration defined here cannot be saved + // half-made, and the server refuses it too. + const missing = [ + ...(routeEdit?.draft.integrationId === NEW_INTEGRATION_ID && edit + ? [ + edit.draft.name.trim().length < 2 && "a name", + !edit.draft.handlerId && "a delivery", + adapterIncomplete(catalogs.handlers, edit.draft.handlerId, edit.draft.handlerProperties) && + "its required delivery fields", + ] + : isNewRoute && routeEdit?.draft.integrationId === null + ? ["an integration"] + : []), + ].filter((m): m is string => typeof m === "string"); + const nodeIsDirty = node ? OWNER[node] === "route" ? routeIsDirty || isNewRoute @@ -372,7 +416,14 @@ export function BusGatewayPage() { disabled={!canEdit} onNewPartner={() => setPartnerDialog(null)} onEditPartner={(id) => setPartnerDialog(id)} - onNewIntegration={() => setCreating("route-integration")} + onNewIntegration={() => { + // No modal: the route draft points at the integration being defined, and the + // canvas draws it like any other. Straight to its own node, where the name is. + setRouteEdit((r) => + r ? { ...r, draft: { ...r.draft, integrationId: NEW_INTEGRATION_ID } } : r, + ); + setQuery({ node: "integration" }); + }} /> ) ); @@ -380,7 +431,7 @@ export function BusGatewayPage() { return (

{routeEdit?.draft.integrationId === null - ? "Pick the integration this route runs first — this step belongs to it." + ? "Pick the integration this route runs, or define one — this step belongs to it." : "Loading the integration…"}

); @@ -399,6 +450,7 @@ export function BusGatewayPage() { : null } lastException={activeData?.lastException ?? null} + autoFocusName={edit.integrationId === NEW_INTEGRATION_ID} /> ); case "transformation": @@ -407,7 +459,11 @@ export function BusGatewayPage() { draft={edit.draft} onChange={onChange} disabled={!canEditIntegration} - mapperEditorHref={`/subscriptions/${edit.integrationId}/mapper`} + mapperEditorHref={ + edit.integrationId === NEW_INTEGRATION_ID + ? null + : `/subscriptions/${edit.integrationId}/mapper` + } /> ); case "delivery": @@ -419,8 +475,6 @@ export function BusGatewayPage() { onChange={onChange} disabled={!canEditIntegration} candidates={(allIntegrations.data ?? []).filter((x) => x.id !== edit.integrationId)} - onNewIntegration={() => setCreating("response-integration")} - canCreate={canCreateIntegration} /> ); } @@ -592,7 +646,15 @@ export function BusGatewayPage() {

Unsaved: {dirtyLabels.join(", ")}

- {save.error?.message} + {missing.length > 0 ? ( +

+ Still needs {missing.slice(0, -1).join(", ")} + {missing.length > 1 ? " and " : ""} + {missing.at(-1)}. +

+ ) : ( + {save.error?.message} + )}
@@ -304,6 +310,7 @@ function ExpandedHop({ saved: hop.saved ?? undefined, catalogs, integrationNames, + unsaved: isNew, })} label={BUS_NODES[node].label} icon={BUS_NODES[node].icon} @@ -348,7 +355,7 @@ function CollapsedHop({ hop, label, onOpen }: { hop: Hop; label: string; onOpen:
diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx index f2f929de..2c2c44da 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx @@ -10,7 +10,12 @@ import { AdapterConfig } from "../../../components/config/AdapterConfig"; import { MatchExpressionEditor } from "../../../components/config/MatchExpressionEditor"; import { HealthBadge } from "../../../components/config/shared"; import { ResponseFields } from "../../integrations/studio/ResponseFields"; -import { BUS_NODES, type BusNodeId, type IntegrationDraft, type RouteDraft } from "./model"; +import { + BUS_NODES, + type BusNodeId, + type IntegrationDraft, + type RouteDraft, +} from "./model"; /** * The configuration surface: one node's form, docked under the canvas. @@ -208,12 +213,15 @@ export function IntegrationBody({ disabled, health, lastException, + /** Set while the integration is being defined here — the name is the first thing asked. */ + autoFocusName = false, }: { draft: IntegrationDraft; onChange: (patch: Partial) => void; disabled: boolean; health: { isRunning: boolean; consecutiveFailures: number } | null; lastException: string | null; + autoFocusName?: boolean; }) { const workGroups = useQuery({ queryKey: ["work-groups"], @@ -230,6 +238,8 @@ export function IntegrationBody({ id="bs-int-name" value={draft.name} disabled={disabled} + autoFocus={autoFocusName} + placeholder="e.g. Coral orders to SAP" onChange={(e) => onChange({ name: e.target.value })} /> @@ -290,7 +300,7 @@ export function TransformationBody({ draft: IntegrationDraft; onChange: (patch: Partial) => void; disabled: boolean; - mapperEditorHref?: string; + mapperEditorHref?: string | null; }) { return ( ) => void; disabled: boolean; candidates: { id: number; name: string; type: IntegrationType }[]; - onNewIntegration: () => void; - canCreate: boolean; }) { return (
@@ -352,13 +358,10 @@ export function ResponseBody({ candidates={candidates} idPrefix="bs-resp" /> - {draft.handlerId !== null && canCreate && !disabled && ( - - )} {draft.handlerId !== null && (

- Both can be set. A bus message is a fan-out — every route bound to that message's information - type picks it up, on this gateway and any other. + A bus message is a fan-out — every route bound to that message's information type picks + it up, on this gateway and any other.

)}
diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/QuickCreate.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/QuickCreate.tsx deleted file mode 100644 index 2276dfda..00000000 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/QuickCreate.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { api } from "../../../api"; -import { Button, FormError } from "../../../components/ui/basics"; -import { Field, TextInput } from "../../../components/ui/forms"; -import { Dialog } from "../../../components/ui/overlays"; -import { AdapterConfig, useAdapterCatalog } from "../../../components/config/AdapterConfig"; -import { adapterIncomplete } from "../../integrations/studio/faces"; - -/* - * Creating from inside the studio. - * - * The route itself is never a dialog — it is a row and a set of nodes, so the - * diagram stays on screen while you answer its three questions. An integration is - * a *different* record and has to exist before the route can point at it, so it - * gets a dialog; what it creates is then configured on the canvas like anything - * else. Partners use the app-wide `PartnerDialog`. - */ - -/** - * A new integration for this gateway, asked down to the two things it can't run - * without: a name and somewhere to deliver. Transformation, response and the rest - * are nodes on the canvas the moment it exists — no reason to ask twice. - */ -export function NewIntegrationDialog({ - informationTypeId, - informationTypeCode, - onClose, - onCreated, -}: { - informationTypeId: number; - informationTypeCode: string; - onClose: () => void; - onCreated: (id: number) => void; -}) { - const queryClient = useQueryClient(); - const handlers = useAdapterCatalog("handler"); - const [name, setName] = useState(""); - const [handlerId, setHandlerId] = useState(null); - const [handlerProperties, setHandlerProperties] = useState>({}); - - const create = useMutation({ - mutationFn: () => - api.createIntegration({ - type: "BusGateway", - name: name.trim(), - informationTypeId, - handlerId, - handlerProperties, - // Safe to enable: a BusGateway integration only ever runs through a route, - // and this one has none until you save the route that is being built. - enabled: true, - }), - onSuccess: (created) => { - void queryClient.invalidateQueries({ queryKey: ["integrations"] }); - void queryClient.invalidateQueries({ queryKey: ["integration-rows"] }); - onCreated(created.id); - onClose(); - }, - }); - - const missing = [ - name.trim().length < 2 && "a name", - handlerId === null && "a delivery", - adapterIncomplete(handlers, handlerId, handlerProperties) && "its required delivery fields", - ].filter((m): m is string => typeof m === "string"); - - return ( - -
- - setName(e.target.value)} - /> - - - - { - setHandlerId(id); - setHandlerProperties(props); - }} - disabled={false} - required - /> - - - {create.error?.message} -
- {missing.length > 0 && ( -

- Still needs {missing.slice(0, -1).join(", ")} - {missing.length > 1 ? " and " : ""} - {missing.at(-1)}. -

- )} - - -
-
-
- ); -} diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/RouteList.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/RouteList.tsx index 60f4ba68..98991670 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/RouteList.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/RouteList.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from "react"; import { Plus, Search } from "lucide-react"; import type { BusGatewayRoute, IntegrationRow } from "../../../api"; -import { HEALTH_DOT, HEALTH_LABEL, conditionText, routeHealth, type RouteDraft } from "./model"; +import { HEALTH_DOT, HEALTH_LABEL, HEALTH_TITLE, conditionText, routeHealth, type RouteDraft } from "./model"; export type Selection = number | "new" | null; @@ -154,7 +154,7 @@ function Row({ = { paused: "bg-warn-400", unknown: "bg-ink-200", }; + +/** What each dot means, for hovering — `HEALTH_LABEL` alone is a single word with no context. */ +export const HEALTH_TITLE: Record = { + ok: "Healthy — enabled, unpaused, and its last run succeeded.", + failing: "Enabled and unpaused, but its recent runs ended in errors.", + disabled: "Turned off — this route's integration won't run, even if the filter matches.", + paused: "Held without being disabled — matches still route here, but nothing runs until unpaused.", + unknown: "No integration data available for this route yet.", +}; diff --git a/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/faces.ts b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/faces.ts index 89e53ffe..39168de8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/faces.ts +++ b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/faces.ts @@ -45,6 +45,12 @@ export interface FaceInput { fault?: StageFace["fault"]; /** Names for the "feed the response into" target. */ integrationNames?: { id: number; name: string }[]; + /** + * Set while the integration is being defined and nothing is saved yet. A delivery-less + * integration that already exists is a legal thing that records and stops; one being + * created here cannot be saved without a delivery, so the node has to say so. + */ + unsaved?: boolean; } /** @@ -55,7 +61,7 @@ export interface FaceInput { * while editing is how the two drift apart. */ export function faceOf(stageId: StageId, input: FaceInput): StageFace { - const { type, draft: d, catalogs, saved, entryPoints = [], nextRunOn, fault, integrationNames } = input; + const { type, draft: d, catalogs, saved, entryPoints = [], nextRunOn, fault, integrationNames, unsaved } = input; const dirty = saved ? stageDirty(stageId, d, saved) : false; switch (stageId) { @@ -113,16 +119,16 @@ export function faceOf(stageId: StageId, input: FaceInput): StageFace { state: d.mapperId ? "set" : "none", }; case "delivery": - // "none", not "missing": an integration that records the document and - // stops is a legal configuration. The create page requires a handler - // anyway, but it says so at the Create button rather than by calling a - // saved integration broken. + // "none", not "missing", once it exists: an integration that records the document + // and stops is a legal configuration, and calling a saved one broken would be + // wrong. While it is still being defined the save is blocked on this, so it is + // genuinely missing and the node says which node to go to. return { id: stageId, dirty, - title: labelOf(catalogs.handlers, d.handlerId) ?? "Stops here", + title: labelOf(catalogs.handlers, d.handlerId) ?? (unsaved ? "Needed" : "Stops here"), detail: locationHint(d.handlerProperties), - state: d.handlerId ? "set" : "none", + state: d.handlerId ? "set" : unsaved ? "missing" : "none", }; case "response": if (!d.handlerId) return { id: stageId, dirty, title: "Nothing delivered", state: "none" }; diff --git a/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/model.ts b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/model.ts index a680cda8..38799b28 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/model.ts +++ b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/model.ts @@ -41,6 +41,40 @@ export const draftOf = (d: IntegrationDetail): Draft => ({ responseMessageTypeName: d.responseMessageTypeName, }); +/** + * An integration being defined on a gateway's canvas, before it exists. + * + * The route already worked this way — see `NEW_ROUTE` — and an integration is the + * same problem one level down: asking for it in a modal hides the diagram the + * answers are about. It lives in the studio's state until one save writes it and + * the thing pointing at it together. + */ +export const EMPTY_INTEGRATION: Draft = { + name: "", + enabled: true, + workGroupId: null, + retryPolicyId: null, + receiverId: null, + receiverProperties: {}, + validatorId: null, + validatorProperties: {}, + mapperId: null, + mapperProperties: {}, + handlerId: null, + handlerProperties: {}, + matchExpression: null, + schedules: [], + responseIntegrationId: null, + responseMessageTypeName: null, +}; + +/** + * Stands in for "the integration being defined right here" wherever an id is + * expected. Negative so it can never collide with a real one, and never sent to + * the server: the save swaps it for the inline payload the gateway endpoints take. + */ +export const NEW_INTEGRATION_ID = -1; + /** * Which draft fields each stage owns — only so a card can carry an unsaved dot. * Name, enabled, work group and retry policy belong to no stage; they live on diff --git a/SW.Bitween.Web/ClientApp/src/router.tsx b/SW.Bitween.Web/ClientApp/src/router.tsx index 6ed34124..7556e8f4 100644 --- a/SW.Bitween.Web/ClientApp/src/router.tsx +++ b/SW.Bitween.Web/ClientApp/src/router.tsx @@ -17,6 +17,7 @@ import { ApiGatewayNewPage } from "./pages/api-gateways/ApiGatewayNewPage"; import { ApiGatewaysPage } from "./pages/api-gateways/ApiGatewaysPage"; import { ApiGatewayPage } from "./pages/api-gateways/ApiGatewayPage"; import { AttachPartnerPage } from "./pages/api-gateways/AttachPartnerPage"; +import { NewGatewayIntegrationPage } from "./pages/api-gateways/NewGatewayIntegrationPage"; import { EditAttachmentPage } from "./pages/api-gateways/EditAttachmentPage"; import { BusGatewayNewPage } from "./pages/bus-gateways/BusGatewayNewPage"; import { BusGatewayPage } from "./pages/bus-gateways/BusGatewayPage"; @@ -238,6 +239,14 @@ export const router = createBrowserRouter([ ), }, + { + path: "api-gateways/:id/attach/new-integration", + element: ( + + + + ), + }, { path: "api-gateways/:id/attachments/:partnerId", element: ( From 31b8277ab8707f43cf4a02fb73a2d0fe626662ea Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 24 Aug 2026 14:44:18 +0300 Subject: [PATCH 54/54] feat: give scheduled jobs their own run history, decoupled from Quartz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems on a Receiving integration's page: the scheduler's own run history always reports success, since ReceivingJob catches every exception from the receive step itself and Quartz never sees it — so "34 failures" on the record never matched what the exchange list showed. And the schedule's next-fire estimate only advanced on a successful run, freezing in the past forever once a receiver started failing, even though the job kept firing on schedule underneath it. ReceivingJob now writes its own ReceiveAttempt row on every run, success or failure, with the exchanges it produced — paged and filterable on the integration's page, retained on the same cleanup schedule as the scheduler's own history. And the schedule now advances regardless of outcome, isolated from the receive step so a bad schedule can't block a receive that would otherwise have worked. --- SW.Bitween.Api/Data/BitweenDbContext.cs | 9 + SW.Bitween.Api/Domain/ReceiveAttempt.cs | 20 + .../Subscriptions/GetReceiveAttempts.cs | 88 + SW.Bitween.Api/Services/BitweenOptions.cs | 13 + .../Services/ReceiveAttemptCleanupJob.cs | 26 + SW.Bitween.Api/Services/ReceivingJob.cs | 51 +- .../Services/SchedulerSeedService.cs | 1 + .../Adapters/NativeEmptyTestReceiver.cs | 26 + .../Adapters/NativeFailingTestReceiver.cs | 26 + .../Fixtures/BitweenFixture.cs | 2 + .../Tests/ReceivingTests.cs | 94 + ...60824093631_AddReceiveAttempts.Designer.cs | 2138 ++++++++++++++ .../20260824093631_AddReceiveAttempts.cs | 45 + .../BitweenDbContextModelSnapshot.cs | 34 + ...60824093618_AddReceiveAttempts.Designer.cs | 2131 ++++++++++++++ .../20260824093618_AddReceiveAttempts.cs | 49 + .../BitweenDbContextModelSnapshot.cs | 34 + SW.Bitween.PgSql/BitweenDbContext.cs | 6 + ...60824093537_AddReceiveAttempts.Designer.cs | 2455 +++++++++++++++++ .../20260824093537_AddReceiveAttempts.cs | 49 + .../BitweenDbContextModelSnapshot.cs | 42 + SW.Bitween.Sdk/Model/Subscription.cs | 39 + .../ClientApp/src/api/http/exchanges.ts | 2 +- SW.Bitween.Web/ClientApp/src/api/types.ts | 20 + .../pages/integrations/studio/Overview.tsx | 52 +- .../studio/ReceiveAttemptsPanel.tsx | 184 ++ 26 files changed, 7621 insertions(+), 15 deletions(-) create mode 100644 SW.Bitween.Api/Domain/ReceiveAttempt.cs create mode 100644 SW.Bitween.Api/Resources/Subscriptions/GetReceiveAttempts.cs create mode 100644 SW.Bitween.Api/Services/ReceiveAttemptCleanupJob.cs create mode 100644 SW.Bitween.IntegrationTests/Adapters/NativeEmptyTestReceiver.cs create mode 100644 SW.Bitween.IntegrationTests/Adapters/NativeFailingTestReceiver.cs create mode 100644 SW.Bitween.MsSql/Migrations/20260824093631_AddReceiveAttempts.Designer.cs create mode 100644 SW.Bitween.MsSql/Migrations/20260824093631_AddReceiveAttempts.cs create mode 100644 SW.Bitween.MySql/Migrations/20260824093618_AddReceiveAttempts.Designer.cs create mode 100644 SW.Bitween.MySql/Migrations/20260824093618_AddReceiveAttempts.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260824093537_AddReceiveAttempts.Designer.cs create mode 100644 SW.Bitween.PgSql/Migrations/20260824093537_AddReceiveAttempts.cs create mode 100644 SW.Bitween.Web/ClientApp/src/pages/integrations/studio/ReceiveAttemptsPanel.tsx diff --git a/SW.Bitween.Api/Data/BitweenDbContext.cs b/SW.Bitween.Api/Data/BitweenDbContext.cs index d1ba5b67..1a3f0eae 100644 --- a/SW.Bitween.Api/Data/BitweenDbContext.cs +++ b/SW.Bitween.Api/Data/BitweenDbContext.cs @@ -242,6 +242,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(p => p.On); }); + modelBuilder.Entity(b => + { + b.ToTable("ReceiveAttempts"); + b.Property(p => p.Id).ValueGeneratedOnAdd(); + b.Property(p => p.ErrorMessage).HasMaxLength(4000); + b.Property(p => p.ExchangeIds).IsSeparatorDelimited(); + b.HasIndex(p => new { p.SubscriptionId, p.StartedOn }); + }); + modelBuilder.Entity(b => { b.ToTable("RetryGroupUsages"); diff --git a/SW.Bitween.Api/Domain/ReceiveAttempt.cs b/SW.Bitween.Api/Domain/ReceiveAttempt.cs new file mode 100644 index 00000000..a7ceaa54 --- /dev/null +++ b/SW.Bitween.Api/Domain/ReceiveAttempt.cs @@ -0,0 +1,20 @@ +using System; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Domain; + +/// +/// One execution of a Receiving subscription's receive step, written by ReceivingJob +/// itself right where it already catches the receiver's own failures — kept independent of +/// Quartz's own run history and unaffected by how Quartz treats a thrown exception. +/// +public class ReceiveAttempt : BaseEntity +{ + public int SubscriptionId { get; set; } + public DateTime StartedOn { get; set; } + public DateTime FinishedOn { get; set; } + public ReceiveOutcome Outcome { get; set; } + public string ErrorMessage { get; set; } + public string[] ExchangeIds { get; set; } = Array.Empty(); +} diff --git a/SW.Bitween.Api/Resources/Subscriptions/GetReceiveAttempts.cs b/SW.Bitween.Api/Resources/Subscriptions/GetReceiveAttempts.cs new file mode 100644 index 00000000..c162e898 --- /dev/null +++ b/SW.Bitween.Api/Resources/Subscriptions/GetReceiveAttempts.cs @@ -0,0 +1,88 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Subscriptions; + +/// +/// Paged, filterable history of one Receiving subscription's own +/// rows — independent of , which reads the scheduler's own (always-succeeds) +/// history instead. +/// +[HandlerName("receiveattempts")] +public class GetReceiveAttempts : IQueryHandler +{ + private readonly BitweenDbContext _dbContext; + private readonly RequestContext _requestContext; + + public GetReceiveAttempts(BitweenDbContext dbContext, RequestContext requestContext) + { + _dbContext = dbContext; + _requestContext = requestContext; + } + + public async Task Handle(SearchReceiveAttemptsModel request) + { + await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.View); + + var offset = request.Offset ?? 0; + var limit = request.Limit ?? 25; + + var query = _dbContext.Set() + .AsNoTracking() + .Where(a => a.SubscriptionId == request.SubscriptionId); + + if (request.Outcome.HasValue) + query = query.Where(a => a.Outcome == request.Outcome.Value); + + var totalCount = await query.CountAsync(); + + var page = await query + .OrderByDescending(a => a.StartedOn) + .Skip(offset) + .Take(limit) + .ToListAsync(); + + var exchangeIds = page.SelectMany(a => a.ExchangeIds ?? Array.Empty()).Distinct().ToList(); + + // Left join: an id an attempt still points at but whose Xchange got cleaned up some + // other way shows up with nulls rather than silently dropping the row's own history. + var exchangesById = await ( + from x in _dbContext.Set() + join r in _dbContext.Set() on x.Id equals r.Id into xr + from r in xr.DefaultIfEmpty() + join p in _dbContext.Set() on x.Id equals p.Id into xp + from p in xp.DefaultIfEmpty() + where exchangeIds.Contains(x.Id) + select new ReceiveAttemptExchangeRef + { + Id = x.Id, + Status = r.Success, + ResponseBad = r.ResponseBad, + PromotedProperties = p == null ? null : p.Properties.ToDictionary(), + } + ).ToDictionaryAsync(e => e.Id); + + var result = page.Select(a => new ReceiveAttemptModel + { + Id = a.Id, + StartedOn = a.StartedOn, + FinishedOn = a.FinishedOn, + Outcome = a.Outcome, + ErrorMessage = a.ErrorMessage, + Exchanges = (a.ExchangeIds ?? Array.Empty()) + .Select(id => exchangesById.TryGetValue(id, out var x) ? x : new ReceiveAttemptExchangeRef { Id = id }) + .ToList(), + }).ToList(); + + return new + { + Result = result, + TotalCount = totalCount, + }; + } +} diff --git a/SW.Bitween.Api/Services/BitweenOptions.cs b/SW.Bitween.Api/Services/BitweenOptions.cs index a6b8da35..d9d426a3 100644 --- a/SW.Bitween.Api/Services/BitweenOptions.cs +++ b/SW.Bitween.Api/Services/BitweenOptions.cs @@ -104,5 +104,18 @@ public BitweenOptions() /// Format: second minute hour dayOfMonth month dayOfWeek /// public string RetryJobCron { get; set; } = "0 * * * * ?"; + + /// + /// How many days to keep ReceiveAttempt rows before ReceiveAttemptCleanupJob + /// deletes them. Matches the scheduler library's own JobExecution retention default. + /// + public int ReceiveAttemptRetentionDays { get; set; } = 30; + + /// + /// Quartz cron expression for ReceiveAttemptCleanupJob. Defaults to daily at 3am — + /// offset from the scheduler library's own cleanup job (2am) so they don't run at once. + /// Format: second minute hour dayOfMonth month dayOfWeek + /// + public string ReceiveAttemptCleanupCron { get; set; } = "0 0 3 * * ?"; } } \ No newline at end of file diff --git a/SW.Bitween.Api/Services/ReceiveAttemptCleanupJob.cs b/SW.Bitween.Api/Services/ReceiveAttemptCleanupJob.cs new file mode 100644 index 00000000..9abb9bc8 --- /dev/null +++ b/SW.Bitween.Api/Services/ReceiveAttemptCleanupJob.cs @@ -0,0 +1,26 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; +using SW.Scheduler; + +namespace SW.Bitween; + +/// +/// Deletes rows older than +/// . +/// Scheduled via (registered by +/// SchedulerSeedService). +/// +[ScheduleConfig(AllowConcurrentExecution = false, MisfireInstructions = MisfireInstructions.Skip)] +public class ReceiveAttemptCleanupJob(BitweenDbContext dbContext, BitweenOptions options) : IScheduledJob +{ + public async Task Execute() + { + var cutoff = DateTime.UtcNow.AddDays(-options.ReceiveAttemptRetentionDays); + await dbContext.Set() + .Where(a => a.StartedOn < cutoff) + .ExecuteDeleteAsync(); + } +} diff --git a/SW.Bitween.Api/Services/ReceivingJob.cs b/SW.Bitween.Api/Services/ReceivingJob.cs index 83a10462..cad0dfab 100644 --- a/SW.Bitween.Api/Services/ReceivingJob.cs +++ b/SW.Bitween.Api/Services/ReceivingJob.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SW.Bitween.Domain; +using SW.Bitween.Model; using SW.PrimitiveTypes; using SW.Scheduler; using System; @@ -32,17 +33,40 @@ public async Task Execute(ReceivingJobParams jobParams) var isIdle = await runFlagUpdater.MarkAsRunning(rec.Id); if (!isIdle) return; + var startedOn = DateTime.UtcNow; + // Populated as files come in, so a mid-loop failure still leaves the ones that + // did make it through visible on the attempt record rather than orphaned. + var createdExchangeIds = new List(); + + // Advances regardless of outcome: the Quartz trigger fires on its own cron no + // matter what happens below, so "next run" has to track that, not the receive + // step's success — otherwise a receiver that keeps failing freezes ReceiveOn + // in the past forever while the job keeps firing on schedule underneath it. + // Isolated in its own try: a schedule problem is unrelated to receiving and + // must not stop the step below from running. + try + { + rec.SetSchedules(); + } + catch (Exception ex) + { + logger.LogError(ex, "Could not advance the schedule for subscription {SubscriptionId}", jobParams.SubscriptionId); + } + try { var globals = await dbContext.Set().ToArrayAsync(); var startupParameters = rec.ReceiverProperties.ToDictionary().Fill(null, globals); - await RunReceiver(rec.ReceiverId, startupParameters, rec.Id); - rec.SetSchedules(); + await RunReceiver(rec.ReceiverId, startupParameters, rec.Id, createdExchangeIds); rec.SetHealth(); + RecordAttempt(rec.Id, startedOn, + createdExchangeIds.Count > 0 ? ReceiveOutcome.Received : ReceiveOutcome.NoNewData, + null, createdExchangeIds); } catch (Exception ex) { rec.SetHealth(ex.ToString()); + RecordAttempt(rec.Id, startedOn, ReceiveOutcome.Failed, ex.ToString(), createdExchangeIds); logger.LogError(ex, "Error processing receiver for subscription {SubscriptionId}", jobParams.SubscriptionId); } finally @@ -53,7 +77,24 @@ public async Task Execute(ReceivingJobParams jobParams) await dbContext.SaveChangesAsync(); } - private async Task RunReceiver(string serverlessId, IDictionary startupParameters, int subId) + private void RecordAttempt( + int subscriptionId, DateTime startedOn, ReceiveOutcome outcome, string errorMessage, + List exchangeIds) + { + dbContext.Add(new ReceiveAttempt + { + SubscriptionId = subscriptionId, + StartedOn = startedOn, + FinishedOn = DateTime.UtcNow, + Outcome = outcome, + ErrorMessage = errorMessage, + ExchangeIds = exchangeIds.ToArray(), + }); + } + + private async Task RunReceiver( + string serverlessId, IDictionary startupParameters, int subId, + List createdExchangeIds) { if (serverlessId.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase)) { @@ -67,7 +108,7 @@ private async Task RunReceiver(string serverlessId, IDictionary { var xchangeFile = await receiver.GetFile(file); logger.LogInformation("Submitting received file for subscriber: '{SubId}'.", subId); - await xchangeService.SubmitSubscriptionXchange(subId, xchangeFile); + createdExchangeIds.Add(await xchangeService.SubmitSubscriptionXchange(subId, xchangeFile)); await receiver.DeleteFile(file); } @@ -85,7 +126,7 @@ private async Task RunReceiver(string serverlessId, IDictionary { var xchangeFile = await serverless.InvokeAsync(nameof(IInfolinkReceiver.GetFile), file); logger.LogInformation("Submitting received file for subscriber: '{SubId}'.", subId); - await xchangeService.SubmitSubscriptionXchange(subId, xchangeFile); + createdExchangeIds.Add(await xchangeService.SubmitSubscriptionXchange(subId, xchangeFile)); await serverless.InvokeAsync(nameof(IInfolinkReceiver.DeleteFile), file); } diff --git a/SW.Bitween.Api/Services/SchedulerSeedService.cs b/SW.Bitween.Api/Services/SchedulerSeedService.cs index 056d42e7..e895bd64 100644 --- a/SW.Bitween.Api/Services/SchedulerSeedService.cs +++ b/SW.Bitween.Api/Services/SchedulerSeedService.cs @@ -25,6 +25,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var options = scope.ServiceProvider.GetRequiredService(); await scheduleRepo.Schedule(options.RetryJobCron); + await scheduleRepo.Schedule(options.ReceiveAttemptCleanupCron); var subscriptions = await dbContext.Set() .Where(s => diff --git a/SW.Bitween.IntegrationTests/Adapters/NativeEmptyTestReceiver.cs b/SW.Bitween.IntegrationTests/Adapters/NativeEmptyTestReceiver.cs new file mode 100644 index 00000000..489b470a --- /dev/null +++ b/SW.Bitween.IntegrationTests/Adapters/NativeEmptyTestReceiver.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using SW.Bitween.NativeAdapters; +using SW.PrimitiveTypes; + +namespace SW.Bitween.IntegrationTests.Adapters; + +/// Always finds nothing — for exercising ReceivingJob's no-new-data path. +public class NativeEmptyTestReceiver : INativeInfolinkReceiver +{ + public string Name => nameof(NativeEmptyTestReceiver); + public Type StartupValuesType => typeof(object); + + public void InitializeStartupValues(IDictionary settings) { } + + public Task Initialize() => Task.CompletedTask; + + public Task> ListFiles() => Task.FromResult>(Array.Empty()); + + public Task GetFile(string fileId) => throw new NotSupportedException(); + + public Task DeleteFile(string fileId) => Task.CompletedTask; + + public Task Finalize() => Task.CompletedTask; +} diff --git a/SW.Bitween.IntegrationTests/Adapters/NativeFailingTestReceiver.cs b/SW.Bitween.IntegrationTests/Adapters/NativeFailingTestReceiver.cs new file mode 100644 index 00000000..1a4d1d51 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Adapters/NativeFailingTestReceiver.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using SW.Bitween.NativeAdapters; +using SW.PrimitiveTypes; + +namespace SW.Bitween.IntegrationTests.Adapters; + +/// Always fails to list files — for exercising ReceivingJob's failure path. +public class NativeFailingTestReceiver : INativeInfolinkReceiver +{ + public string Name => nameof(NativeFailingTestReceiver); + public Type StartupValuesType => typeof(object); + + public void InitializeStartupValues(IDictionary settings) { } + + public Task Initialize() => Task.CompletedTask; + + public Task> ListFiles() => throw new InvalidOperationException("Connection refused"); + + public Task GetFile(string fileId) => throw new NotSupportedException(); + + public Task DeleteFile(string fileId) => Task.CompletedTask; + + public Task Finalize() => Task.CompletedTask; +} diff --git a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs index 657f893a..c4274999 100644 --- a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs +++ b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs @@ -115,6 +115,8 @@ public async Task InitializeAsync() services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/SW.Bitween.IntegrationTests/Tests/ReceivingTests.cs b/SW.Bitween.IntegrationTests/Tests/ReceivingTests.cs index 7fdbeb18..ef6900bb 100644 --- a/SW.Bitween.IntegrationTests/Tests/ReceivingTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/ReceivingTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -47,6 +49,98 @@ public async Task Receiving_job_creates_one_xchange_per_received_file() Assert.Equal(2, count); } + [Fact] + public async Task Receiving_job_records_one_attempt_with_the_exchanges_it_created() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var job = scope.ServiceProvider.GetRequiredService(); + var cache = _fixture.App.Services.GetRequiredService(); + + var document = new Document(6006, "Receiving Attempt Doc"); + db.Set().Add(document); + + var subscription = new Subscription("Receive Attempt Test", document.Id); + subscription.ReceiverId = nameof(NativeTestReceiver); + subscription.Inactive = false; + // SetSchedules() throws "Invalid schedule" with none configured — a real Receiving + // subscription always has one, so give this test one too. + subscription.SetSchedules(new[] { new Schedule(Recurrence.Hourly, TimeSpan.FromMinutes(30)) }); + db.Set().Add(subscription); + await db.SaveChangesAsync(); + + cache.Revoke(); + + await job.Execute(new ReceivingJobParams(subscription.Id, null)); + + var attempt = await db.Set().SingleAsync(a => a.SubscriptionId == subscription.Id); + Assert.Equal(ReceiveOutcome.Received, attempt.Outcome); + Assert.Null(attempt.ErrorMessage); + Assert.Equal(2, attempt.ExchangeIds.Length); + + var xchangeIds = await db.Set() + .Where(x => x.SubscriptionId == subscription.Id) + .Select(x => x.Id) + .ToListAsync(); + Assert.Equal(xchangeIds.OrderBy(i => i), attempt.ExchangeIds.OrderBy(i => i)); + } + + [Fact] + public async Task Receiving_job_records_a_failed_attempt_when_listing_files_throws() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var job = scope.ServiceProvider.GetRequiredService(); + var cache = _fixture.App.Services.GetRequiredService(); + + var document = new Document(6007, "Receiving Failure Doc"); + db.Set().Add(document); + + var subscription = new Subscription("Receive Failure Test", document.Id); + subscription.ReceiverId = nameof(NativeFailingTestReceiver); + subscription.Inactive = false; + subscription.SetSchedules(new[] { new Schedule(Recurrence.Hourly, TimeSpan.FromMinutes(30)) }); + db.Set().Add(subscription); + await db.SaveChangesAsync(); + + cache.Revoke(); + + await job.Execute(new ReceivingJobParams(subscription.Id, null)); + + var attempt = await db.Set().SingleAsync(a => a.SubscriptionId == subscription.Id); + Assert.Equal(ReceiveOutcome.Failed, attempt.Outcome); + Assert.Contains("Connection refused", attempt.ErrorMessage); + Assert.Empty(attempt.ExchangeIds); + } + + [Fact] + public async Task Receiving_job_records_no_new_data_when_nothing_is_found() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var job = scope.ServiceProvider.GetRequiredService(); + var cache = _fixture.App.Services.GetRequiredService(); + + var document = new Document(6008, "Receiving Empty Doc"); + db.Set().Add(document); + + var subscription = new Subscription("Receive Empty Test", document.Id); + subscription.ReceiverId = nameof(NativeEmptyTestReceiver); + subscription.Inactive = false; + subscription.SetSchedules(new[] { new Schedule(Recurrence.Hourly, TimeSpan.FromMinutes(30)) }); + db.Set().Add(subscription); + await db.SaveChangesAsync(); + + cache.Revoke(); + + await job.Execute(new ReceivingJobParams(subscription.Id, null)); + + var attempt = await db.Set().SingleAsync(a => a.SubscriptionId == subscription.Id); + Assert.Equal(ReceiveOutcome.NoNewData, attempt.Outcome); + Assert.Null(attempt.ErrorMessage); + Assert.Empty(attempt.ExchangeIds); + } + [Fact] public async Task Receiving_job_does_nothing_for_inactive_subscription() { diff --git a/SW.Bitween.MsSql/Migrations/20260824093631_AddReceiveAttempts.Designer.cs b/SW.Bitween.MsSql/Migrations/20260824093631_AddReceiveAttempts.Designer.cs new file mode 100644 index 00000000..ef118cc7 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260824093631_AddReceiveAttempts.Designer.cs @@ -0,0 +1,2138 @@ +// +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("20260824093631_AddReceiveAttempts")] + partial class AddReceiveAttempts + { + /// + 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.HasSequence("DocumentIds"); + + 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("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.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + 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.Accounts.Role", 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("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Permissions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + 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") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValueSql("NEXT VALUE FOR [DocumentIds]"); + + SqlServerPropertyBuilderExtensions.UseSequence(b.Property("Id"), "DocumentIds"); + + b.Property("BusEnabled") + .HasColumnType("bit"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + 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("Code") + .IsUnique() + .HasFilter("[Code] 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("Inactive") + .HasColumnType("bit"); + + 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("Inactive") + .HasColumnType("bit"); + + 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.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ExchangeIds") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("Outcome") + .HasColumnType("int"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "StartedOn"); + + b.ToTable("ReceiveAttempts", (string)null); + }); + + 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.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Settings", (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("ManualRetry") + .HasColumnType("bit"); + + 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.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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/20260824093631_AddReceiveAttempts.cs b/SW.Bitween.MsSql/Migrations/20260824093631_AddReceiveAttempts.cs new file mode 100644 index 00000000..044c3a9b --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20260824093631_AddReceiveAttempts.cs @@ -0,0 +1,45 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class AddReceiveAttempts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ReceiveAttempts", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + SubscriptionId = table.Column(type: "int", nullable: false), + StartedOn = table.Column(type: "datetime2", nullable: false), + FinishedOn = table.Column(type: "datetime2", nullable: false), + Outcome = table.Column(type: "int", nullable: false), + ErrorMessage = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: true), + ExchangeIds = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ReceiveAttempts", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ReceiveAttempts_SubscriptionId_StartedOn", + table: "ReceiveAttempts", + columns: new[] { "SubscriptionId", "StartedOn" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ReceiveAttempts"); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs index a30467a7..de43d8c9 100644 --- a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -605,6 +605,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SW.Bitween.Domain.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ExchangeIds") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("Outcome") + .HasColumnType("int"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "StartedOn"); + + b.ToTable("ReceiveAttempts", (string)null); + }); + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => { b.Property("SubscriptionId") diff --git a/SW.Bitween.MySql/Migrations/20260824093618_AddReceiveAttempts.Designer.cs b/SW.Bitween.MySql/Migrations/20260824093618_AddReceiveAttempts.Designer.cs new file mode 100644 index 00000000..e8d70e04 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260824093618_AddReceiveAttempts.Designer.cs @@ -0,0 +1,2131 @@ +// +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("20260824093618_AddReceiveAttempts")] + partial class AddReceiveAttempts + { + /// + 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("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.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("AccountId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRoles", (string)null); + }); + + 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.Accounts.Role", 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("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsSystem") + .HasColumnType("tinyint(1)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + 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") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BusEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("BusMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + 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("Code") + .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("Inactive") + .HasColumnType("tinyint(1)"); + + 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("Inactive") + .HasColumnType("tinyint(1)"); + + 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.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExchangeIds") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("Outcome") + .HasColumnType("int"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "StartedOn"); + + b.ToTable("ReceiveAttempts", (string)null); + }); + + 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.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Settings", (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("ManualRetry") + .HasColumnType("tinyint(1)"); + + 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.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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/20260824093618_AddReceiveAttempts.cs b/SW.Bitween.MySql/Migrations/20260824093618_AddReceiveAttempts.cs new file mode 100644 index 00000000..9402ea68 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20260824093618_AddReceiveAttempts.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class AddReceiveAttempts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ReceiveAttempts", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + SubscriptionId = table.Column(type: "int", nullable: false), + StartedOn = table.Column(type: "datetime(6)", nullable: false), + FinishedOn = table.Column(type: "datetime(6)", nullable: false), + Outcome = table.Column(type: "int", nullable: false), + ErrorMessage = table.Column(type: "varchar(4000)", maxLength: 4000, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + ExchangeIds = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_ReceiveAttempts", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_ReceiveAttempts_SubscriptionId_StartedOn", + table: "ReceiveAttempts", + columns: new[] { "SubscriptionId", "StartedOn" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ReceiveAttempts"); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs index f8970906..78a9a898 100644 --- a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs @@ -599,6 +599,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SW.Bitween.Domain.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExchangeIds") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("Outcome") + .HasColumnType("int"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "StartedOn"); + + b.ToTable("ReceiveAttempts", (string)null); + }); + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => { b.Property("SubscriptionId") diff --git a/SW.Bitween.PgSql/BitweenDbContext.cs b/SW.Bitween.PgSql/BitweenDbContext.cs index 291c4c7c..b0456c75 100644 --- a/SW.Bitween.PgSql/BitweenDbContext.cs +++ b/SW.Bitween.PgSql/BitweenDbContext.cs @@ -425,6 +425,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(p => p.On); }); + modelBuilder.Entity(b => + { + b.Property(p => p.Id).ValueGeneratedOnAdd(); + b.HasIndex(p => new { p.SubscriptionId, p.StartedOn }); + }); + modelBuilder.Entity(b => { b.HasKey(p => new { p.SubscriptionId, p.GroupId }); diff --git a/SW.Bitween.PgSql/Migrations/20260824093537_AddReceiveAttempts.Designer.cs b/SW.Bitween.PgSql/Migrations/20260824093537_AddReceiveAttempts.Designer.cs new file mode 100644 index 00000000..c1722c67 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260824093537_AddReceiveAttempts.Designer.cs @@ -0,0 +1,2455 @@ +// +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("20260824093537_AddReceiveAttempts")] + partial class AddReceiveAttempts + { + /// + 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("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.AccountRoleLink", b => + { + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("RoleId") + .HasColumnType("integer") + .HasColumnName("role_id"); + + b.HasKey("AccountId", "RoleId") + .HasName("pk_account_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_account_roles_role_id"); + + b.ToTable("AccountRoles", "infolink"); + }); + + 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.Accounts.Role", 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("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_roles_name"); + + b.ToTable("Roles", "infolink"); + + b.HasData( + new + { + Id = 1, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Full access to everything, including members, roles and settings.", + IsSystem = true, + Name = "Administrator", + Permissions = "[]" + }, + new + { + Id = 2, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Runs and configures integrations. Can't manage members, roles or settings.", + IsSystem = true, + Name = "Member", + Permissions = "[]" + }, + new + { + Id = 3, + CreatedOn = new DateTime(2021, 12, 31, 22, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + 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") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("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("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + 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("Code") + .IsUnique() + .HasDatabaseName("ix_document_code"); + + 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("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + 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("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + 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.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("ExchangeIds") + .HasColumnType("text[]") + .HasColumnName("exchange_ids"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Outcome") + .HasColumnType("integer") + .HasColumnName("outcome"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_receive_attempt"); + + b.HasIndex("SubscriptionId", "StartedOn") + .HasDatabaseName("ix_receive_attempt_subscription_id_started_on"); + + b.ToTable("receive_attempt", "infolink"); + }); + + 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.Setting", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("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("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_settings"); + + b.ToTable("Settings", "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("ManualRetry") + .HasColumnType("boolean") + .HasColumnName("manual_retry"); + + 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.AccountRoleLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_accounts_account_id"); + + b.HasOne("SW.Bitween.Domain.Accounts.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_roles_roles_role_id"); + }); + + 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/20260824093537_AddReceiveAttempts.cs b/SW.Bitween.PgSql/Migrations/20260824093537_AddReceiveAttempts.cs new file mode 100644 index 00000000..cee14373 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20260824093537_AddReceiveAttempts.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + public partial class AddReceiveAttempts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "receive_attempt", + schema: "infolink", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + subscription_id = table.Column(type: "integer", nullable: false), + started_on = table.Column(type: "timestamp with time zone", nullable: false), + finished_on = table.Column(type: "timestamp with time zone", nullable: false), + outcome = table.Column(type: "integer", nullable: false), + error_message = table.Column(type: "text", nullable: true), + exchange_ids = table.Column(type: "text[]", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_receive_attempt", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_receive_attempt_subscription_id_started_on", + schema: "infolink", + table: "receive_attempt", + columns: new[] { "subscription_id", "started_on" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "receive_attempt", + schema: "infolink"); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs index daf3df1d..1a37f6f0 100644 --- a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -727,6 +727,48 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SW.Bitween.Domain.ReceiveAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("ExchangeIds") + .HasColumnType("text[]") + .HasColumnName("exchange_ids"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("Outcome") + .HasColumnType("integer") + .HasColumnName("outcome"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_receive_attempt"); + + b.HasIndex("SubscriptionId", "StartedOn") + .HasDatabaseName("ix_receive_attempt_subscription_id_started_on"); + + b.ToTable("receive_attempt", "infolink"); + }); + modelBuilder.Entity("SW.Bitween.Domain.RetryAlertOverride", b => { b.Property("SubscriptionId") diff --git a/SW.Bitween.Sdk/Model/Subscription.cs b/SW.Bitween.Sdk/Model/Subscription.cs index 1ab28e63..5211a967 100644 --- a/SW.Bitween.Sdk/Model/Subscription.cs +++ b/SW.Bitween.Sdk/Model/Subscription.cs @@ -84,6 +84,45 @@ public class SearchSubscriptionLastRunsModel { } + /// + /// One execution of a Receiving subscription's own receive step, recorded directly by + /// ReceivingJob — independent of the scheduler's own run history, which only knows + /// whether Execute() threw (it never does; the receive step's own failures are caught + /// and reported here instead, alongside the successes and no-op checks history never covers). + /// + public enum ReceiveOutcome + { + Failed = 0, + NoNewData = 1, + Received = 2, + } + + public class ReceiveAttemptExchangeRef + { + public string Id { get; set; } + public bool? Status { get; set; } + public bool? ResponseBad { get; set; } + public IDictionary PromotedProperties { get; set; } + } + + public class ReceiveAttemptModel + { + public int Id { get; set; } + public DateTime StartedOn { get; set; } + public DateTime FinishedOn { get; set; } + public ReceiveOutcome Outcome { get; set; } + public string ErrorMessage { get; set; } + public ICollection Exchanges { get; set; } + } + + public class SearchReceiveAttemptsModel + { + public int SubscriptionId { get; set; } + public ReceiveOutcome? Outcome { get; set; } + public int? Offset { get; set; } + public int? Limit { get; set; } + } + /// /// Whether a scheduled subscription will actually fire — read from the scheduler /// itself, not from what Bitween thinks it configured. The two can disagree, and diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index 12718ecd..643236f1 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -60,7 +60,7 @@ interface RawDelayedRetryRow { * *response* (the handler delivered but the receiver answered with an error) * is distinct from an outright failure. */ -const deriveStatus = (raw: Pick): ExchangeStatus => +export const deriveStatus = (raw: Pick): ExchangeStatus => raw.status === null ? "processing" : !raw.status ? "failed" : raw.responseBad ? "badResponse" : "success"; const STATUS_FILTER: Record = { diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 8ddc36ef..4e35d18c 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -590,6 +590,26 @@ export interface IntegrationLastRun extends IntegrationRun { recentSucceeded: number; } +/** One poll of a Receiving integration's own receive step — independent of the scheduler's + * run history, which only knows whether the method threw (it never does; failures here are + * caught and reported this way instead). */ +export type ReceiveOutcome = "Failed" | "NoNewData" | "Received"; + +export interface ReceiveAttemptExchange { + id: string; + status: ExchangeStatus; + promotedProperties: Record | null; +} + +export interface ReceiveAttemptRow { + id: number; + startedOn: string; + finishedOn: string; + outcome: ReceiveOutcome; + errorMessage: string | null; + exchanges: ReceiveAttemptExchange[]; +} + /** * Whether a scheduled integration will actually fire, straight from the scheduler. * Everything here can disagree with what the integration's own record says, and diff --git a/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/Overview.tsx b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/Overview.tsx index b5c5d7a0..567a5901 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/Overview.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/Overview.tsx @@ -10,6 +10,7 @@ import { Panel } from "../../../components/ui/Panel"; import { ExchangesList, HealthBadge, TrailTable } from "../../../components/config/shared"; import { WorkGroupDialog } from "../../../components/config/WorkGroupDialog"; import { formatDate, formatDateTime, formatDurationMs, timeAgo, timeUntil } from "../../../lib/dates"; +import { ReceiveAttemptsPanel } from "./ReceiveAttemptsPanel"; import { RetryBudget } from "./RetryBudget"; import type { Draft, EntryPoint } from "./model"; @@ -174,11 +175,34 @@ export function Overview({ staleTime: Infinity, }); const retryPolicies = useQuery({ queryKey: ["retry-policies"], queryFn: () => api.listRetryPolicies() }); + // Receiving gets its own attempt history (ReceiveAttemptsPanel) instead — the scheduler's + // run history there is Quartz vocabulary an operator has no reason to know, and it always + // reports success even when the receive step itself failed (see ReceivingJob). + const receiving = s.type === "Receiving"; const runs = useQuery({ queryKey: ["integration-runs", s.id], queryFn: () => api.listIntegrationRuns(s.id, 20), - enabled: scheduled, + enabled: scheduled && !receiving, }); + // Just for the "Last run" fact above — ReceiveAttemptsPanel fetches its own page. + const latestAttempt = useQuery({ + queryKey: ["receive-attempts", s.id, null, 0, 1], + queryFn: () => api.searchReceiveAttempts(s.id, { outcome: null, offset: 0, limit: 1 }), + enabled: receiving, + }); + const lastReceiveRun: IntegrationRun | undefined = ((): IntegrationRun | undefined => { + const a = latestAttempt.data?.result[0]; + if (!a) return undefined; + return { + startedOn: a.startedOn, + endedOn: a.finishedOn, + durationMs: new Date(a.finishedOn).getTime() - new Date(a.startedOn).getTime(), + success: a.outcome !== "Failed", + error: a.errorMessage, + node: "", + manual: false, + }; + })(); const paused = s.pausedOn !== null; /** undefined = closed, null = creating, number = editing that group. */ const [groupDialog, setGroupDialog] = useState(undefined); @@ -196,7 +220,7 @@ export function Overview({ <> {s.nextReceiveOn ? timeUntil(s.nextReceiveOn) : "—"} - + )} @@ -271,7 +295,9 @@ export function Overview({ on resume.

)} - {s.lastException && ( + {/* Receiving gets this per-attempt instead, in ReceiveAttemptsPanel below — showing + it again here duplicated the same error twice on one page. */} + {s.lastException && !receiving && (
           {s.lastException}
         
@@ -285,9 +311,15 @@ export function Overview({ /> )} + {receiving && ( + + + + )} +
- {scheduled && ( + {scheduled && !receiving && ( @@ -301,11 +333,13 @@ export function Overview({
- - - - - + {!receiving && ( + + + + + + )} {s.watchingNotifiers.length > 0 && ( diff --git a/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/ReceiveAttemptsPanel.tsx b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/ReceiveAttemptsPanel.tsx new file mode 100644 index 00000000..0bc5c29b --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/integrations/studio/ReceiveAttemptsPanel.tsx @@ -0,0 +1,184 @@ +import { useState } from "react"; +import { Link } from "react-router"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { Check, ChevronDown, ChevronRight, Copy } from "lucide-react"; +import { api, type ReceiveAttemptRow, type ReceiveOutcome } from "../../../api"; +import { Badge, EmptyState, LoadingBlock } from "../../../components/ui/basics"; +import { Select } from "../../../components/ui/forms"; +import { Pagination } from "../../../components/ui/Pagination"; +import { Table } from "../../../components/ui/Table"; +import { PromotedProps } from "../../../components/config/shared"; +import { StatusBadge } from "../../exchanges/shared"; +import { formatDateTime, timeAgo } from "../../../lib/dates"; + +const PAGE_SIZE = 25; + +const OUTCOME_OPTIONS: { value: string; label: string }[] = [ + { value: "", label: "All" }, + { value: "Failed", label: "Couldn't check" }, + { value: "NoNewData", label: "Nothing new" }, + { value: "Received", label: "Received data" }, +]; + +function CopyErrorButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function ErrorText({ text }: { text: string }) { + const [expanded, setExpanded] = useState(false); + return ( + + + {expanded ? ( +
+          {text}
+        
+ ) : ( + + {text} + + )} + +
+ ); +} + +function AttemptResult({ attempt }: { attempt: ReceiveAttemptRow }) { + if (attempt.outcome === "Failed") + return ( + + Couldn't check + {attempt.errorMessage && } + + ); + if (attempt.outcome === "NoNewData") + return Nothing new; + return ( + + Received {attempt.exchanges.length} item{attempt.exchanges.length === 1 ? "" : "s"} + + ); +} + +function AttemptExchanges({ exchanges }: { exchanges: ReceiveAttemptRow["exchanges"] }) { + if (exchanges.length === 0) return ; + return ( + + {exchanges.map((x) => ( + e.stopPropagation()} + className="flex items-center gap-1.5 hover:opacity-70" + > + + + + ))} + + ); +} + +/** + * Every time a Receiving integration checked for new data — replaces the old pairing of + * the scheduler's own run history (Quartz vocabulary an operator has no reason to know, + * and which always reports success even when the receive step itself failed) next to a + * capped 8-row exchange glance. One row per run regardless of what it found, so a poll + * that couldn't even connect shows up here just as much as one that produced an exchange — + * "Exchanges" as a title would undersell the rows that have none. + */ +export function ReceiveAttemptsPanel({ subscriptionId }: { subscriptionId: number }) { + const [outcome, setOutcome] = useState(null); + const [offset, setOffset] = useState(0); + + const attempts = useQuery({ + queryKey: ["receive-attempts", subscriptionId, outcome, offset], + queryFn: () => api.searchReceiveAttempts(subscriptionId, { outcome, offset, limit: PAGE_SIZE }), + placeholderData: keepPreviousData, + }); + + const rows = attempts.data?.result ?? []; + const total = attempts.data?.total ?? 0; + + return ( +
+
+
+

Runs

+

+ Every time this integration checked for new data — what it found, and what happened to it. +

+
+
+
a.id} + minWidth="min-w-160" + footer={ + + } + columns={[ + { + header: "When", + cell: (a) => ( + + {timeAgo(a.startedOn)} + + ), + }, + { header: "Result", cell: (a) => }, + { header: "Exchange", cell: (a) => }, + ]} + /> + )} + + ); +}