diff --git a/SW.Bitween.Api/Data/BitweenDbContext.cs b/SW.Bitween.Api/Data/BitweenDbContext.cs index 629ee9d8..1b569444 100644 --- a/SW.Bitween.Api/Data/BitweenDbContext.cs +++ b/SW.Bitween.Api/Data/BitweenDbContext.cs @@ -748,6 +748,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(p => p.Hash).IsUnique(); }); + modelBuilder.Entity(b => + { + b.ToTable("AccountLinks"); + b.HasKey(p => p.Id); + b.Property(p => p.Id).ValueGeneratedOnAdd(); + b.HasOne().WithMany().HasForeignKey(p => p.AccountId).OnDelete(DeleteBehavior.Cascade); + + b.Property(p => p.Purpose).HasConversion(); + b.Property(p => p.Hash).IsRequired().IsUnicode(false).HasMaxLength(64); + b.Property(p => p.EmailError).HasMaxLength(500); + // A link is looked up by its hash when it is opened. + b.HasIndex(p => p.Hash).IsUnique(); + }); + modelBuilder.Entity(b => { b.ToTable("Roles"); diff --git a/SW.Bitween.Api/Domain/Accounts/AccountLink.cs b/SW.Bitween.Api/Domain/Accounts/AccountLink.cs new file mode 100644 index 00000000..c30bbd19 --- /dev/null +++ b/SW.Bitween.Api/Domain/Accounts/AccountLink.cs @@ -0,0 +1,77 @@ +using System; +using System.Security.Cryptography; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Domain.Accounts; + +/// +/// A link emailed to a person to choose their password: an invite to a new member, or a reset +/// asked for from the sign-in page. It works once, and only until it expires. +/// +/// +/// Only a hash is kept, as for service tokens: the link itself is in the email, and whoever reads +/// this table can't use it. A new invite replaces the old one; reset links each last their hour. +/// Using any link ends every link the member has. +/// +public class AccountLink : BaseEntity, IHasCreationTime +{ + public static readonly TimeSpan InviteLifetime = TimeSpan.FromHours(48); + public static readonly TimeSpan ResetLifetime = TimeSpan.FromHours(1); + + private AccountLink() + { + } + + AccountLink(int accountId, AccountLinkPurpose purpose, string hash, DateTime expiresOn) + { + AccountId = accountId; + Purpose = purpose; + Hash = hash; + ExpiresOn = expiresOn; + } + + /// A new link for the account, and the only time its token is known. + public static (AccountLink Link, string Token) Issue(int accountId, AccountLinkPurpose purpose, DateTime nowUtc) + { + // 32 random bytes, URL-safe: it goes in a link's query string. + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + var lifetime = purpose == AccountLinkPurpose.Invite ? InviteLifetime : ResetLifetime; + return (new AccountLink(accountId, purpose, HashOf(token), nowUtc + lifetime) { CreatedOn = nowUtc }, token); + } + + public static string HashOf(string token) => ServiceToken.HashOf(token); + + public int AccountId { get; private set; } + public AccountLinkPurpose Purpose { get; private set; } + + /// SHA-256 of the token, hex. + public string Hash { get; private set; } + + public DateTime ExpiresOn { get; private set; } + + /// When the email carrying it was handed to the mail server; null when it wasn't sent. + public DateTime? EmailedOn { get; private set; } + + /// Why the email couldn't be sent, for the member's page; null once one was. + public string EmailError { get; private set; } + + public DateTime CreatedOn { get; set; } + + public bool IsUsable(DateTime nowUtc) => ExpiresOn > nowUtc; + + public void Emailed(DateTime nowUtc) + { + EmailedOn = nowUtc; + EmailError = null; + } + + public void NotEmailed(string error) => + EmailError = error is { Length: > 500 } ? error[..500] : error; +} + +public enum AccountLinkPurpose : byte +{ + Invite = 1, + PasswordReset = 2, +} diff --git a/SW.Bitween.Api/Resources/Accounts/ChangePassword.cs b/SW.Bitween.Api/Resources/Accounts/ChangePassword.cs index 6ef00734..ba9a120e 100644 --- a/SW.Bitween.Api/Resources/Accounts/ChangePassword.cs +++ b/SW.Bitween.Api/Resources/Accounts/ChangePassword.cs @@ -11,7 +11,7 @@ namespace SW.Bitween.Resources.Accounts; [HandlerName("changePassword")] public class ChangePassword(BitweenDbContext dbContext, RequestContext requestContext, SignInThrottle throttle, - Microsoft.AspNetCore.Http.IHttpContextAccessor httpContextAccessor) + Microsoft.AspNetCore.Http.IHttpContextAccessor httpContextAccessor, Services.AccountMail mail) : ICommandHandler { public async Task Handle(ChangePasswordModel request) @@ -54,6 +54,10 @@ await dbContext.Set() .Where(t => t.AccountId == account.Id) .ExecuteDeleteAsync(); + // Whoever holds a stolen session can change the password too; the owner hears of it. + mail.SendInBackground(account.Email, Services.AccountEmails.PasswordChanged( + Services.AccountEmails.ChangedBy.Themselves, account.Email, mail.SignedInBase, mail.ResetAvailable)); + return null; } diff --git a/SW.Bitween.Api/Resources/Accounts/Create.cs b/SW.Bitween.Api/Resources/Accounts/Create.cs index f9f2f1d5..7173fbc2 100644 --- a/SW.Bitween.Api/Resources/Accounts/Create.cs +++ b/SW.Bitween.Api/Resources/Accounts/Create.cs @@ -20,9 +20,15 @@ public async Task Handle(CreateAccountModel request) await requestContext.EnsurePermission(dbContext, Model.Permissions.Users.Create); if (string.IsNullOrEmpty(request.Name) || string.IsNullOrEmpty(request.Email) || - (!_bitweenOptions.DisableEmailPasswordLogin && string.IsNullOrEmpty(request.Password))) + (!_bitweenOptions.DisableEmailPasswordLogin && !request.Invite && string.IsNullOrEmpty(request.Password))) throw new SWValidationException("INVALID_PAYLOAD", "The payload is invalid"); + // An invited member chooses their own password; one set here too would be a second + // way in that nobody asked them about. + if (request.Invite && !string.IsNullOrEmpty(request.Password)) + throw new SWValidationException("INVITE_OR_PASSWORD", + "Send either a password or an invite, not both."); + if (await dbContext.Set().AnyAsync(a => a.Email == request.Email)) throw new SWValidationException("ACCOUNT_EXISTS", $"Account with email {request.Email} exists"); @@ -39,7 +45,7 @@ public async Task Handle(CreateAccountModel request) // No password at all when this instance signs in through Microsoft only - the account // exists purely to be matched by email. - var password = _bitweenOptions.DisableEmailPasswordLogin + var password = _bitweenOptions.DisableEmailPasswordLogin || request.Invite ? null : SecurePasswordHasher.Hash(request.Password); @@ -70,7 +76,8 @@ public Validate(BitweenOptions bitweenOptions) { RuleFor(i => i.Name).NotEmpty(); RuleFor(i => i.Email).NotEmpty(); - RuleFor(i => i.Password).Password().When(_ => !bitweenOptions.DisableEmailPasswordLogin); + // An invited member chooses their own password later, against the same rules. + RuleFor(i => i.Password).Password().When(i => !bitweenOptions.DisableEmailPasswordLogin && !i.Invite); // The handler prefers explicit role ids and only falls back to the legacy coarse // Role, so demanding Role unconditionally rejected the payload the UI actually // sends - adding a member failed outright. Still required when no ids were sent, diff --git a/SW.Bitween.Api/Resources/Accounts/ForgotPassword.cs b/SW.Bitween.Api/Resources/Accounts/ForgotPassword.cs new file mode 100644 index 00000000..12d9e043 --- /dev/null +++ b/SW.Bitween.Api/Resources/Accounts/ForgotPassword.cs @@ -0,0 +1,82 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.Bitween.Services; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Accounts; + +/// +/// "Forgot password?": emails a reset link to the address, when it is a member's. +/// +/// +/// The answer is the same whether the address has an account or not, and so is the work done +/// before it: one lookup. Making the link and sending it happen after the answer, so neither the +/// reply nor its timing says who has an account here. A member gets at most one email a minute, +/// however often anyone sends the form. +/// +[HandlerName("forgotPassword")] +[Unprotect] +public class ForgotPassword(BitweenDbContext dbContext, AccountMail mail, IServiceScopeFactory scopes, + ILogger logger) : ICommandHandler +{ + static readonly TimeSpan OneEmailPer = TimeSpan.FromMinutes(1); + + public async Task Handle(ForgotPasswordModel request) + { + if (!mail.ResetAvailable) + throw new SWValidationException("PASSWORD_RESET_OFF", + "This Bitween can't email password resets. Ask your administrator to set a new password for you."); + + var email = request.Email?.Trim(); + if (string.IsNullOrEmpty(email)) return null; + + var account = await dbContext.Set().AsNoTracking() + .Where(a => a.Email.ToLower() == email.ToLower() && a.Kind == AccountKind.Person && !a.Disabled) + .Select(a => new { a.Id, a.Email }) + .FirstOrDefaultAsync(); + + if (account is not null) + _ = Task.Run(() => IssueAsync(account.Id, account.Email)); + + return null; + } + + /// Makes the link and emails it, on a scope of its own: the request's is gone by then. + async Task IssueAsync(int accountId, string email) + { + try + { + await using var scope = scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + + if (await db.Set().AnyAsync(l => l.AccountId == accountId && + l.Purpose == AccountLinkPurpose.PasswordReset && + l.CreatedOn > now - OneEmailPer)) + return; + + // Earlier reset links keep working until they expire, and using any of them ends them all. + // Replacing them here instead let two requests at once each delete the other's link, so + // one of the two emails carried a link that no longer worked. Only expired ones go. + await db.Set() + .Where(l => l.AccountId == accountId && l.ExpiresOn <= now) + .ExecuteDeleteAsync(); + + var (link, token) = AccountLink.Issue(accountId, AccountLinkPurpose.PasswordReset, now); + db.Add(link); + await db.SaveChangesAsync(); + + await mail.SendAsync(email, AccountEmails.Reset(email, mail.PublicBase, token)); + } + catch (Exception ex) + { + logger.LogWarning(ex, "A password reset for account {AccountId} could not be made.", accountId); + } + } +} diff --git a/SW.Bitween.Api/Resources/Accounts/Invite.cs b/SW.Bitween.Api/Resources/Accounts/Invite.cs new file mode 100644 index 00000000..c2259e6d --- /dev/null +++ b/SW.Bitween.Api/Resources/Accounts/Invite.cs @@ -0,0 +1,95 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.Bitween.Services; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Accounts; + +/// +/// Invites a member who has no password yet to choose one: emailed when there is a mail server, +/// otherwise handed back for whoever invited to pass on. Sending it again replaces the last one. +/// +/// +/// On an instance that signs in only with Microsoft or single sign-on there is no password to +/// choose, so the invite is an email saying where to sign in, and needs a mail server. +/// +[HandlerName("invite")] +public class Invite(BitweenDbContext dbContext, RequestContext requestContext, BitweenOptions options, + AccountMail mail) : ICommandHandler +{ + public async Task Handle(int key, InviteAccountModel request) + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Users.Create); + + var account = await Members.FindAsync(dbContext, key); + if (account is null) + throw new SWValidationException("ACCOUNT_NOT_FOUND", $"No account exists with the id {key}"); + + // The link chooses their password, which is signing in as them: as with Set password, only + // someone who holds everything they hold may have it. + await GrantLimits.EnsureCallerOutranks(dbContext, requestContext, key); + + if (account.Disabled) + throw new SWValidationException("ACCOUNT_DISABLED", + $"{account.DisplayName} is disabled. Re-enable them before inviting them."); + + var callerId = Convert.ToInt32(requestContext.GetNameIdentifier()); + var inviter = await dbContext.Set().Where(a => a.Id == callerId) + .Select(a => a.DisplayName).FirstOrDefaultAsync() ?? "An administrator"; + var linkBase = mail.SignedInBase; + + if (options.DisableEmailPasswordLogin) + { + if (!mail.CanSend) + throw new SWValidationException("NO_MAIL_SERVER", + "Bitween can't email an invite until a mail server is set up in Settings, under Mail server."); + + var sent = await mail.SendAsync(account.Email, AccountEmails.InviteToSignIn(inviter, account.Email, linkBase)); + return new InviteResultModel { Emailed = sent is null, EmailError = sent }; + } + + if (!string.IsNullOrEmpty(account.Password)) + throw new SWValidationException("ALREADY_HAS_PASSWORD", + $"{account.DisplayName} already has a password. Set a new one for them, " + + "or have them use Forgot password on the sign-in page."); + + var now = DateTime.UtcNow; + var (link, token) = AccountLink.Issue(key, AccountLinkPurpose.Invite, now); + + // Only the newest invite works, so a resend makes any earlier email useless. The old link goes + // and the new one is stored as one change, committed before anything is sent: an email is + // never sent for a link that wasn't saved, and a failed save leaves the old link working. + await using (var transaction = await dbContext.Database.BeginTransactionAsync()) + { + await dbContext.Set() + .Where(l => l.AccountId == key && l.Purpose == AccountLinkPurpose.Invite) + .ExecuteDeleteAsync(); + dbContext.Add(link); + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + } + + string error = null; + if (mail.CanSend) + { + error = await mail.SendAsync(account.Email, AccountEmails.Invite(inviter, account.Email, linkBase, token)); + if (error is null) link.Emailed(now); + else link.NotEmailed(error); + await dbContext.SaveChangesAsync(); + } + + var emailed = mail.CanSend && error is null; + return new InviteResultModel + { + Emailed = emailed, + EmailError = error, + ExpiresOn = link.ExpiresOn, + // Handed back only when nobody else will get it to them. + Link = emailed ? null : $"{linkBase}/set-password?token={token}" + }; + } +} diff --git a/SW.Bitween.Api/Resources/Accounts/Login.cs b/SW.Bitween.Api/Resources/Accounts/Login.cs index 8457eebf..553cc3f7 100644 --- a/SW.Bitween.Api/Resources/Accounts/Login.cs +++ b/SW.Bitween.Api/Resources/Accounts/Login.cs @@ -15,7 +15,7 @@ namespace SW.Bitween.Resources.Accounts [Unprotect] public class Login(JwtTokenParameters jwtTokenParameters, BitweenDbContext dbContext, BitweenOptions BitweenSettings, IHttpContextAccessor httpContextAccessor, ILogger logger, - SignInThrottle throttle, Services.OidcSignIn oidc) : ICommandHandler + SignInThrottle throttle, Services.OidcSignIn oidc, Services.AccountMail mail) : ICommandHandler { // Account-wide, across every address: the backstop for guessing spread over many of them. // Five from any one address already locks that address out - see SignInThrottle. @@ -297,6 +297,12 @@ await dbContext.Set() a => a.FailedLoginCount + 1 >= MaxFailedLoginAttempts ? lockoutEnd : a.LockoutEnd) .SetProperty(a => a.FailedLoginCount, a => a.FailedLoginCount + 1 >= MaxFailedLoginAttempts ? 0 : a.FailedLoginCount + 1)); + + // It wasn't locked a moment ago - that was checked above - so a lock now is this + // attempt's, and the owner should know someone is guessing. + if (await dbContext.Set().AnyAsync(a => a.Id == account.Id && a.LockoutEnd > nowUtc)) + mail.SendInBackground(account.Email, Services.AccountEmails.Locked( + account.Email, mail.PublicBase, MaxFailedLoginAttempts, lockoutEnd)); throw new SWException("Invalid username or password."); } diff --git a/SW.Bitween.Api/Resources/Accounts/PasswordLink.cs b/SW.Bitween.Api/Resources/Accounts/PasswordLink.cs new file mode 100644 index 00000000..33278691 --- /dev/null +++ b/SW.Bitween.Api/Resources/Accounts/PasswordLink.cs @@ -0,0 +1,101 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.Model; +using SW.Bitween.Services; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Accounts; + +/// The links invite and reset emails carry, and the account each one is for. +internal static class PasswordLinks +{ + public const string Invalid = "This link has expired or has already been used. Ask for a new one."; + + /// The usable link the token belongs to and its member, or a refusal. + public static async Task<(AccountLink Link, Account Account)> FindAsync(BitweenDbContext dbContext, string token) + { + if (string.IsNullOrWhiteSpace(token) || token.Length > 100) + throw new SWValidationException("LINK_INVALID", Invalid); + + var hash = AccountLink.HashOf(token.Trim()); + var link = await dbContext.Set().FirstOrDefaultAsync(l => l.Hash == hash); + var account = link is null ? null : await Members.FindAsync(dbContext, link.AccountId); + + if (link is null || !link.IsUsable(DateTime.UtcNow) || account is null || account.Disabled) + throw new SWValidationException("LINK_INVALID", Invalid); + + return (link, account); + } +} + +/// Who a link is for, so its page can greet them and say what it does. Changes nothing. +[HandlerName("passwordLink")] +[Unprotect] +public class PasswordLink(BitweenDbContext dbContext) : ICommandHandler +{ + public async Task Handle(PasswordLinkModel request) + { + var (link, account) = await PasswordLinks.FindAsync(dbContext, request.Token); + return new PasswordLinkInfoModel + { + Purpose = link.Purpose.ToString(), + Name = account.DisplayName, + Email = account.Email, + ExpiresOn = link.ExpiresOn + }; + } +} + +/// +/// Sets the password a link was sent for. The link stops working, and so does every other one the +/// member had, and every session they had open. +/// +[HandlerName("usePasswordLink")] +[Unprotect] +public class UsePasswordLink(BitweenDbContext dbContext, SignInThrottle throttle, AccountMail mail) + : ICommandHandler +{ + public async Task Handle(UsePasswordLinkModel request) + { + var (link, account) = await PasswordLinks.FindAsync(dbContext, request.Token); + + await using (var transaction = await dbContext.Database.BeginTransactionAsync()) + { + // Spent first, and only once: of two requests with the same link at the same moment, one + // deletes it and the other finds nothing to delete, rather than both setting a password. + if (await dbContext.Set().Where(l => l.Id == link.Id).ExecuteDeleteAsync() == 0) + throw new SWValidationException("LINK_INVALID", PasswordLinks.Invalid); + + account.SetPassword(request.Password); + // Locked out after wrong guesses is usually why someone resets; the new password is the way back in. + account.Unlock(); + await dbContext.SaveChangesAsync(); + + await dbContext.Set().Where(l => l.AccountId == account.Id).ExecuteDeleteAsync(); + // A session opened with the old password outlives the change unless its refresh tokens go. + await dbContext.Set().Where(t => t.AccountId == account.Id).ExecuteDeleteAsync(); + await transaction.CommitAsync(); + } + + throttle.ClearAccount(account.Id); + + // An invite is the member choosing their first password: nothing to warn them about. + if (link.Purpose == AccountLinkPurpose.PasswordReset) + mail.SendInBackground(account.Email, AccountEmails.PasswordChanged( + AccountEmails.ChangedBy.ResetLink, account.Email, mail.PublicBase, mail.ResetAvailable)); + + return new { account.Email }; + } + + private class Validate : AbstractValidator + { + public Validate() + { + RuleFor(i => i.Password).Password(); + } + } +} diff --git a/SW.Bitween.Api/Resources/Accounts/Search.cs b/SW.Bitween.Api/Resources/Accounts/Search.cs index 626af326..d06af19d 100644 --- a/SW.Bitween.Api/Resources/Accounts/Search.cs +++ b/SW.Bitween.Api/Resources/Accounts/Search.cs @@ -51,15 +51,36 @@ public async Task Handle(SearchMembersModel request) Role = a.Role.ToString(), LockoutEnd = a.LockoutEnd, LastSignInOn = a.LastSignInOn, - MicrosoftLinked = a.MicrosoftIdentity != null + MicrosoftLinked = a.MicrosoftIdentity != null, + HasPassword = a.Password != null && a.Password != "" }) .ToListAsync(); var accountIds = accounts.Select(a => a.Id).ToList(); var rolesByAccount = await AccountRoles.For(dbContext, accountIds); + // The latest invite of each member still to choose a password. Expired ones too: the + // page says it expired, and offers another. + var waiting = accounts.Where(a => !a.HasPassword).Select(a => a.Id).ToList(); + var invites = waiting.Count == 0 + ? new List() + : await dbContext.Set().AsNoTracking() + .Where(l => waiting.Contains(l.AccountId) && l.Purpose == AccountLinkPurpose.Invite) + .ToListAsync(); + foreach (var account in accounts) + { account.Roles = rolesByAccount.GetValueOrDefault(account.Id, []); + account.Invite = invites.Where(l => l.AccountId == account.Id) + .OrderByDescending(l => l.CreatedOn) + .Select(l => new AccountInviteModel + { + ExpiresOn = l.ExpiresOn, + EmailedOn = l.EmailedOn, + EmailError = l.EmailError + }) + .FirstOrDefault(); + } return new { diff --git a/SW.Bitween.Api/Resources/Accounts/SetPassword.cs b/SW.Bitween.Api/Resources/Accounts/SetPassword.cs index e236ed67..85495b28 100644 --- a/SW.Bitween.Api/Resources/Accounts/SetPassword.cs +++ b/SW.Bitween.Api/Resources/Accounts/SetPassword.cs @@ -10,12 +10,12 @@ namespace SW.Bitween.Resources.Accounts; /// -/// Sets a member's password on their behalf. Bitween has no outbound mail, so there's no -/// self-service reset - without this, anyone who forgets their password is locked out for good. +/// Sets a member's password on their behalf: for an instance without a mail server, where there is +/// no self-service reset, and for anyone who can't wait for one. /// Changing your own password goes through ChangePassword, which asks for the current one. /// [HandlerName("setPassword")] -public class SetPassword(BitweenDbContext dbContext, RequestContext requestContext) +public class SetPassword(BitweenDbContext dbContext, RequestContext requestContext, Services.AccountMail mail) : ICommandHandler { public async Task Handle(int key, SetAccountPasswordModel request) @@ -44,6 +44,9 @@ await dbContext.Set() .Where(t => t.AccountId == account.Id) .ExecuteDeleteAsync(); + mail.SendInBackground(account.Email, Services.AccountEmails.PasswordChanged( + Services.AccountEmails.ChangedBy.Administrator, account.Email, mail.SignedInBase, mail.ResetAvailable)); + return null; } diff --git a/SW.Bitween.Api/Resources/Settings/Config.cs b/SW.Bitween.Api/Resources/Settings/Config.cs index 012b5913..b951ed99 100644 --- a/SW.Bitween.Api/Resources/Settings/Config.cs +++ b/SW.Bitween.Api/Resources/Settings/Config.cs @@ -45,6 +45,10 @@ public async Task Handle() // which starts the sign-in. OidcEnabled = !string.IsNullOrWhiteSpace(BitweenOptions.OidcAuthority) && !string.IsNullOrWhiteSpace(BitweenOptions.OidcClientId), OidcDisplayName = string.IsNullOrWhiteSpace(BitweenOptions.OidcDisplayName) ? null : BitweenOptions.OidcDisplayName.Trim(), + // Whether Bitween can email members: the Team page offers invites by email, and the + // sign-in page "Forgot password?", only when it can. + MailServerConfigured = BitweenOptions.MailServer.IsSet, + PasswordResetAvailable = Services.AccountMail.ResetAvailableFor(BitweenOptions), IsRabbitMqManagementConfigured = !string.IsNullOrWhiteSpace(BitweenOptions.RabbitMqManagementUrl) && !string.IsNullOrWhiteSpace(BitweenOptions.RabbitMqManagementUsername) && !string.IsNullOrWhiteSpace(BitweenOptions.RabbitMqManagementPassword), diff --git a/SW.Bitween.Api/Services/AccountMail.cs b/SW.Bitween.Api/Services/AccountMail.cs new file mode 100644 index 00000000..958f5300 --- /dev/null +++ b/SW.Bitween.Api/Services/AccountMail.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using SW.Bitween.NativeAdapters; +using SW.Bitween.NativeAdapters.ChannelMessages; +using SW.Bitween.NativeAdapters.EmailMessageHandler; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Services; + +/// +/// Emails about a person's own account - an invite, a password reset, a password changed, an +/// account locked - sent to that person through the mail server set in Settings. +/// +/// +/// Not through notification channels: those tell a team about its integrations, and a channel can +/// be a shared Teams or Slack room. These go to exactly one person, and carry links that sign in. +/// Laid out by the Email channel's own adapter, so they look like Bitween's other emails. +/// +public class AccountMail(BitweenOptions options, ILogger logger, + IHttpContextAccessor httpContextAccessor, DestinationCircuits circuits = null, ThemeOptions theme = null) +{ + /// Whether there is a mail server to send through. + public bool CanSend => options.MailServer.IsSet; + + /// Whether the sign-in page can offer "Forgot password?". + public bool ResetAvailable => ResetAvailableFor(options); + + /// + /// Needs the public address as well as a mail server. Anyone can ask for a reset, and the link + /// in it must not be built on the address their request came in on: a forged Host header would + /// send the victim a link to the attacker's site, with the victim's reset token in it. + /// + public static bool ResetAvailableFor(BitweenOptions options) => + options.MailServer.IsSet && !string.IsNullOrWhiteSpace(options.PublicUrl) && !options.DisableEmailPasswordLogin; + + /// The public address the chart sets, or null. + public string PublicBase => string.IsNullOrWhiteSpace(options.PublicUrl) ? null : options.PublicUrl.TrimEnd('/'); + + /// + /// Where links point for a request a signed-in member made: the public address, or else the one + /// they are using. Never for a request anyone could make; see . + /// + public string SignedInBase + { + get + { + if (PublicBase is { } publicBase) return publicBase; + var request = httpContextAccessor.HttpContext?.Request; + return request is { Host.HasValue: true } + ? $"{request.Scheme}://{request.Host}{request.PathBase}".TrimEnd('/') + : ""; + } + } + + /// Sends one email; null when it went, else why it didn't. + public async Task SendAsync(string to, ChannelMessage message) + { + if (!CanSend) return "No mail server is set up. Add one in Settings, under Mail server."; + try + { + var email = new NativeEmailMessageHandler(options.MailServer, circuits, theme?.PrimaryColor); + email.InitializeStartupValues(new Dictionary { ["To"] = to }); + await email.Handle(new XchangeFile(JsonConvert.SerializeObject(message))); + return null; + } + catch (Exception ex) + { + logger.LogWarning(ex, "An account email ({Title}) could not be sent: {Error}", + message.Title, ex.GetBaseException().Message); + return ex.GetBaseException().Message; + } + } + + /// + /// Sends without waiting, for an email the request doesn't depend on. Also what keeps a reset + /// from taking longer for an address that has an account than for one that hasn't. + /// + public void SendInBackground(string to, ChannelMessage message) + { + if (!CanSend || string.IsNullOrWhiteSpace(to)) return; + _ = Task.Run(() => SendAsync(to, message)); + } +} + +/// What each account email says. +public static class AccountEmails +{ + public enum ChangedBy + { + Themselves, + Administrator, + ResetLink + } + + public static ChannelMessage Invite(string inviter, string email, string linkBase, string token) => new() + { + Title = "You're invited to Bitween", + Tone = ChannelMessageTone.Info, + Text = $"{inviter} added you to Bitween{On(linkBase)}. Choose your password to sign in. " + + $"The link works once, for {Hours(Domain.Accounts.AccountLink.InviteLifetime)}.", + Facts = [new("Email", email)], + LinkUrl = $"{linkBase}/set-password?token={token}", + LinkLabel = "Choose your password" + }; + + /// For an instance that signs in only with Microsoft or single sign-on: no password to choose. + public static ChannelMessage InviteToSignIn(string inviter, string email, string linkBase) => new() + { + Title = "You're invited to Bitween", + Tone = ChannelMessageTone.Info, + Text = $"{inviter} added you to Bitween{On(linkBase)}. Sign in with your company account.", + Facts = [new("Email", email)], + LinkUrl = string.IsNullOrEmpty(linkBase) ? null : $"{linkBase}/login", + LinkLabel = "Sign in" + }; + + public static ChannelMessage Reset(string email, string linkBase, string token) => new() + { + Title = "Reset your Bitween password", + Tone = ChannelMessageTone.Info, + Text = $"Someone asked to reset the password of {email}{On(linkBase)}. If it was you, choose a new one: " + + $"the link works once, for {Hours(Domain.Accounts.AccountLink.ResetLifetime)}. " + + "If it wasn't, ignore this email. Your password stays as it is.", + LinkUrl = $"{linkBase}/set-password?token={token}", + LinkLabel = "Choose a new password" + }; + + public static ChannelMessage PasswordChanged(ChangedBy by, string email, string linkBase, bool resetAvailable) => new() + { + Title = "Your Bitween password was changed", + Tone = ChannelMessageTone.Warning, + Text = by switch + { + ChangedBy.Administrator => + $"An administrator set a new password for {email}{On(linkBase)}. You've been signed out everywhere. " + + "If you didn't expect this, tell your administrator.", + ChangedBy.ResetLink => + $"The password of {email}{On(linkBase)} was changed with a reset link. You've been signed out everywhere. " + + "If it wasn't you, tell your administrator.", + _ => + $"The password of {email}{On(linkBase)} was just changed. If it wasn't you, " + + (resetAvailable ? "reset it now and tell your administrator." : "tell your administrator now.") + }, + Facts = [new("When", When(DateTime.UtcNow))], + LinkUrl = string.IsNullOrEmpty(linkBase) ? null : resetAvailable ? $"{linkBase}/forgot-password" : $"{linkBase}/login", + LinkLabel = resetAvailable ? "Reset your password" : "Sign in" + }; + + public static ChannelMessage Locked(string email, string linkBase, int attempts, DateTime lockedUntil) => new() + { + Title = "Your Bitween account was locked", + Tone = ChannelMessageTone.Warning, + Text = $"After {attempts} wrong passwords for {email}{On(linkBase)}, signing in with a password is locked " + + "for a while. If this wasn't you, someone may be guessing your password: tell your administrator.", + Facts = [new("Locked until", When(lockedUntil))] + }; + + static string On(string linkBase) => + Uri.TryCreate(linkBase, UriKind.Absolute, out var uri) ? $" at {uri.Host}" : ""; + + static string Hours(TimeSpan lifetime) => + lifetime.TotalHours == 1 ? "1 hour" : $"{lifetime.TotalHours:0} hours"; + + static string When(DateTime utc) => + utc.ToString("d MMM yyyy, HH:mm 'UTC'", CultureInfo.InvariantCulture); +} diff --git a/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs b/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs index c940a7a7..0eb07ddd 100644 --- a/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs +++ b/SW.Bitween.Api/Services/Settings/SettingsCatalog.cs @@ -310,7 +310,7 @@ public static class SettingsCatalog // Email adapter reads these on each send, so a change applies to the next email. new("Bitween.MailServer.Host", "Mail server", "Server", - "The SMTP server Email notification channels send through, e.g. smtp.office365.com. While it's empty, Email channels can't send.", + "The SMTP server that Email notification channels, member invites and password resets send through, e.g. smtp.office365.com. While it's empty, none of them can be emailed.", SettingKind.String, false, t => t.Bitween.MailServer.Host, (t, v) => t.Bitween.MailServer.Host = Trimmed(v)), diff --git a/SW.Bitween.HttpTests/PasswordAndSecretTests.cs b/SW.Bitween.HttpTests/PasswordAndSecretTests.cs index 6a6edf70..ee7dc225 100644 --- a/SW.Bitween.HttpTests/PasswordAndSecretTests.cs +++ b/SW.Bitween.HttpTests/PasswordAndSecretTests.cs @@ -53,6 +53,47 @@ await fixture.InDbAsync(db => db.Set().Where(a => a.Email == email) Assert.Equal(upgraded, await StoredHashAsync(email)); } + [Fact] + public async Task The_password_link_endpoints_answer_without_a_session() + { + using var anonymous = fixture.Client(); + + // This app has no mail server, so a reset is refused - by the handler, not by sign-in. + var reset = await anonymous.PostAsJsonAsync("/api/accounts/forgotPassword", new { Email = Email() }); + Assert.Equal(System.Net.HttpStatusCode.BadRequest, reset.StatusCode); + Assert.Contains("PASSWORD_RESET_OFF", await reset.Content.ReadAsStringAsync()); + + foreach (var path in new[] { "/api/accounts/passwordLink", "/api/accounts/usePasswordLink" }) + { + var made = await anonymous.PostAsJsonAsync(path, new { Token = "not-a-real-token", Password = "Whatever-2026!" }); + Assert.Equal(System.Net.HttpStatusCode.BadRequest, made.StatusCode); + Assert.Contains("LINK_INVALID", await made.Content.ReadAsStringAsync()); + } + } + + [Fact] + public async Task A_member_added_with_an_invite_needs_no_password_and_chooses_one_from_the_link() + { + using var admin = await fixture.AdminAsync(); + var email = Email(); + var id = await Api.CreateAsync(admin, "/api/accounts", new { name = "Invited Member", email, invite = true, roleIds = new[] { 2 } }); + + // No mail server here, so the link comes back to pass on by hand. + var invite = await Api.Json(await admin.PostAsJsonAsync($"/api/accounts/{id}/invite", new { })); + Assert.False((bool)invite["emailed"]!); + var token = new Uri((string)invite["link"]!).Query.Split("token=")[1]; + + using var anonymous = fixture.Client(); + var link = await Api.Json(await anonymous.PostAsJsonAsync("/api/accounts/passwordLink", new { token })); + Assert.Equal("Invite", (string)link["purpose"]!); + + var weak = await anonymous.PostAsJsonAsync("/api/accounts/usePasswordLink", new { token, password = "weak" }); + Assert.Equal(System.Net.HttpStatusCode.BadRequest, weak.StatusCode); + + await Api.Json(await anonymous.PostAsJsonAsync("/api/accounts/usePasswordLink", new { token, password = "Chosen-By-Them-2026!" })); + Assert.False(string.IsNullOrEmpty(await HttpFixture.SignInAsync(anonymous, email, "Chosen-By-Them-2026!"))); + } + [Fact] public async Task Guessing_the_old_password_locks_out_as_sign_in_does() { diff --git a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs index 9da98829..22bb2819 100644 --- a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs +++ b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs @@ -92,6 +92,25 @@ public sealed class BitweenFixture : IAsyncLifetime /// Base address of MailHog's own API, for reading back what was delivered. public string MailHogApi => $"http://{_mailHog.Hostname}:{_mailHog.GetMappedPublicPort(ApiContainerPort)}"; + /// What MailHog received for with this subject. + public async Task> MailedAsync(string to, string subject) + { + using var http = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + using var messages = System.Text.Json.JsonDocument.Parse( + await http.GetStringAsync($"{MailHogApi}/api/v2/messages?limit=250")); + var found = new List(); + foreach (var item in messages.RootElement.GetProperty("items").EnumerateArray()) + { + var raw = item.GetProperty("Raw").GetProperty("Data").GetString()!; + var message = await MimeKit.MimeMessage.LoadAsync( + new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes(raw))); + if (message.Subject == subject && + System.Linq.Enumerable.Any(message.To.Mailboxes, m => string.Equals(m.Address, to, StringComparison.OrdinalIgnoreCase))) + found.Add(message); + } + return found; + } + /// /// Connection details for the external broker, as a data source's properties. /// @@ -228,6 +247,7 @@ await Task.WhenAll(_postgres.StartAsync(), _rabbitMq.StartAsync(), _mailHog.Star services.AddMemoryCache(); services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); services.AddDbContext(c => diff --git a/SW.Bitween.IntegrationTests/Tests/AccountEmailTests.cs b/SW.Bitween.IntegrationTests/Tests/AccountEmailTests.cs new file mode 100644 index 00000000..3933298d --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/AccountEmailTests.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.Bitween.Resources.Accounts; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// Invites and password resets, emailed through the fixture's mail server (MailHog) and used the +/// way the person who gets them would: open the link, choose a password, sign in. +/// +[Collection("Bitween")] +public class AccountEmailTests(BitweenFixture fixture) +{ + const string StrongPassword = "Chosen-by-them-1"; + + [Fact] + public async Task An_invite_emails_a_link_that_sets_the_password_once() + { + await using var scope = fixture.CreateScope(); + var email = Address("invitee"); + var member = await AddInvitedMember(scope, email); + + var result = (InviteResultModel)await Handler(scope).Handle(member, new InviteAccountModel()); + + Assert.True(result.Emailed); + Assert.Null(result.Link); + var token = TokenIn(await MailTo(email, "You're invited to Bitween")); + + var info = (PasswordLinkInfoModel)await Handler(scope).Handle(new PasswordLinkModel { Token = token }); + Assert.Equal("Invite", info.Purpose); + Assert.Equal(email, info.Email); + + await Handler(scope).Handle(new UsePasswordLinkModel { Token = token, Password = StrongPassword }); + + var account = await Read(scope, member); + Assert.True(SecurePasswordHasher.Verify(StrongPassword, account.Password)); + + var again = await Assert.ThrowsAsync(() => + Handler(scope).Handle(new UsePasswordLinkModel { Token = token, Password = "Another-one-2" })); + Assert.Contains(PasswordLinks.Invalid, again.Message); + } + + [Fact] + public async Task Without_a_mail_server_the_invite_link_is_handed_to_whoever_invited() + { + await using var scope = fixture.CreateScope(); + var member = await AddInvitedMember(scope, Address("no-mail")); + var mailServer = scope.ServiceProvider.GetRequiredService().MailServer; + var host = mailServer.Host; + mailServer.Host = null; + try + { + var result = (InviteResultModel)await Handler(scope).Handle(member, new InviteAccountModel()); + + Assert.False(result.Emailed); + Assert.StartsWith("https://bitween.test/set-password?token=", result.Link); + var info = (PasswordLinkInfoModel)await Handler(scope) + .Handle(new PasswordLinkModel { Token = TokenIn(result.Link) }); + Assert.Equal("Invite", info.Purpose); + } + finally + { + mailServer.Host = host; + } + } + + [Fact] + public async Task A_resent_invite_makes_the_earlier_link_useless() + { + await using var scope = fixture.CreateScope(); + var email = Address("resent"); + var member = await AddInvitedMember(scope, email); + + await Handler(scope).Handle(member, new InviteAccountModel()); + var first = TokenIn(await MailTo(email, "You're invited to Bitween")); + await Handler(scope).Handle(member, new InviteAccountModel()); + + await Assert.ThrowsAsync(() => + Handler(scope).Handle(new PasswordLinkModel { Token = first })); + } + + [Fact] + public async Task A_member_who_outranks_the_caller_cannot_be_invited_by_them() + { + int member; + await using (var adminScope = fixture.CreateScope()) + member = await AddInvitedMember(adminScope, Address("outranks"), Role.AdministratorId); + + // A scope of its own: a request works out its caller's permissions once. + await using var scope = fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var helpdesk = new Role($"helpdesk-{Guid.NewGuid():N}"[..24], "Adds members", + [Permissions.Users.View, Permissions.Users.Create]); + db.Add(helpdesk); + await db.SaveChangesAsync(); + var caller = await Account(scope, Address("helpdesk"), "irrelevant-hash", helpdesk.Id); + scope.As(caller); + + await Assert.ThrowsAsync(() => + Handler(scope).Handle(member, new InviteAccountModel())); + } + + [Fact] + public async Task A_reset_signs_the_member_out_everywhere_and_tells_them() + { + await using var scope = fixture.CreateScope(); + var email = Address("forgetful"); + var member = await Account(scope, email, SecurePasswordHasher.Hash("Forgotten-pass-1")); + var db = scope.ServiceProvider.GetRequiredService(); + db.Add(new RefreshToken(member, LoginMethod.EmailAndPassword)); + await db.SaveChangesAsync(); + + // Typed as people type it: the address is matched whatever its case and spaces. + await Handler(scope).Handle(new ForgotPasswordModel { Email = $" {email.ToUpperInvariant()} " }); + var token = TokenIn(await MailTo(email, "Reset your Bitween password")); + + await Handler(scope).Handle(new UsePasswordLinkModel { Token = token, Password = StrongPassword }); + + Assert.True(SecurePasswordHasher.Verify(StrongPassword, (await Read(scope, member)).Password)); + Assert.False(await db.Set().AnyAsync(t => t.AccountId == member)); + await MailTo(email, "Your Bitween password was changed"); + } + + [Fact] + public async Task A_reset_asked_twice_in_a_minute_is_emailed_once() + { + await using var scope = fixture.CreateScope(); + var email = Address("impatient"); + await Account(scope, email, SecurePasswordHasher.Hash("Forgotten-pass-1")); + + await Handler(scope).Handle(new ForgotPasswordModel { Email = email }); + await MailTo(email, "Reset your Bitween password"); + await Handler(scope).Handle(new ForgotPasswordModel { Email = email }); + await Task.Delay(1500); + + Assert.Single(await fixture.MailedAsync(email, "Reset your Bitween password")); + } + + [Fact] + public async Task A_reset_for_an_address_without_an_account_answers_the_same_and_sends_nothing() + { + await using var scope = fixture.CreateScope(); + var email = Address("nobody"); + + var answer = await Handler(scope).Handle(new ForgotPasswordModel { Email = email }); + await Task.Delay(1500); + + Assert.Null(answer); + Assert.Empty(await fixture.MailedAsync(email, "Reset your Bitween password")); + } + + [Fact] + public async Task A_link_used_twice_at_once_sets_one_password() + { + int member; + string token; + await using (var scope = fixture.CreateScope()) + { + member = await Account(scope, Address("twice"), SecurePasswordHasher.Hash("Forgotten-pass-1")); + (var link, token) = AccountLink.Issue(member, AccountLinkPurpose.PasswordReset, DateTime.UtcNow); + var db = scope.ServiceProvider.GetRequiredService(); + db.Add(link); + await db.SaveChangesAsync(); + } + + // Each on a request of its own, as two clicks of the same button would be. + async Task Use(string password) + { + await using var scope = fixture.CreateScope(); + try + { + await Handler(scope).Handle(new UsePasswordLinkModel { Token = token, Password = password }); + return true; + } + catch (SWValidationException) + { + return false; + } + } + + var results = await Task.WhenAll(Use("First-choice-1"), Use("Second-choice-2")); + + Assert.Single(results, worked => worked); + await using var check = fixture.CreateScope(); + var stored = (await Read(check, member)).Password; + Assert.True(SecurePasswordHasher.Verify(results[0] ? "First-choice-1" : "Second-choice-2", stored)); + } + + [Fact] + public async Task An_expired_link_is_refused() + { + await using var scope = fixture.CreateScope(); + var member = await Account(scope, Address("late"), SecurePasswordHasher.Hash("Forgotten-pass-1")); + var (link, token) = AccountLink.Issue(member, AccountLinkPurpose.PasswordReset, DateTime.UtcNow.AddHours(-2)); + var db = scope.ServiceProvider.GetRequiredService(); + db.Add(link); + await db.SaveChangesAsync(); + + await Assert.ThrowsAsync(() => + Handler(scope).Handle(new UsePasswordLinkModel { Token = token, Password = StrongPassword })); + } + + [Fact] + public async Task A_member_with_a_password_is_not_sent_an_invite() + { + await using var scope = fixture.CreateScope(); + var member = await Account(scope, Address("has-one"), SecurePasswordHasher.Hash("Already-set-1")); + scope.Superuser(); + + var refused = await Assert.ThrowsAsync(() => + Handler(scope).Handle(member, new InviteAccountModel())); + Assert.Contains("already has a password", refused.Message); + } + + /// An administrator adds a member to be invited, and stays signed in as that administrator. + async Task AddInvitedMember(AsyncServiceScope scope, string email, params int[] roleIds) + { + var admin = await Account(scope, Address("admin"), "irrelevant-hash", Role.AdministratorId); + scope.As(admin); + return (int)await Handler(scope).Handle(new CreateAccountModel + { + Name = "Invited Person", + Email = email, + Invite = true, + RoleIds = roleIds.ToList() + }); + } + + static async Task Account(AsyncServiceScope scope, string email, string passwordHash, params int[] roleIds) + { + var db = scope.ServiceProvider.GetRequiredService(); + var account = new Account("Test Person", email, passwordHash, AccountRole.Member); + db.Add(account); + await db.SaveChangesAsync(); + foreach (var roleId in roleIds) db.Add(new AccountRoleLink(account.Id, roleId)); + await db.SaveChangesAsync(); + return account.Id; + } + + static async Task Read(AsyncServiceScope scope, int id) => + await scope.ServiceProvider.GetRequiredService().Set() + .AsNoTracking().SingleAsync(a => a.Id == id); + + static T Handler(AsyncServiceScope scope) => ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + /// The one email of this subject to the address, waited for: some are sent in the background. + async Task MailTo(string email, string subject) + { + for (var i = 0; i < 50; i++) + { + var found = await fixture.MailedAsync(email, subject); + if (found.Count > 0) return Assert.Single(found); + await Task.Delay(200); + } + throw new Xunit.Sdk.XunitException($"No \"{subject}\" email reached {email}."); + } + + static string TokenIn(MimeKit.MimeMessage message) => TokenIn(message.TextBody); + + static string TokenIn(string text) => Regex.Match(text, "token=([A-Za-z0-9_-]+)").Groups[1].Value; + + static string Address(string label) => $"{label}-{Guid.NewGuid():N}"[..(label.Length + 13)] + "@test.local"; +} diff --git a/SW.Bitween.MsSql/Migrations/20261015131710_AccountLinks.Designer.cs b/SW.Bitween.MsSql/Migrations/20261015131710_AccountLinks.Designer.cs new file mode 100644 index 00000000..07dffb52 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20261015131710_AccountLinks.Designer.cs @@ -0,0 +1,3495 @@ +// +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("20261015131710_AccountLinks")] + partial class AccountLinks + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.20") + .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("Kind") + .HasColumnType("tinyint"); + + b.Property("LastSignInOn") + .HasColumnType("datetime2"); + + b.Property("LockoutEnd") + .HasColumnType("datetime2"); + + b.Property("LoginMethods") + .HasColumnType("tinyint"); + + b.Property("MicrosoftIdentity") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("MustChangePassword") + .HasColumnType("bit"); + + b.Property("OidcIdentity") + .HasColumnType("nvarchar(max)"); + + 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(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + Kind = (byte)0, + LoginMethods = (byte)2, + MustChangePassword = true, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EmailedOn") + .HasColumnType("datetime2"); + + b.Property("ExpiresOn") + .HasColumnType("datetime2"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Purpose") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("AccountLinks", (string)null); + }); + + 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.Property("SupersededOn") + .HasColumnType("datetime2"); + + 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(2022, 1, 1, 0, 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(2022, 1, 1, 0, 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(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("ExpiresOn") + .HasColumnType("datetime2"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("LastUsedFrom") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("LastUsedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RevokedBy") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RevokedOn") + .HasColumnType("datetime2"); + + b.Property("Shown") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("ServiceTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("BaseVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("FilesHash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Kind") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("ModifiedBy") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AdapterId"); + + b.ToTable("AdapterDrafts", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterRelease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("DraftId") + .HasColumnType("int"); + + b.Property("OccurredOn") + .HasColumnType("datetime2"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AdapterId", "OccurredOn"); + + b.ToTable("AdapterReleases", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterSourceAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OccurredOn") + .HasColumnType("datetime2"); + + b.Property("Path") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AdapterId", "OccurredOn"); + + b.ToTable("AdapterSourceAccesses", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ApiKeyUse", b => + { + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("KeyName") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("LastUsedOn") + .HasColumnType("datetime2"); + + b.HasKey("PartnerId", "KeyName"); + + b.ToTable("ApiKeyUses", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AuditEntry", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Changes") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("EntityKey") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OccurredOn") + .HasColumnType("datetime2"); + + b.Property("Sequence") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(10) + .IsUnicode(false) + .HasColumnType("varchar(10)"); + + b.Property("UserId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("OccurredOn"); + + b.HasIndex("EntityName", "EntityKey", "OccurredOn"); + + b.ToTable("AuditEntries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AcquiredOn") + .HasColumnType("datetime2"); + + b.Property("OwnerNode") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Term") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("ClusterLeases", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterNode", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("DataSources") + .HasColumnType("bit"); + + b.Property("Host") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("LastSeenOn") + .HasColumnType("datetime2"); + + b.Property("OutboxPublishedLastHour") + .HasColumnType("int"); + + b.Property("ResidentHealth") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Runtimes") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("StartedOn") + .HasColumnType("datetime2"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenOn"); + + b.ToTable("ClusterNodes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Connections.Connection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AccessTokenExpiresOn") + .HasColumnType("datetime2"); + + b.Property("AuthorizeParameters") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("AuthorizeUrl") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("AuthorizedBy") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AuthorizedOn") + .HasColumnType("datetime2"); + + b.Property("ClientId") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Grant") + .HasColumnType("int"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Password") + .HasColumnType("nvarchar(max)"); + + b.Property("RefreshToken") + .HasColumnType("nvarchar(max)"); + + b.Property("RenewingUntil") + .HasColumnType("datetime2"); + + b.Property("Scopes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TokenAuth") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("TokenUrl") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Username") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Connections", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.AdapterState", b => + { + b.Property("AdapterId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InstanceKey") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedOn") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.HasKey("AdapterId", "InstanceKey", "Name"); + + b.ToTable("AdapterStates", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CpuLimitSamples") + .HasColumnType("int"); + + b.Property("CpuPercentLimit") + .HasColumnType("float"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeduplicationWindowDays") + .HasColumnType("int"); + + b.Property("HardMemoryLimitMb") + .HasColumnType("int"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("LastException") + .HasColumnType("nvarchar(max)"); + + b.Property("LastHeartbeatOn") + .HasColumnType("datetime2"); + + b.Property("LastKnownState") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnedByNode") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Placement") + .HasColumnType("int"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("SecretProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("SoftMemoryLimitMb") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DataSources", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", 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("CursorColumn") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("KeyColumn") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Sql") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkGroupId"); + + b.HasIndex("DataSourceId", "Name") + .IsUnique(); + + b.ToTable("DataSourceStatements", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.Property("Id") + .HasMaxLength(400) + .IsUnicode(false) + .HasColumnType("varchar(400)"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("SeenOn") + .HasColumnType("datetime2"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DataSourceId"); + + b.HasIndex("SeenOn"); + + b.ToTable("InboundMessages", (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.Property("RunFailures") + .HasColumnType("int"); + + 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("DropDuplicates") + .HasColumnType("bit"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("RetiredOn") + .HasColumnType("datetime2"); + + b.Property("ValidationSchema") + .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, + DropDuplicates = false, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DuplicateArrival", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("Dropped") + .HasColumnType("bit"); + + b.Property("OccurredOn") + .HasColumnType("datetime2"); + + b.Property("OriginalXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "OccurredOn"); + + b.ToTable("DuplicateArrivals", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthMethod") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("FailureAnswer") + .HasColumnType("int"); + + b.Property("Inactive") + .HasColumnType("bit"); + + b.Property("JwtAudience") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("JwtIssuer") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("JwtPartnerClaim") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PartnerKeyHeader") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + 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("DataSourceId") + .HasColumnType("int"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("Endpoint") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("EndpointProperties") + .HasColumnType("nvarchar(max)"); + + 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("DataSourceId"); + + 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("SecretProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("Values") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.IdempotencyKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("XchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("Scope", "Key") + .IsUnique(); + + b.ToTable("IdempotencyKeys", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.AdapterStats", b => + { + b.Property("Hour") + .HasColumnType("datetime2"); + + b.Property("Node") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("AdapterId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Role") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Runtime") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Calls") + .HasColumnType("int"); + + b.Property("Crashes") + .HasColumnType("int"); + + b.Property("D0") + .HasColumnType("int"); + + b.Property("D1") + .HasColumnType("int"); + + b.Property("D2") + .HasColumnType("int"); + + b.Property("D3") + .HasColumnType("int"); + + b.Property("D4") + .HasColumnType("int"); + + b.Property("D5") + .HasColumnType("int"); + + b.Property("D6") + .HasColumnType("int"); + + b.Property("D7") + .HasColumnType("int"); + + b.Property("D8") + .HasColumnType("int"); + + b.Property("D9") + .HasColumnType("int"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint"); + + b.Property("DurationSumMs") + .HasColumnType("bigint"); + + b.Property("Failures") + .HasColumnType("int"); + + b.Property("LimitStops") + .HasColumnType("int"); + + b.Property("Timeouts") + .HasColumnType("int"); + + b.HasKey("Hour", "Node", "AdapterId", "Version", "Role", "Runtime"); + + b.ToTable("AdapterStats", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ErrorStats", b => + { + b.Property("Hour") + .HasColumnType("datetime2"); + + b.Property("Node") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Scope") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("ScopeKey") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Fingerprint") + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("Count") + .HasColumnType("int"); + + b.Property("LastSeenOn") + .HasColumnType("datetime2"); + + b.Property("Sample") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Hour", "Node", "Scope", "ScopeKey", "Fingerprint"); + + b.ToTable("ErrorStats", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ExchangeStats", b => + { + b.Property("Hour") + .HasColumnType("datetime2"); + + b.Property("Node") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("BadResponse") + .HasColumnType("int"); + + b.Property("BytesIn") + .HasColumnType("bigint"); + + b.Property("BytesOut") + .HasColumnType("bigint"); + + b.Property("D0") + .HasColumnType("int"); + + b.Property("D1") + .HasColumnType("int"); + + b.Property("D2") + .HasColumnType("int"); + + b.Property("D3") + .HasColumnType("int"); + + b.Property("D4") + .HasColumnType("int"); + + b.Property("D5") + .HasColumnType("int"); + + b.Property("D6") + .HasColumnType("int"); + + b.Property("D7") + .HasColumnType("int"); + + b.Property("D8") + .HasColumnType("int"); + + b.Property("D9") + .HasColumnType("int"); + + b.Property("DbCommands") + .HasColumnType("bigint"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint"); + + b.Property("DurationSumMs") + .HasColumnType("bigint"); + + b.Property("Failed") + .HasColumnType("int"); + + b.Property("FailedAtHandler") + .HasColumnType("int"); + + b.Property("FailedAtMapper") + .HasColumnType("int"); + + b.Property("ManualRetries") + .HasColumnType("int"); + + b.Property("PayloadParses") + .HasColumnType("bigint"); + + b.Property("Retries") + .HasColumnType("int"); + + b.Property("Succeeded") + .HasColumnType("int"); + + b.Property("Total") + .HasColumnType("int"); + + b.HasKey("Hour", "Node", "SubscriptionId", "PartnerId"); + + b.ToTable("ExchangeStats", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.QueueSnapshot", b => + { + b.Property("At") + .HasColumnType("datetime2"); + + b.Property("AckRate") + .HasColumnType("float"); + + b.Property("Consumers") + .HasColumnType("int"); + + b.Property("DeadLetterBacklog") + .HasColumnType("bigint"); + + b.Property("IncomingRate") + .HasColumnType("float"); + + b.Property("QueueDepth") + .HasColumnType("bigint"); + + b.Property("RetryBacklog") + .HasColumnType("bigint"); + + b.Property("UnhealthyConsumers") + .HasColumnType("int"); + + b.HasKey("At"); + + b.ToTable("QueueSnapshots", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.NotificationChannel", 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("HandlerId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationChannels", (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("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("Data") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .HasColumnType("nvarchar(max)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("SourceValues") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OperatorAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Detail") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("OccurredOn") + .HasColumnType("datetime2"); + + b.Property("Target") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("OccurredOn"); + + b.ToTable("OperatorActions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attempts") + .HasColumnType("int"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimedUntil") + .HasColumnType("datetime2"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Headers") + .HasColumnType("nvarchar(max)"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PublishedOn") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn") + .HasFilter("[PublishedOn] IS NULL"); + + b.ToTable("OutboxMessages", (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("LoginIdentity") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("SecretProperties") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("LoginIdentity") + .IsUnique() + .HasFilter("[LoginIdentity] IS NOT NULL"); + + 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("AlertChannelId") + .HasColumnType("int"); + + 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("AlertChannelId") + .HasColumnType("int"); + + 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("AutoPauseAfterFailures") + .HasColumnType("int"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("nvarchar(max)"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("DocumentFilter") + .HasColumnType("nvarchar(max)"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ExpectTraffic") + .HasColumnType("nvarchar(max)"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("nvarchar(max)"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + 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("MapperVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("MatchExpression") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notifications") + .HasColumnType("nvarchar(max)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedAutomatically") + .HasColumnType("bit"); + + b.Property("PausedOn") + .HasColumnType("datetime2"); + + b.Property("QuietSince") + .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("ReceiverVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("RunOnBadResponses") + .HasColumnType("bit"); + + b.Property("RunningSince") + .HasColumnType("datetime2"); + + 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("ValidatorVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DataSourceId"); + + 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.SubscriptionRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("SavedBy") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SavedOn") + .HasColumnType("datetime2"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "Number") + .IsUnique(); + + b.ToTable("SubscriptionRevisions", (string)null); + }); + + 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("FilesPrefix") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + 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("SourceValues") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceXchangeId") + .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.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("int"); + + 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("Suppressed") + .HasColumnType("bit"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("XchangeId"); + + b.HasIndex("ChannelId", "FinishedOn"); + + b.HasIndex("NotifierId", "FinishedOn"); + + 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(max)"); + + b.HasKey("Id"); + + 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("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedOn") + .HasColumnType("datetime2"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("NodeName") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + 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("SubscriptionId") + .HasColumnType("int"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Warning") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("RetryGroupId"); + + b.HasIndex("SubscriptionId", "FinishedOn"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ClaimedOn") + .HasColumnType("datetime2"); + + b.Property("RetryXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeRetryClaims", (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.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.Accounts.ServiceToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.WorkGroup", null) + .WithMany() + .HasForeignKey("WorkGroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("DataSource"); + }); + + 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("KeyPrefix") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + 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.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict); + + 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", "ResponseSubscription") + .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("ResponseSubscription"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.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.Bitween.Domain.XchangeRetryClaim", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeRetryClaim", "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/20261015131710_AccountLinks.cs b/SW.Bitween.MsSql/Migrations/20261015131710_AccountLinks.cs new file mode 100644 index 00000000..f098d7c1 --- /dev/null +++ b/SW.Bitween.MsSql/Migrations/20261015131710_AccountLinks.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MsSql.Migrations +{ + /// + public partial class AccountLinks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AccountLinks", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + AccountId = table.Column(type: "int", nullable: false), + Purpose = table.Column(type: "tinyint", nullable: false), + Hash = table.Column(type: "varchar(64)", unicode: false, maxLength: 64, nullable: false), + ExpiresOn = table.Column(type: "datetime2", nullable: false), + EmailedOn = table.Column(type: "datetime2", nullable: true), + EmailError = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + CreatedOn = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountLinks", x => x.Id); + table.ForeignKey( + name: "FK_AccountLinks_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AccountLinks_AccountId", + table: "AccountLinks", + column: "AccountId"); + + migrationBuilder.CreateIndex( + name: "IX_AccountLinks_Hash", + table: "AccountLinks", + column: "Hash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AccountLinks"); + } + } +} diff --git a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs index 55e44157..f6c147b8 100644 --- a/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MsSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -122,6 +122,49 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EmailedOn") + .HasColumnType("datetime2"); + + b.Property("ExpiresOn") + .HasColumnType("datetime2"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Purpose") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("AccountLinks", (string)null); + }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => { b.Property("AccountId") @@ -3035,6 +3078,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("QRTZ_triggers", "dbo"); }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => { b.HasOne("SW.Bitween.Domain.Accounts.Account", null) diff --git a/SW.Bitween.MySql/Migrations/20261015131708_AccountLinks.Designer.cs b/SW.Bitween.MySql/Migrations/20261015131708_AccountLinks.Designer.cs new file mode 100644 index 00000000..819671f2 --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20261015131708_AccountLinks.Designer.cs @@ -0,0 +1,3486 @@ +// +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("20261015131708_AccountLinks")] + partial class AccountLinks + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.20") + .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("Kind") + .HasColumnType("tinyint unsigned"); + + b.Property("LastSignInOn") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime(6)"); + + b.Property("LoginMethods") + .HasColumnType("tinyint unsigned"); + + b.Property("MicrosoftIdentity") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("MustChangePassword") + .HasColumnType("tinyint(1)"); + + b.Property("OidcIdentity") + .HasColumnType("longtext"); + + 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(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + Kind = (byte)0, + LoginMethods = (byte)2, + MustChangePassword = true, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("EmailedOn") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresOn") + .HasColumnType("datetime(6)"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Purpose") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("AccountLinks", (string)null); + }); + + 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.Property("SupersededOn") + .HasColumnType("datetime(6)"); + + 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(2022, 1, 1, 0, 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(2022, 1, 1, 0, 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(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresOn") + .HasColumnType("datetime(6)"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("LastUsedFrom") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("LastUsedOn") + .HasColumnType("datetime(6)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RevokedBy") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevokedOn") + .HasColumnType("datetime(6)"); + + b.Property("Shown") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("ServiceTokens", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("BaseVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("FilesHash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Kind") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("ModifiedBy") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AdapterId"); + + b.ToTable("AdapterDrafts", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterRelease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("DraftId") + .HasColumnType("int"); + + b.Property("OccurredOn") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AdapterId", "OccurredOn"); + + b.ToTable("AdapterReleases", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterSourceAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("OccurredOn") + .HasColumnType("datetime(6)"); + + b.Property("Path") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AdapterId", "OccurredOn"); + + b.ToTable("AdapterSourceAccesses", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ApiKeyUse", b => + { + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("KeyName") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("LastUsedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("PartnerId", "KeyName"); + + b.ToTable("ApiKeyUses", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AuditEntry", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Changes") + .HasColumnType("longtext"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("EntityKey") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OccurredOn") + .HasColumnType("datetime(6)"); + + b.Property("Sequence") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(10) + .IsUnicode(false) + .HasColumnType("varchar(10)"); + + b.Property("UserId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("OccurredOn"); + + b.HasIndex("EntityName", "EntityKey", "OccurredOn"); + + b.ToTable("AuditEntries", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("AcquiredOn") + .HasColumnType("datetime(6)"); + + b.Property("OwnerNode") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Term") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("ClusterLeases", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterNode", b => + { + b.Property("Id") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("DataSources") + .HasColumnType("tinyint(1)"); + + b.Property("Host") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("LastSeenOn") + .HasColumnType("datetime(6)"); + + b.Property("OutboxPublishedLastHour") + .HasColumnType("int"); + + b.Property("ResidentHealth") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("Runtimes") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("StartedOn") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenOn"); + + b.ToTable("ClusterNodes", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Connections.Connection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasColumnType("longtext"); + + b.Property("AccessTokenExpiresOn") + .HasColumnType("datetime(6)"); + + b.Property("AuthorizeParameters") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("AuthorizeUrl") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("AuthorizedBy") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("AuthorizedOn") + .HasColumnType("datetime(6)"); + + b.Property("ClientId") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ClientSecret") + .HasColumnType("longtext"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Grant") + .HasColumnType("int"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Password") + .HasColumnType("longtext"); + + b.Property("RefreshToken") + .HasColumnType("longtext"); + + b.Property("RenewingUntil") + .HasColumnType("datetime(6)"); + + b.Property("Scopes") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TokenAuth") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("TokenUrl") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Username") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Connections", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.AdapterState", b => + { + b.Property("AdapterId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("InstanceKey") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasMaxLength(8000) + .HasColumnType("varchar(8000)"); + + b.HasKey("AdapterId", "InstanceKey", "Name"); + + b.ToTable("AdapterStates", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CpuLimitSamples") + .HasColumnType("int"); + + b.Property("CpuPercentLimit") + .HasColumnType("double"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("DeduplicationWindowDays") + .HasColumnType("int"); + + b.Property("HardMemoryLimitMb") + .HasColumnType("int"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("LastException") + .HasColumnType("longtext"); + + b.Property("LastHeartbeatOn") + .HasColumnType("datetime(6)"); + + b.Property("LastKnownState") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OwnedByNode") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Placement") + .HasColumnType("int"); + + b.Property("Properties") + .HasColumnType("longtext"); + + b.Property("SecretProperties") + .HasColumnType("longtext"); + + b.Property("SoftMemoryLimitMb") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DataSources", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", 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("CursorColumn") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("KeyColumn") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Sql") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkGroupId"); + + b.HasIndex("DataSourceId", "Name") + .IsUnique(); + + b.ToTable("DataSourceStatements", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.Property("Id") + .HasMaxLength(400) + .IsUnicode(false) + .HasColumnType("varchar(400)"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("SeenOn") + .HasColumnType("datetime(6)"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DataSourceId"); + + b.HasIndex("SeenOn"); + + b.ToTable("InboundMessages", (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.Property("RunFailures") + .HasColumnType("int"); + + 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("DropDuplicates") + .HasColumnType("tinyint(1)"); + + b.Property("DuplicateInterval") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("PromotedProperties") + .HasColumnType("longtext"); + + b.Property("RetiredOn") + .HasColumnType("datetime(6)"); + + b.Property("ValidationSchema") + .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, + DropDuplicates = false, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DuplicateArrival", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("Dropped") + .HasColumnType("tinyint(1)"); + + b.Property("OccurredOn") + .HasColumnType("datetime(6)"); + + b.Property("OriginalXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "OccurredOn"); + + b.ToTable("DuplicateArrivals", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AuthMethod") + .HasColumnType("int"); + + b.Property("CreatedBy") + .HasColumnType("longtext"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("FailureAnswer") + .HasColumnType("int"); + + b.Property("Inactive") + .HasColumnType("tinyint(1)"); + + b.Property("JwtAudience") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("JwtIssuer") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("JwtPartnerClaim") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PartnerKeyHeader") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + 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("DataSourceId") + .HasColumnType("int"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("Endpoint") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("EndpointProperties") + .HasColumnType("longtext"); + + 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("DataSourceId"); + + 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("SecretProperties") + .HasColumnType("longtext"); + + b.Property("Values") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("GlobalAdapterValuesSets", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.IdempotencyKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("XchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("Scope", "Key") + .IsUnique(); + + b.ToTable("IdempotencyKeys", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.AdapterStats", b => + { + b.Property("Hour") + .HasColumnType("datetime(6)"); + + b.Property("Node") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("AdapterId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Role") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Runtime") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("Calls") + .HasColumnType("int"); + + b.Property("Crashes") + .HasColumnType("int"); + + b.Property("D0") + .HasColumnType("int"); + + b.Property("D1") + .HasColumnType("int"); + + b.Property("D2") + .HasColumnType("int"); + + b.Property("D3") + .HasColumnType("int"); + + b.Property("D4") + .HasColumnType("int"); + + b.Property("D5") + .HasColumnType("int"); + + b.Property("D6") + .HasColumnType("int"); + + b.Property("D7") + .HasColumnType("int"); + + b.Property("D8") + .HasColumnType("int"); + + b.Property("D9") + .HasColumnType("int"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint"); + + b.Property("DurationSumMs") + .HasColumnType("bigint"); + + b.Property("Failures") + .HasColumnType("int"); + + b.Property("LimitStops") + .HasColumnType("int"); + + b.Property("Timeouts") + .HasColumnType("int"); + + b.HasKey("Hour", "Node", "AdapterId", "Version", "Role", "Runtime"); + + b.ToTable("AdapterStats", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ErrorStats", b => + { + b.Property("Hour") + .HasColumnType("datetime(6)"); + + b.Property("Node") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("Scope") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("ScopeKey") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("Fingerprint") + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("Count") + .HasColumnType("int"); + + b.Property("LastSeenOn") + .HasColumnType("datetime(6)"); + + b.Property("Sample") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Hour", "Node", "Scope", "ScopeKey", "Fingerprint"); + + b.ToTable("ErrorStats", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ExchangeStats", b => + { + b.Property("Hour") + .HasColumnType("datetime(6)"); + + b.Property("Node") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("BadResponse") + .HasColumnType("int"); + + b.Property("BytesIn") + .HasColumnType("bigint"); + + b.Property("BytesOut") + .HasColumnType("bigint"); + + b.Property("D0") + .HasColumnType("int"); + + b.Property("D1") + .HasColumnType("int"); + + b.Property("D2") + .HasColumnType("int"); + + b.Property("D3") + .HasColumnType("int"); + + b.Property("D4") + .HasColumnType("int"); + + b.Property("D5") + .HasColumnType("int"); + + b.Property("D6") + .HasColumnType("int"); + + b.Property("D7") + .HasColumnType("int"); + + b.Property("D8") + .HasColumnType("int"); + + b.Property("D9") + .HasColumnType("int"); + + b.Property("DbCommands") + .HasColumnType("bigint"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint"); + + b.Property("DurationSumMs") + .HasColumnType("bigint"); + + b.Property("Failed") + .HasColumnType("int"); + + b.Property("FailedAtHandler") + .HasColumnType("int"); + + b.Property("FailedAtMapper") + .HasColumnType("int"); + + b.Property("ManualRetries") + .HasColumnType("int"); + + b.Property("PayloadParses") + .HasColumnType("bigint"); + + b.Property("Retries") + .HasColumnType("int"); + + b.Property("Succeeded") + .HasColumnType("int"); + + b.Property("Total") + .HasColumnType("int"); + + b.HasKey("Hour", "Node", "SubscriptionId", "PartnerId"); + + b.ToTable("ExchangeStats", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.QueueSnapshot", b => + { + b.Property("At") + .HasColumnType("datetime(6)"); + + b.Property("AckRate") + .HasColumnType("double"); + + b.Property("Consumers") + .HasColumnType("int"); + + b.Property("DeadLetterBacklog") + .HasColumnType("bigint"); + + b.Property("IncomingRate") + .HasColumnType("double"); + + b.Property("QueueDepth") + .HasColumnType("bigint"); + + b.Property("RetryBacklog") + .HasColumnType("bigint"); + + b.Property("UnhealthyConsumers") + .HasColumnType("int"); + + b.HasKey("At"); + + b.ToTable("QueueSnapshots", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.NotificationChannel", 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("HandlerId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("ModifiedBy") + .HasColumnType("longtext"); + + b.Property("ModifiedOn") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationChannels", (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("CorrelationId") + .HasColumnType("longtext"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("FileName") + .HasColumnType("longtext"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("References") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("SourceValues") + .HasColumnType("longtext"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("OnHoldXchanges", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OperatorAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Detail") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("OccurredOn") + .HasColumnType("datetime(6)"); + + b.Property("Target") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("OccurredOn"); + + b.ToTable("OperatorActions", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Attempts") + .HasColumnType("int"); + + b.Property("Body") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ClaimedUntil") + .HasColumnType("datetime(6)"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Headers") + .HasColumnType("longtext"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedOn") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("PublishedOn", "CreatedOn"); + + b.ToTable("OutboxMessages", (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("LoginIdentity") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("SecretProperties") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("LoginIdentity") + .IsUnique(); + + 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("AlertChannelId") + .HasColumnType("int"); + + 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("AlertChannelId") + .HasColumnType("int"); + + 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("AutoPauseAfterFailures") + .HasColumnType("int"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CustomRetryPolicy") + .HasColumnType("longtext"); + + b.Property("DataSourceId") + .HasColumnType("int"); + + b.Property("DocumentFilter") + .HasColumnType("longtext"); + + b.Property("DocumentId") + .HasColumnType("int"); + + b.Property("ExpectTraffic") + .HasColumnType("longtext"); + + b.Property("HandlerId") + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("varchar(200)"); + + b.Property("HandlerProperties") + .HasColumnType("longtext"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + 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("MapperVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("MatchExpression") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Notifications") + .HasColumnType("longtext"); + + b.Property("PartnerId") + .HasColumnType("int"); + + b.Property("PausedAutomatically") + .HasColumnType("tinyint(1)"); + + b.Property("PausedOn") + .HasColumnType("datetime(6)"); + + b.Property("QuietSince") + .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("ReceiverVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ResponseMessageTypeName") + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnType("varchar(500)"); + + b.Property("ResponseSubscriptionId") + .HasColumnType("int"); + + b.Property("RetryPolicyId") + .HasColumnType("int"); + + b.Property("RunOnBadResponses") + .HasColumnType("tinyint(1)"); + + b.Property("RunningSince") + .HasColumnType("datetime(6)"); + + 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("ValidatorVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("WorkGroupId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AggregationForId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DataSourceId"); + + 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.SubscriptionRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("SavedBy") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SavedOn") + .HasColumnType("datetime(6)"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SubscriptionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "Number") + .IsUnique(); + + b.ToTable("SubscriptionRevisions", (string)null); + }); + + 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("FilesPrefix") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + 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("SourceValues") + .HasColumnType("longtext"); + + b.Property("SourceXchangeId") + .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.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("int"); + + 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("Suppressed") + .HasColumnType("tinyint(1)"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("XchangeId"); + + b.HasIndex("ChannelId", "FinishedOn"); + + b.HasIndex("NotifierId", "FinishedOn"); + + 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("longtext"); + + b.HasKey("Id"); + + 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("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("Exception") + .HasColumnType("longtext"); + + b.Property("FinishedOn") + .HasColumnType("datetime(6)"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("NodeName") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + 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("SubscriptionId") + .HasColumnType("int"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.Property("Warning") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("RetryGroupId"); + + b.HasIndex("SubscriptionId", "FinishedOn"); + + b.ToTable("XchangeResults", (string)null); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.Property("Id") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("ClaimedOn") + .HasColumnType("datetime(6)"); + + b.Property("RetryXchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.ToTable("XchangeRetryClaims", (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.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.Accounts.ServiceToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SW.Bitween.Domain.WorkGroup", null) + .WithMany() + .HasForeignKey("WorkGroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("DataSource"); + }); + + 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("KeyPrefix") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + 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.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict); + + 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", "ResponseSubscription") + .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("ResponseSubscription"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.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.Bitween.Domain.XchangeRetryClaim", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeRetryClaim", "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/20261015131708_AccountLinks.cs b/SW.Bitween.MySql/Migrations/20261015131708_AccountLinks.cs new file mode 100644 index 00000000..2335fd8e --- /dev/null +++ b/SW.Bitween.MySql/Migrations/20261015131708_AccountLinks.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SW.Bitween.MySql.Migrations +{ + /// + public partial class AccountLinks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AccountLinks", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + AccountId = table.Column(type: "int", nullable: false), + Purpose = table.Column(type: "tinyint unsigned", nullable: false), + Hash = table.Column(type: "varchar(64)", unicode: false, maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ExpiresOn = table.Column(type: "datetime(6)", nullable: false), + EmailedOn = table.Column(type: "datetime(6)", nullable: true), + EmailError = table.Column(type: "varchar(500)", maxLength: 500, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedOn = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountLinks", x => x.Id); + table.ForeignKey( + name: "FK_AccountLinks_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_AccountLinks_AccountId", + table: "AccountLinks", + column: "AccountId"); + + migrationBuilder.CreateIndex( + name: "IX_AccountLinks_Hash", + table: "AccountLinks", + column: "Hash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AccountLinks"); + } + } +} diff --git a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs index e5f46063..0c84395d 100644 --- a/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.MySql/Migrations/BitweenDbContextModelSnapshot.cs @@ -119,6 +119,49 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("EmailedOn") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresOn") + .HasColumnType("datetime(6)"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Purpose") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("AccountLinks", (string)null); + }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => { b.Property("AccountId") @@ -3026,6 +3069,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("QRTZ_triggers", (string)null); }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => { b.HasOne("SW.Bitween.Domain.Accounts.Account", null) diff --git a/SW.Bitween.NativeAdapters/ChannelMessages/EmailBody.cs b/SW.Bitween.NativeAdapters/ChannelMessages/EmailBody.cs index 1042ada4..20503fad 100644 --- a/SW.Bitween.NativeAdapters/ChannelMessages/EmailBody.cs +++ b/SW.Bitween.NativeAdapters/ChannelMessages/EmailBody.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text; +using System.Text.RegularExpressions; namespace SW.Bitween.NativeAdapters.ChannelMessages; @@ -8,53 +9,124 @@ namespace SW.Bitween.NativeAdapters.ChannelMessages; /// for clients that will not show HTML. /// /// -/// Inline styles and a table, because that is all most mail clients honour. Every value is encoded: -/// an error message is text from somewhere else, and markup in it must not become the email's markup. +/// +/// Laid out with tables and inline styles, because that is all Outlook and most webmail honour: +/// no stylesheet, no flexbox, no SVG. The logo is a PNG sent inside the email and referred to by +/// , so it shows wherever the email is read, including outside the +/// network the instance runs in and in clients that block remote images. +/// +/// +/// Every value is encoded: an error message is text from somewhere else, and markup in it must not +/// become the email's markup. The accent is the instance's brand colour, taken only when it is a +/// plain hex colour, since it is written into a style attribute. +/// /// internal static class EmailBody { - public static (string Subject, string Html, string Text) Render(ChannelMessage message) + /// The cid: the HTML shows the logo by; the sender attaches it under this id. + public const string LogoContentId = "bitween-logo"; + + public const string DefaultAccent = "#e3311d"; + + const string Ink = "#201b1a"; + const string Body = "#4d4341"; + const string Muted = "#7f726e"; + const string Line = "#ece7e4"; + const string Wash = "#f6f3f1"; + const string Font = "-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif"; + + static readonly Regex HexColour = new("^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$", RegexOptions.Compiled); + + /// The logo the HTML refers to, as PNG bytes. + public static byte[] Logo() + { + using var stream = typeof(EmailBody).Assembly.GetManifestResourceStream( + "SW.Bitween.NativeAdapters.ChannelMessages.bitween-logo.png")!; + using var bytes = new MemoryStream(); + stream.CopyTo(bytes); + return bytes.ToArray(); + } + + /// The brand colour for the top bar and the button; Bitween's red when unset or not a hex colour. + public static (string Subject, string Html, string Text) Render(ChannelMessage message, string? accent = null) { // A subject is one line; a title with a line break in it would be cut there by most clients. var subject = message.Title.ReplaceLineEndings(" "); - return (subject, Html(message), Text(message)); + var colour = accent is not null && HexColour.IsMatch(accent.Trim()) ? accent.Trim() : DefaultAccent; + return (subject, Html(message, colour), Text(message)); } - private static string Html(ChannelMessage message) + private static string Html(ChannelMessage message, string accent) { static string E(string value) => WebUtility.HtmlEncode(value); var html = new StringBuilder(); - html.Append("
"); - html.Append($"
"); - html.Append($"
{E(message.Title)}
"); + html.Append("") + .Append("") + // Laid out for a light background; without these, some clients invert it unevenly. + .Append("") + .Append($"{E(message.Title)}") + .Append($""); + + // The first line of text, shown beside the subject in an inbox. + var preview = message.Text ?? message.Title; + html.Append($"
{E(preview)}
"); + + html.Append($"") + .Append("
") + .Append(""); + + // The card: the brand colour across the top, then the logo. + html.Append($""); - html.Append("

Sent by Bitween.

"); - html.Append(""); + html.Append($"") + .Append("
") + .Append($"\"Bitween\""); + + // The title, marked with what kind of message it is - except plain information, which in the + // brand's red would read as an error. + var title = $"
{E(message.Title)}
"; + if (Tone(message.Tone) is { } tone) + html.Append($"
") + .Append(title) + .Append("
"); + else + html.Append(title); + if (!string.IsNullOrWhiteSpace(message.Text)) - html.Append($"
{E(message.Text)}
"); - html.Append(""); + html.Append($"

{E(message.Text)}

"); if (message.Facts.Count > 0) { - html.Append(""); + html.Append($"
"); foreach (var fact in message.Facts) html.Append("") - .Append($"") - .Append($"") + .Append($"") + .Append($"") .Append(""); html.Append("
{E(fact.Label)}{E(fact.Value)}{E(fact.Label)}{E(fact.Value)}
"); } if (!string.IsNullOrWhiteSpace(message.Detail)) - html.Append("
")
+            html.Append($"
")
                 .Append(E(message.Detail))
                 .Append("
"); if (!string.IsNullOrWhiteSpace(message.LinkUrl)) - html.Append($"

") + { + var url = E(message.LinkUrl); + // A button made of a table cell, which Outlook draws as one. + html.Append("") + .Append($"
") + .Append($"") .Append(E(message.LinkLabel ?? "Open in Bitween")) - .Append("

"); + .Append("
") + // For a client that shows no button, and for copying into another browser. + .Append($"

Or open this link: ") + .Append($"{url}

"); + } + + html.Append("
Sent by Bitween
"); return html.ToString(); } @@ -70,14 +142,16 @@ private static string Text(ChannelMessage message) if (!string.IsNullOrWhiteSpace(message.Detail)) text.AppendLine().AppendLine(message.Detail); if (!string.IsNullOrWhiteSpace(message.LinkUrl)) text.AppendLine().AppendLine($"{message.LinkLabel ?? "Open in Bitween"}: {message.LinkUrl}"); + text.AppendLine().AppendLine("Sent by Bitween"); return text.ToString(); } - private static string Color(ChannelMessageTone tone) => tone switch + /// What kind of message it is, beside its title; null for plain information. + private static string? Tone(ChannelMessageTone tone) => tone switch { ChannelMessageTone.Failure => "#cf222e", ChannelMessageTone.Warning => "#bf8700", ChannelMessageTone.Success => "#1a7f37", - _ => "#0969da" + _ => null }; } diff --git a/SW.Bitween.NativeAdapters/ChannelMessages/bitween-logo.png b/SW.Bitween.NativeAdapters/ChannelMessages/bitween-logo.png new file mode 100644 index 00000000..8c3ea012 Binary files /dev/null and b/SW.Bitween.NativeAdapters/ChannelMessages/bitween-logo.png differ diff --git a/SW.Bitween.NativeAdapters/EmailMessageHandler/NativeEmailMessageHandler.cs b/SW.Bitween.NativeAdapters/EmailMessageHandler/NativeEmailMessageHandler.cs index 907ef136..a0137710 100644 --- a/SW.Bitween.NativeAdapters/EmailMessageHandler/NativeEmailMessageHandler.cs +++ b/SW.Bitween.NativeAdapters/EmailMessageHandler/NativeEmailMessageHandler.cs @@ -13,7 +13,9 @@ namespace SW.Bitween.NativeAdapters.EmailMessageHandler; /// stays for anyone who wants their own subject and body templates, /// or a server of their own per channel; both send through the same connection code. /// -public class NativeEmailMessageHandler(MailServerOptions mailServer, DestinationCircuits? circuits = null) +/// The instance's brand colour, for the email's top bar and button. +public class NativeEmailMessageHandler(MailServerOptions mailServer, DestinationCircuits? circuits = null, + string? accent = null) : INativeInfolinkHandler, IChannelMessageAdapter { readonly DestinationCircuits circuits = circuits ?? DestinationCircuits.None; @@ -37,12 +39,16 @@ public async Task Handle(XchangeFile xchangeFile) throw new InvalidOperationException( "No mail server is set up, so Email channels cannot send. Add one in Settings, under Mail server."); - var (subject, html, text) = EmailBody.Render(message); + var (subject, html, text) = EmailBody.Render(message, accent); var email = new MimeMessage(); email.From.Add(new MailboxAddress(mailServer.FromName ?? string.Empty, mailServer.From)); email.Subject = subject; - email.Body = new BodyBuilder { HtmlBody = html, TextBody = text }.ToMessageBody(); + var body = new BodyBuilder { HtmlBody = html, TextBody = text }; + // The logo travels inside the email, so it shows without fetching anything. + var logo = body.LinkedResources.Add("bitween-logo.png", EmailBody.Logo(), new ContentType("image", "png")); + logo.ContentId = EmailBody.LogoContentId; + email.Body = body.ToMessageBody(); NativeSmtpHandler.AddAddresses(email.To, _options.To); NativeSmtpHandler.AddAddresses(email.Cc, _options.Cc); diff --git a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj index 502934f6..94f1c12c 100644 --- a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj +++ b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj @@ -39,6 +39,7 @@ + diff --git a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs index 11b7c361..be6cc958 100644 --- a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs +++ b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs @@ -38,11 +38,16 @@ public static class ServiceCollectionExtensions /// The mail server Email channels send through. Read per adapter instance for the same reason as /// the license key: it is set in Settings while the app runs. Left out, Email channels have none. /// + /// + /// The brand colour emails are drawn in, read per adapter instance as it too changes in Settings. + /// Left out, Bitween's own. + /// public static void AddNativeAdapters(this IServiceCollection serviceCollection, Func? rebexLicenseKey = null, Func? blockPrivateNetworks = null, Func? circuitBreaker = null, - Func? mailServer = null) + Func? mailServer = null, + Func? emailAccent = null) { // One per process, so every adapter instance (they are scoped) learns from the others' calls. serviceCollection.TryAddSingleton(sp => new DestinationCircuits( @@ -96,9 +101,9 @@ public static void AddNativeAdapters(this IServiceCollection serviceCollection, // one, and kept out of the adapter pickers by IChannelMessageAdapter. mailServer ??= _ => new MailServerOptions(); serviceCollection.AddScoped(sp => - new NativeEmailMessageHandler(mailServer(sp), sp.GetRequiredService())); + new NativeEmailMessageHandler(mailServer(sp), sp.GetRequiredService(), emailAccent?.Invoke(sp))); serviceCollection.AddScoped(sp => - new NativeEmailMessageHandler(mailServer(sp), sp.GetRequiredService())); + new NativeEmailMessageHandler(mailServer(sp), sp.GetRequiredService(), emailAccent?.Invoke(sp))); serviceCollection.AddScoped(); serviceCollection.AddScoped(); diff --git a/SW.Bitween.PgSql/BitweenDbContext.cs b/SW.Bitween.PgSql/BitweenDbContext.cs index d9735ee0..5555f926 100644 --- a/SW.Bitween.PgSql/BitweenDbContext.cs +++ b/SW.Bitween.PgSql/BitweenDbContext.cs @@ -579,6 +579,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(p => p.Hash).IsUnique(); }); + modelBuilder.Entity(b => + { + b.ToTable("AccountLinks"); + b.HasKey(p => p.Id); + b.Property(p => p.Id).ValueGeneratedOnAdd(); + b.HasOne().WithMany().HasForeignKey(p => p.AccountId).OnDelete(DeleteBehavior.Cascade); + + b.Property(p => p.Purpose).HasConversion(); + b.Property(p => p.Hash).IsRequired().IsUnicode(false).HasMaxLength(64); + b.Property(p => p.EmailError).HasMaxLength(500); + // A link is looked up by its hash when it is opened. + b.HasIndex(p => p.Hash).IsUnique(); + }); + modelBuilder.Entity(b => { b.ToTable("Roles"); diff --git a/SW.Bitween.PgSql/Migrations/20261015131705_AccountLinks.Designer.cs b/SW.Bitween.PgSql/Migrations/20261015131705_AccountLinks.Designer.cs new file mode 100644 index 00000000..aa8971bb --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261015131705_AccountLinks.Designer.cs @@ -0,0 +1,4103 @@ +// +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("20261015131705_AccountLinks")] + partial class AccountLinks + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("infolink") + .HasAnnotation("ProductVersion", "9.0.20") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); + 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("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("LastSignInOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sign_in_on"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("LoginMethods") + .HasColumnType("smallint") + .HasColumnName("login_methods"); + + b.Property("MicrosoftIdentity") + .HasColumnType("text") + .HasColumnName("microsoft_identity"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("MustChangePassword") + .HasColumnType("boolean") + .HasColumnName("must_change_password"); + + b.Property("OidcIdentity") + .HasColumnType("text") + .HasColumnName("oidc_identity"); + + 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(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Deleted = false, + Disabled = false, + DisplayName = "Admin", + Email = "admin@Bitween.systems", + EmailProvider = (byte)0, + FailedLoginCount = 0, + Kind = (byte)0, + LoginMethods = (byte)2, + MustChangePassword = true, + Password = "$SWHASH$V1$10000$VQCi48eitH4Ml5juvBMOFZrMdQwBbhuIQVXe6RR7qJdDF2bJ", + Role = 0 + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("email_error"); + + b.Property("EmailedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("emailed_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("Purpose") + .HasColumnType("smallint") + .HasColumnName("purpose"); + + b.HasKey("Id") + .HasName("pk_account_links"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_links_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_account_links_hash"); + + b.ToTable("AccountLinks", "infolink"); + }); + + 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.Property("SupersededOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("superseded_on"); + + 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(2022, 1, 1, 0, 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(2022, 1, 1, 0, 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(2022, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Read-only access to integrations, exchanges and configuration.", + IsSystem = true, + Name = "Viewer", + Permissions = "[]" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Accounts.ServiceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("LastUsedFrom") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("last_used_from"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_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(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RevokedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("revoked_by"); + + b.Property("RevokedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_on"); + + b.Property("Shown") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("shown"); + + b.HasKey("Id") + .HasName("pk_service_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_service_tokens_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_service_tokens_hash"); + + b.ToTable("ServiceTokens", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("BaseVersion") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("base_version"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FilesHash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("files_hash"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("files_json"); + + b.Property("Kind") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("kind"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("language"); + + b.Property("ModifiedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.HasKey("Id") + .HasName("pk_adapter_draft"); + + b.HasIndex("AdapterId") + .HasDatabaseName("ix_adapter_draft_adapter_id"); + + b.ToTable("adapter_draft", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterRelease", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("action"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("DraftId") + .HasColumnType("integer") + .HasColumnName("draft_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_release"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_release_adapter_id_occurred_on"); + + b.ToTable("adapter_release", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AdapterSourceAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("account_id"); + + b.Property("AdapterId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Path") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("path"); + + b.Property("Version") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_adapter_source_access"); + + b.HasIndex("AdapterId", "OccurredOn") + .HasDatabaseName("ix_adapter_source_access_adapter_id_occurred_on"); + + b.ToTable("adapter_source_access", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.ApiKeyUse", b => + { + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("KeyName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("key_name"); + + b.Property("LastUsedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_on"); + + b.HasKey("PartnerId", "KeyName") + .HasName("pk_api_key_use"); + + b.ToTable("api_key_use", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.AuditEntry", b => + { + b.Property("Id") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("character varying(32)") + .HasColumnName("id"); + + b.Property("Changes") + .HasColumnType("text") + .HasColumnName("changes"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("character varying(36)") + .HasColumnName("correlation_id"); + + b.Property("EntityKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_key"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_name"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Sequence") + .HasColumnType("integer") + .HasColumnName("sequence"); + + b.Property("State") + .IsRequired() + .HasMaxLength(10) + .IsUnicode(false) + .HasColumnType("character varying(10)") + .HasColumnName("state"); + + b.Property("UserId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_audit_entries"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_entries_correlation_id"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_audit_entries_occurred_on"); + + b.HasIndex("EntityName", "EntityKey", "OccurredOn") + .HasDatabaseName("ix_audit_entries_entity_name_entity_key_occurred_on"); + + b.ToTable("AuditEntries", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterLease", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("AcquiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("acquired_on"); + + b.Property("OwnerNode") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("owner_node"); + + b.Property("Term") + .HasColumnType("bigint") + .HasColumnName("term"); + + b.HasKey("Id") + .HasName("pk_cluster_lease"); + + b.ToTable("cluster_lease", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Cluster.ClusterNode", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("DataSources") + .HasColumnType("boolean") + .HasColumnName("data_sources"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("host"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("OutboxPublishedLastHour") + .HasColumnType("integer") + .HasColumnName("outbox_published_last_hour"); + + b.Property("ResidentHealth") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("resident_health"); + + b.Property("Runtimes") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("runtimes"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_on"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_cluster_node"); + + b.HasIndex("LastSeenOn") + .HasDatabaseName("ix_cluster_node_last_seen_on"); + + b.ToTable("cluster_node", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Connections.Connection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasColumnType("text") + .HasColumnName("access_token"); + + b.Property("AccessTokenExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("access_token_expires_on"); + + b.Property("AuthorizeParameters") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("authorize_parameters"); + + b.Property("AuthorizeUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("authorize_url"); + + b.Property("AuthorizedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("authorized_by"); + + b.Property("AuthorizedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("authorized_on"); + + b.Property("ClientId") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Grant") + .HasColumnType("integer") + .HasColumnName("grant"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + 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) + .IsUnicode(false) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refresh_token"); + + b.Property("RenewingUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("renewing_until"); + + b.Property("Scopes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("scopes"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TokenAuth") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character varying(20)") + .HasColumnName("token_auth"); + + b.Property("TokenUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("token_url"); + + b.Property("Username") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_connections"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_connections_name"); + + b.ToTable("Connections", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.AdapterState", b => + { + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("InstanceKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("instance_key"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.Property("Value") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("value"); + + b.HasKey("AdapterId", "InstanceKey", "Name") + .HasName("pk_adapter_state"); + + b.ToTable("adapter_state", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdapterId") + .HasColumnType("text") + .HasColumnName("adapter_id"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("CpuLimitSamples") + .HasColumnType("integer") + .HasColumnName("cpu_limit_samples"); + + b.Property("CpuPercentLimit") + .HasColumnType("double precision") + .HasColumnName("cpu_percent_limit"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DeduplicationWindowDays") + .HasColumnType("integer") + .HasColumnName("deduplication_window_days"); + + b.Property("HardMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("hard_memory_limit_mb"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("Kind") + .HasColumnType("integer") + .HasColumnName("kind"); + + b.Property("LastException") + .HasColumnType("text") + .HasColumnName("last_exception"); + + b.Property("LastHeartbeatOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_heartbeat_on"); + + b.Property("LastKnownState") + .HasColumnType("text") + .HasColumnName("last_known_state"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("ModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified_on"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnedByNode") + .HasColumnType("text") + .HasColumnName("owned_by_node"); + + b.Property("Placement") + .HasColumnType("integer") + .HasColumnName("placement"); + + b.Property>("Properties") + .HasColumnType("hstore") + .HasColumnName("properties"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("text[]") + .HasColumnName("secret_properties"); + + b.Property("SoftMemoryLimitMb") + .HasColumnType("integer") + .HasColumnName("soft_memory_limit_mb"); + + b.HasKey("Id") + .HasName("pk_data_source"); + + b.ToTable("data_source", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", 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("CursorColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("cursor_column"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("KeyColumn") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("key_column"); + + 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("Sql") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sql"); + + b.Property("WorkGroupId") + .HasColumnType("integer") + .HasColumnName("work_group_id"); + + b.HasKey("Id") + .HasName("pk_data_source_statement"); + + b.HasIndex("WorkGroupId") + .HasDatabaseName("ix_data_source_statement_work_group_id"); + + b.HasIndex("DataSourceId", "Name") + .IsUnique() + .HasDatabaseName("ix_data_source_statement_data_source_id_name"); + + b.ToTable("data_source_statement", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("character varying(400)") + .HasColumnName("id"); + + b.Property("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("SeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("seen_on"); + + b.Property("XchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_inbound_message"); + + b.HasIndex("DataSourceId") + .HasDatabaseName("ix_inbound_message_data_source_id"); + + b.HasIndex("SeenOn") + .HasDatabaseName("ix_inbound_message_seen_on"); + + b.ToTable("inbound_message", "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.Property("RunFailures") + .HasColumnType("integer") + .HasColumnName("run_failures"); + + 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("DropDuplicates") + .HasColumnType("boolean") + .HasColumnName("drop_duplicates"); + + b.Property("DuplicateInterval") + .HasColumnType("integer") + .HasColumnName("duplicate_interval"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PromotedProperties") + .HasColumnType("json") + .HasColumnName("promoted_properties"); + + b.Property("RetiredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_on"); + + b.Property("ValidationSchema") + .HasColumnType("text") + .HasColumnName("validation_schema"); + + 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, + DropDuplicates = false, + DuplicateInterval = 0, + Name = "Aggregation Document", + PromotedProperties = "{}" + }); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DuplicateArrival", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Dropped") + .HasColumnType("boolean") + .HasColumnName("dropped"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("OriginalXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("original_xchange_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.HasKey("Id") + .HasName("pk_duplicate_arrival"); + + b.HasIndex("DocumentId", "OccurredOn") + .HasDatabaseName("ix_duplicate_arrival_document_id_occurred_on"); + + b.ToTable("duplicate_arrival", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Gateway.ApiGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthMethod") + .HasColumnType("integer") + .HasColumnName("auth_method"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("FailureAnswer") + .HasColumnType("integer") + .HasColumnName("failure_answer"); + + b.Property("Inactive") + .HasColumnType("boolean") + .HasColumnName("inactive"); + + b.Property("JwtAudience") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_audience"); + + b.Property("JwtIssuer") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("jwt_issuer"); + + b.Property("JwtPartnerClaim") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("jwt_partner_claim"); + + 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("PartnerKeyHeader") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("partner_key_header"); + + 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("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("Endpoint") + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property>("EndpointProperties") + .HasColumnType("hstore") + .HasColumnName("endpoint_properties"); + + 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("DataSourceId") + .HasDatabaseName("ix_bus_gateway_data_source_id"); + + 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.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + 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.IdempotencyKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope"); + + b.Property("XchangeId") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_idempotency_key"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_idempotency_key_created_on"); + + b.HasIndex("Scope", "Key") + .IsUnique() + .HasDatabaseName("ix_idempotency_key_scope_key"); + + b.ToTable("idempotency_key", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.AdapterStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("AdapterId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("adapter_id"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("Role") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Runtime") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("runtime"); + + b.Property("Calls") + .HasColumnType("integer") + .HasColumnName("calls"); + + b.Property("Crashes") + .HasColumnType("integer") + .HasColumnName("crashes"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failures") + .HasColumnType("integer") + .HasColumnName("failures"); + + b.Property("LimitStops") + .HasColumnType("integer") + .HasColumnName("limit_stops"); + + b.Property("Timeouts") + .HasColumnType("integer") + .HasColumnName("timeouts"); + + b.HasKey("Hour", "Node", "AdapterId", "Version", "Role", "Runtime") + .HasName("pk_adapter_stats"); + + b.ToTable("adapter_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ErrorStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("Scope") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("scope"); + + b.Property("ScopeKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scope_key"); + + b.Property("Fingerprint") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("fingerprint"); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("LastSeenOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_on"); + + b.Property("Sample") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("sample"); + + b.HasKey("Hour", "Node", "Scope", "ScopeKey", "Fingerprint") + .HasName("pk_error_stats"); + + b.ToTable("error_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.ExchangeStats", b => + { + b.Property("Hour") + .HasColumnType("timestamp with time zone") + .HasColumnName("hour"); + + b.Property("Node") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("BadResponse") + .HasColumnType("integer") + .HasColumnName("bad_response"); + + b.Property("BytesIn") + .HasColumnType("bigint") + .HasColumnName("bytes_in"); + + b.Property("BytesOut") + .HasColumnType("bigint") + .HasColumnName("bytes_out"); + + b.Property("D0") + .HasColumnType("integer") + .HasColumnName("d0"); + + b.Property("D1") + .HasColumnType("integer") + .HasColumnName("d1"); + + b.Property("D2") + .HasColumnType("integer") + .HasColumnName("d2"); + + b.Property("D3") + .HasColumnType("integer") + .HasColumnName("d3"); + + b.Property("D4") + .HasColumnType("integer") + .HasColumnName("d4"); + + b.Property("D5") + .HasColumnType("integer") + .HasColumnName("d5"); + + b.Property("D6") + .HasColumnType("integer") + .HasColumnName("d6"); + + b.Property("D7") + .HasColumnType("integer") + .HasColumnName("d7"); + + b.Property("D8") + .HasColumnType("integer") + .HasColumnName("d8"); + + b.Property("D9") + .HasColumnType("integer") + .HasColumnName("d9"); + + b.Property("DbCommands") + .HasColumnType("bigint") + .HasColumnName("db_commands"); + + b.Property("DurationMaxMs") + .HasColumnType("bigint") + .HasColumnName("duration_max_ms"); + + b.Property("DurationSumMs") + .HasColumnType("bigint") + .HasColumnName("duration_sum_ms"); + + b.Property("Failed") + .HasColumnType("integer") + .HasColumnName("failed"); + + b.Property("FailedAtHandler") + .HasColumnType("integer") + .HasColumnName("failed_at_handler"); + + b.Property("FailedAtMapper") + .HasColumnType("integer") + .HasColumnName("failed_at_mapper"); + + b.Property("ManualRetries") + .HasColumnType("integer") + .HasColumnName("manual_retries"); + + b.Property("PayloadParses") + .HasColumnType("bigint") + .HasColumnName("payload_parses"); + + b.Property("Retries") + .HasColumnType("integer") + .HasColumnName("retries"); + + b.Property("Succeeded") + .HasColumnType("integer") + .HasColumnName("succeeded"); + + b.Property("Total") + .HasColumnType("integer") + .HasColumnName("total"); + + b.HasKey("Hour", "Node", "SubscriptionId", "PartnerId") + .HasName("pk_exchange_stats"); + + b.ToTable("exchange_stats", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.Metrics.QueueSnapshot", b => + { + b.Property("At") + .HasColumnType("timestamp with time zone") + .HasColumnName("at"); + + b.Property("AckRate") + .HasColumnType("double precision") + .HasColumnName("ack_rate"); + + b.Property("Consumers") + .HasColumnType("integer") + .HasColumnName("consumers"); + + b.Property("DeadLetterBacklog") + .HasColumnType("bigint") + .HasColumnName("dead_letter_backlog"); + + b.Property("IncomingRate") + .HasColumnType("double precision") + .HasColumnName("incoming_rate"); + + b.Property("QueueDepth") + .HasColumnType("bigint") + .HasColumnName("queue_depth"); + + b.Property("RetryBacklog") + .HasColumnType("bigint") + .HasColumnName("retry_backlog"); + + b.Property("UnhealthyConsumers") + .HasColumnType("integer") + .HasColumnName("unhealthy_consumers"); + + b.HasKey("At") + .HasName("pk_queue_snapshot"); + + b.ToTable("queue_snapshot", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.NotificationChannel", 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("HandlerId") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(false) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("text") + .HasColumnName("handler_properties"); + + 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.HasKey("Id") + .HasName("pk_notification_channel"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_channel_name"); + + b.ToTable("notification_channel", "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.PrimitiveCollection("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("CorrelationId") + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("Data") + .HasColumnType("text") + .HasColumnName("data"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.PrimitiveCollection("References") + .HasColumnType("text[]") + .HasColumnName("references"); + + b.Property>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + 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.OperatorAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("account_id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("action"); + + b.Property("Detail") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("detail"); + + b.Property("OccurredOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on"); + + b.Property("Target") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("target"); + + b.HasKey("Id") + .HasName("pk_operator_action"); + + b.HasIndex("OccurredOn") + .HasDatabaseName("ix_operator_action_occurred_on"); + + b.ToTable("operator_action", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("ClaimedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_until"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Headers") + .HasColumnType("text") + .HasColumnName("headers"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("message_type"); + + b.Property("PublishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_on"); + + b.HasKey("Id") + .HasName("pk_outbox_message"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("ix_outbox_message_created_on") + .HasFilter("published_on IS NULL") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("outbox_message", "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("LoginIdentity") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("login_identity"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.PrimitiveCollection>("SecretProperties") + .HasColumnType("jsonb") + .HasColumnName("secret_properties"); + + b.HasKey("Id") + .HasName("pk_partner"); + + b.HasIndex("LoginIdentity") + .IsUnique() + .HasDatabaseName("ix_partner_login_identity"); + + 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.PrimitiveCollection("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("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_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("AlertChannelId") + .HasColumnType("integer") + .HasColumnName("alert_channel_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("AutoPauseAfterFailures") + .HasColumnType("integer") + .HasColumnName("auto_pause_after_failures"); + + 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("DataSourceId") + .HasColumnType("integer") + .HasColumnName("data_source_id"); + + b.Property>("DocumentFilter") + .HasColumnType("jsonb") + .HasColumnName("document_filter"); + + b.Property("DocumentId") + .HasColumnType("integer") + .HasColumnName("document_id"); + + b.Property("ExpectTraffic") + .HasColumnType("text") + .HasColumnName("expect_traffic"); + + b.Property("HandlerId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("handler_id"); + + b.Property("HandlerProperties") + .HasColumnType("jsonb") + .HasColumnName("handler_properties"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + 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("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("MatchExpression") + .HasColumnType("text") + .HasColumnName("match_expression"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Notifications") + .HasColumnType("text") + .HasColumnName("notifications"); + + b.Property("PartnerId") + .HasColumnType("integer") + .HasColumnName("partner_id"); + + b.Property("PausedAutomatically") + .HasColumnType("boolean") + .HasColumnName("paused_automatically"); + + b.Property("PausedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_on"); + + b.Property("QuietSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("quiet_since"); + + 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("ReceiverVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("receiver_version"); + + 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("RunOnBadResponses") + .HasColumnType("boolean") + .HasColumnName("run_on_bad_responses"); + + b.Property("RunningSince") + .HasColumnType("timestamp with time zone") + .HasColumnName("running_since"); + + 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("ValidatorVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("validator_version"); + + 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("DataSourceId") + .HasDatabaseName("ix_subscription_data_source_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.SubscriptionRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("SavedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("saved_by"); + + b.Property("SavedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("saved_on"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("text") + .HasColumnName("snapshot"); + + b.Property("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.HasKey("Id") + .HasName("pk_subscription_revision"); + + b.HasIndex("SubscriptionId", "Number") + .IsUnique() + .HasDatabaseName("ix_subscription_revision_subscription_id_number"); + + b.ToTable("subscription_revision", "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("FilesPrefix") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("files_prefix"); + + 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.PrimitiveCollection("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>("SourceValues") + .HasColumnType("jsonb") + .HasColumnName("source_values"); + + b.Property("SourceXchangeId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_xchange_id"); + + 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.XchangeNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer") + .HasColumnName("channel_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("Suppressed") + .HasColumnType("boolean") + .HasColumnName("suppressed"); + + b.Property("XchangeId") + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("character varying(50)") + .HasColumnName("xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_notification"); + + b.HasIndex("XchangeId") + .HasDatabaseName("ix_xchange_notification_xchange_id"); + + b.HasIndex("ChannelId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_channel_id_finished_on"); + + b.HasIndex("NotifierId", "FinishedOn") + .HasDatabaseName("ix_xchange_notification_notifier_id_finished_on"); + + b.ToTable("xchange_notification", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangePromotedProperties", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.PrimitiveCollection("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.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("ConsecutiveFailures") + .HasColumnType("integer") + .HasColumnName("consecutive_failures"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Exception") + .HasColumnType("text") + .HasColumnName("exception"); + + b.Property("FinishedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_on"); + + b.Property("HandlerVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("handler_version"); + + b.Property("MapperVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mapper_version"); + + b.Property("NodeName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node_name"); + + 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("SubscriptionId") + .HasColumnType("integer") + .HasColumnName("subscription_id"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Warning") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("warning"); + + b.HasKey("Id") + .HasName("pk_xchange_result"); + + b.HasIndex("RetryGroupId") + .HasDatabaseName("ix_xchange_result_retry_group_id"); + + b.HasIndex("SubscriptionId", "FinishedOn") + .HasDatabaseName("ix_xchange_result_subscription_id_finished_on") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.ToTable("xchange_result", "infolink"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.XchangeRetryClaim", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("id"); + + b.Property("ClaimedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_on"); + + b.Property("RetryXchangeId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("retry_xchange_id"); + + b.HasKey("Id") + .HasName("pk_xchange_retry_claim"); + + b.ToTable("xchange_retry_claim", "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.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_links_accounts_account_id"); + }); + + 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.Accounts.ServiceToken", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_service_tokens_accounts_account_id"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.DataSourceStatement", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_data_source_statement_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.WorkGroup", null) + .WithMany() + .HasForeignKey("WorkGroupId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_data_source_statement_work_group_work_group_id"); + + b.Navigation("DataSource"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.DataSources.InboundMessage", b => + { + b.HasOne("SW.Bitween.Domain.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_inbound_message_data_source_data_source_id"); + }); + + 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.DataSources.DataSource", "DataSource") + .WithMany() + .HasForeignKey("DataSourceId") + .HasConstraintName("fk_bus_gateway_data_source_data_source_id"); + + b.HasOne("SW.Bitween.Domain.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bus_gateway_document_document_id"); + + b.Navigation("DataSource"); + }); + + 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("KeyPrefix") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("key_prefix"); + + 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.DataSources.DataSource", null) + .WithMany() + .HasForeignKey("DataSourceId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subscription_data_source_data_source_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", "ResponseSubscription") + .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("ResponseSubscription"); + + b.Navigation("RetryPolicy"); + + b.Navigation("Schedules"); + + b.Navigation("WorkGroup"); + }); + + modelBuilder.Entity("SW.Bitween.Domain.SubscriptionRevision", b => + { + b.HasOne("SW.Bitween.Domain.Subscription", null) + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subscription_revision_subscription_subscription_id"); + }); + + 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.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.Bitween.Domain.XchangeRetryClaim", b => + { + b.HasOne("SW.Bitween.Domain.Xchange", null) + .WithOne() + .HasForeignKey("SW.Bitween.Domain.XchangeRetryClaim", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_xchange_retry_claim_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/20261015131705_AccountLinks.cs b/SW.Bitween.PgSql/Migrations/20261015131705_AccountLinks.cs new file mode 100644 index 00000000..cd325354 --- /dev/null +++ b/SW.Bitween.PgSql/Migrations/20261015131705_AccountLinks.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace SW.Bitween.PgSql.Migrations +{ + /// + public partial class AccountLinks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AccountLinks", + schema: "infolink", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + account_id = table.Column(type: "integer", nullable: false), + purpose = table.Column(type: "smallint", nullable: false), + hash = table.Column(type: "character varying(64)", unicode: false, maxLength: 64, nullable: false), + expires_on = table.Column(type: "timestamp with time zone", nullable: false), + emailed_on = table.Column(type: "timestamp with time zone", nullable: true), + email_error = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + created_on = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_account_links", x => x.id); + table.ForeignKey( + name: "fk_account_links_accounts_account_id", + column: x => x.account_id, + principalSchema: "infolink", + principalTable: "Accounts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_account_links_account_id", + schema: "infolink", + table: "AccountLinks", + column: "account_id"); + + migrationBuilder.CreateIndex( + name: "ix_account_links_hash", + schema: "infolink", + table: "AccountLinks", + column: "hash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AccountLinks", + schema: "infolink"); + } + } +} diff --git a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs index 14d5c1af..492ba258 100644 --- a/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs +++ b/SW.Bitween.PgSql/Migrations/BitweenDbContextModelSnapshot.cs @@ -145,6 +145,60 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer") + .HasColumnName("account_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("email_error"); + + b.Property("EmailedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("emailed_on"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("character varying(64)") + .HasColumnName("hash"); + + b.Property("Purpose") + .HasColumnType("smallint") + .HasColumnName("purpose"); + + b.HasKey("Id") + .HasName("pk_account_links"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_links_account_id"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_account_links_hash"); + + b.ToTable("AccountLinks", "infolink"); + }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => { b.Property("AccountId") @@ -3586,6 +3640,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("qrtz_triggers", "infolink"); }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountLink", b => + { + b.HasOne("SW.Bitween.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_links_accounts_account_id"); + }); + modelBuilder.Entity("SW.Bitween.Domain.Accounts.AccountRoleLink", b => { b.HasOne("SW.Bitween.Domain.Accounts.Account", null) diff --git a/SW.Bitween.Sdk/Model/Account.cs b/SW.Bitween.Sdk/Model/Account.cs index 594986e8..b3742fac 100644 --- a/SW.Bitween.Sdk/Model/Account.cs +++ b/SW.Bitween.Sdk/Model/Account.cs @@ -21,6 +21,12 @@ public class CreateAccountModel /// Roles to grant. Preferred over , which is legacy. public List RoleIds { get; set; } = []; + + /// + /// The member chooses their own password from an invite link instead, so none is sent. The + /// invite itself is POST /accounts/{id}/invite. + /// + public bool Invite { get; set; } } public class UpdateAccountModel @@ -75,6 +81,77 @@ public class AccountModel // Non-null and in the future => the account is currently locked out. public DateTime? LockoutEnd { get; set; } + + /// Whether the member has a password. One invited and yet to choose it hasn't. + public bool HasPassword { get; set; } + + /// The member's latest invite while they are yet to choose a password; null otherwise. + public AccountInviteModel? Invite { get; set; } +} + +/// An invite waiting to be used. +public class AccountInviteModel +{ + public DateTime ExpiresOn { get; set; } + + /// When it was emailed; null when it wasn't, and the link was handed to whoever invited. + public DateTime? EmailedOn { get; set; } + + /// Why the email couldn't be sent, when it couldn't. + public string? EmailError { get; set; } +} + +public class InviteAccountModel +{ +} + +/// What became of an invite. +public class InviteResultModel +{ + /// Whether it was emailed to the member. + public bool Emailed { get; set; } + + /// Why it couldn't be, when there is a mail server and sending failed. + public string? EmailError { get; set; } + + /// When the link stops working; null for an instance without passwords, whose invite has no link. + public DateTime? ExpiresOn { get; set; } + + /// + /// The link, only when it wasn't emailed, for whoever invited to pass on. Shown once: Bitween + /// keeps only its hash. + /// + public string? Link { get; set; } +} + +/// "Forgot password?" on the sign-in page. +public class ForgotPasswordModel +{ + public string Email { get; set; } = null!; +} + +/// A link from an invite or reset email, opened. +public class PasswordLinkModel +{ + public string Token { get; set; } = null!; +} + +/// Who a password link is for, for the page that uses it. +public class PasswordLinkInfoModel +{ + /// Invite or PasswordReset. + public string Purpose { get; set; } = null!; + + public string Name { get; set; } = null!; + public string Email { get; set; } = null!; + public DateTime ExpiresOn { get; set; } +} + +/// A password chosen from an invite or reset link. +public class UsePasswordLinkModel +{ + public string Token { get; set; } = null!; + public string Password { get; set; } = null!; } /// The signed-in account, plus everything the UI needs to decide what to show. diff --git a/SW.Bitween.UnitTests/ChannelMessageTests.cs b/SW.Bitween.UnitTests/ChannelMessageTests.cs index 759e2f0a..2cf372b6 100644 --- a/SW.Bitween.UnitTests/ChannelMessageTests.cs +++ b/SW.Bitween.UnitTests/ChannelMessageTests.cs @@ -300,6 +300,23 @@ public void An_email_shows_markup_from_an_error_as_text() StringAssert.Contains(text, "Open the exchange: https://bitween.example/exchanges?ids=a&b"); } + [TestMethod] + public void An_email_is_drawn_in_the_brand_colour_with_the_logo_it_carries() + { + var message = new ChannelMessage { Title = "You're invited to Bitween", LinkUrl = "https://bitween.example/x" }; + + var branded = EmailBody.Render(message, "#0055aa").Html; + StringAssert.Contains(branded, "border-top:4px solid #0055aa"); + StringAssert.Contains(branded, "bgcolor=\"#0055aa\""); + StringAssert.Contains(branded, $"src=\"cid:{EmailBody.LogoContentId}\""); + Assert.IsTrue(EmailBody.Logo().Length > 0); + + // The colour goes into a style attribute, so anything but a plain hex colour is ignored. + var forged = EmailBody.Render(message, "red;background:url(https://evil.example)").Html; + Assert.IsFalse(forged.Contains("evil.example")); + StringAssert.Contains(forged, $"border-top:4px solid {EmailBody.DefaultAccent}"); + } + [TestMethod] public async Task The_Slack_adapter_posts_to_its_webhook_and_reports_what_Slack_said_when_refused() { diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index faa3a794..7fd392e8 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -1,5 +1,7 @@ import type { AddBusRouteInput, AttachPartnerInput } from "./http/gateways"; import type { + InviteResult, + PasswordLinkInfo, AdapterInfo, KitInfo, KitInstallRequest, @@ -148,11 +150,16 @@ export interface ApiClient { logout(): Promise; // - self service - - // No password-reset flow exists on the backend yet (BACKEND_WIRING_PLAN.md G3) - it needs - // outbound mail, which Bitween doesn't have. Hidden in the UI rather than faked. updateProfile(changes: { displayName: string }): Promise; changePassword(currentPassword: string, newPassword: string): Promise; + // - password links, from invite and reset emails; no session needed - + /** Emails a reset link when the address is a member's. Answers the same either way. */ + forgotPassword(email: string): Promise; + readPasswordLink(token: string): Promise; + /** Sets the password and spends the link. Returns the member's email, to sign in with. */ + usePasswordLink(token: string, password: string): Promise<{ email: string }>; + // - service accounts - listServiceAccounts(): Promise; createServiceAccount(input: { name: string; roleIds: number[] }): Promise; @@ -166,16 +173,19 @@ export interface ApiClient { listUsers(): Promise; getUser(id: string): Promise; /** - * Creates a member outright, password and all. Bitween has no outbound mail, so there's no - * invite link - whoever adds the member passes the first password on themselves. + * Creates a member. With `invite` they choose their own password from an invite (see + * `inviteUser`); otherwise whoever adds them sets it here and passes it on. */ createUser(input: { displayName: string; email: string; - password: string; + password?: string; + invite?: boolean; roleIds: string[]; }): Promise; - /** Stands in for a self-service reset, which would need mail Bitween doesn't have. */ + /** Invites a member with no password yet: emailed, or a link handed back to pass on. Replaces any earlier one. */ + inviteUser(id: string): Promise; + /** Sets someone else's password, for when they can't reset it themselves. */ setUserPassword(id: string, password: string): Promise; updateUserRoles(id: string, roleIds: string[]): Promise; /** Clears a failed-sign-in lockout early, rather than waiting it out. */ diff --git a/SW.Bitween.Web/ClientApp/src/api/http/appConfig.ts b/SW.Bitween.Web/ClientApp/src/api/http/appConfig.ts index efce5bce..5b58bd9d 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/appConfig.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/appConfig.ts @@ -12,6 +12,10 @@ export interface AppConfig { /** The provider's name for the button, as "Continue with {name}"; null for "single sign-on". */ oidcDisplayName?: string | null; isRabbitMqManagementConfigured?: boolean; + /** Whether a mail server is set, so members can be emailed invites. */ + mailServerConfigured?: boolean; + /** Whether the sign-in page can offer "Forgot password?": a mail server and a public address. */ + passwordResetAvailable?: boolean; /** Effective brand values (any stored override already applied), keyed like `ThemeOptions`. */ theme?: Record; /** The same keys as configured, before any override - lets us tell "set" from "untouched". */ diff --git a/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts b/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts index 69cc41f6..bf6c6c64 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts @@ -52,6 +52,7 @@ interface RawApiGateway { // Only the detail endpoint sends these. authentication?: RawGatewayAuthentication | null; defaultKeyHeader?: string | null; + failureAnswer?: GatewayFailureAnswer | null; } interface RawBusGatewayRoute { id: number; @@ -111,6 +112,7 @@ const toApiGatewayDetail = (raw: RawApiGateway): ApiGatewayDetail => ({ partnerClaim: raw.authentication?.partnerClaim ?? "", }, defaultKeyHeader: raw.defaultKeyHeader ?? "partnerkey", + failureAnswer: raw.failureAnswer ?? undefined, }); const toBusGatewayRoute = (r: RawBusGatewayRoute): BusGatewayRoute => ({ diff --git a/SW.Bitween.Web/ClientApp/src/api/http/session.ts b/SW.Bitween.Web/ClientApp/src/api/http/session.ts index 88eff06f..ad8400da 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/session.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/session.ts @@ -1,4 +1,4 @@ -import { ApiRequestError, type Session, type User } from "../types"; +import { ApiRequestError, type PasswordLinkInfo, type Session, type User } from "../types"; import { getAppConfig } from "./appConfig"; import { clearToken, get, getToken, mayHaveSession, post, request, setToken, silentRefresh } from "./request"; @@ -32,6 +32,9 @@ const buildSession = (profile: Profile): Session => { lockedUntil: null, microsoftLinked: false, createdOn: profile.createdOn, + // Signed in, so they have whatever it takes to sign in; nothing on the profile reads these. + hasPassword: true, + invite: null, }; return { user, @@ -180,4 +183,16 @@ export const sessionMethods = { newPassword, }); }, + + async forgotPassword(email: string): Promise { + await post("/accounts/forgotPassword", { email }); + }, + + async readPasswordLink(token: string): Promise { + return await post("/accounts/passwordLink", { token }); + }, + + async usePasswordLink(token: string, password: string): Promise<{ email: string }> { + return await post<{ email: string }>("/accounts/usePasswordLink", { token, password }); + }, }; diff --git a/SW.Bitween.Web/ClientApp/src/api/http/team.ts b/SW.Bitween.Web/ClientApp/src/api/http/team.ts index e978ab6a..ea60f935 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/team.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/team.ts @@ -1,6 +1,7 @@ import type { ApiClient } from "../client"; import { ApiRequestError, + type InviteResult, type PermissionArea, type PermissionKey, type Role, @@ -30,6 +31,8 @@ interface RawAccount { lastSignInOn?: string | null; microsoftLinked?: boolean; roles: RawRoleSummary[] | null; + hasPassword?: boolean; + invite?: { expiresOn: string; emailedOn: string | null; emailError: string | null } | null; } interface RawRole { @@ -54,6 +57,9 @@ const toUser = (r: RawAccount): User => ({ createdOn: r.createdOn, // The last sign-in, not the last request: a session renewing itself doesn't count. lastActiveOn: r.lastSignInOn ?? undefined, + // Missing from an older backend, whose members all have passwords. + hasPassword: r.hasPassword ?? true, + invite: r.invite ?? null, }); const toRole = (r: RawRole): Role => ({ @@ -95,22 +101,35 @@ export const teamMethods = { displayName, email, password, + invite, roleIds, }: { displayName: string; email: string; - password: string; + password?: string; + invite?: boolean; roleIds: string[]; }): Promise { const id = await post("/accounts", { name: displayName, email, - password, + ...(invite ? { invite: true } : { password }), roleIds: roleIds.map(Number), }); return fetchUser(String(id)); }, + async inviteUser(id: string): Promise { + const r = await post>(`/accounts/${id}/invite`, {}); + return { + emailed: r.emailed ?? false, + emailError: r.emailError ?? null, + expiresOn: r.expiresOn ?? null, + // Built on the address the admin is using when the instance has no public one. + link: r.link ? new URL(r.link, window.location.origin).toString() : null, + }; + }, + async setUserPassword(id: string, password: string): Promise { await post(`/accounts/${id}/setPassword`, { password }); }, diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 4ed41d5c..2f5f2e1c 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -48,6 +48,34 @@ export interface User { microsoftLinked: boolean; createdOn: string; lastActiveOn?: string; + /** False for a member invited and yet to choose a password. */ + hasPassword: boolean; + /** Their latest invite while they have no password, expired or not; null otherwise. */ + invite: MemberInvite | null; +} + +export interface MemberInvite { + expiresOn: string; + /** When it was emailed; null when the link was handed to whoever invited instead. */ + emailedOn: string | null; + /** Why the email couldn't be sent, when it couldn't. */ + emailError: string | null; +} + +/** What became of an invite. `link` only when it wasn't emailed, to pass on by hand - shown once. */ +export interface InviteResult { + emailed: boolean; + emailError: string | null; + expiresOn: string | null; + link: string | null; +} + +/** Who an invite or reset link is for. */ +export interface PasswordLinkInfo { + purpose: "Invite" | "PasswordReset"; + name: string; + email: string; + expiresOn: string; } export type ServiceTokenStatus = "active" | "expired" | "revoked"; diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/__tests__/FailureAnswer.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/__tests__/FailureAnswer.test.tsx new file mode 100644 index 00000000..4e5d4e79 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/__tests__/FailureAnswer.test.tsx @@ -0,0 +1,39 @@ +import { screen } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +/** What a sync caller is told when a delivery fails is read back from the gateway, not assumed. */ +const none = (path: string) => http.get(apiPath(path), () => HttpResponse.json({ result: [], totalCount: 0 })); + +describe("sync callers are told", () => { + it("shows the gateway's saved answer and keeps it on an unrelated save", async () => { + let saved: Record | undefined; + const { user } = renderApp("/api-gateways/15", { + handlers: [ + http.get(apiPath("/apigateways/15/rejections"), () => + HttpResponse.json({ node: "web-1:12", since: "2026-10-10T08:00:00Z", counts: {}, recent: [] }), + ), + http.get(apiPath("/apigateways/15"), () => + HttpResponse.json({ id: 15, name: "Orders", urlName: "orders", partnersCount: 0, inactive: false, partners: [], authentication: null, defaultKeyHeader: "partnerkey", failureAnswer: "Accurate" }), + ), + http.post(apiPath("/apigateways/15"), async ({ request }) => { + saved = (await request.json()) as Record; + return new HttpResponse(null, { status: 204 }); + }), + none("/apigateways/attachments"), + ...["/subscriptions", "/documents", "/partners", "/workgroups", "/retrypolicies", "/audit"].map((p) => none(p)), + ], + }); + + const answer = await screen.findByRole("combobox", { name: "Sync callers are told" }, { timeout: 5000 }); + expect(answer).toHaveValue("Accurate"); + + const name = screen.getByRole("textbox", { name: "Name" }); + await user.clear(name); + await user.type(name, "Orders inbound"); + await user.click(screen.getByRole("button", { name: /^Save/ })); + + await expect.poll(() => saved?.failureAnswer).toBe("Accurate"); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/auth/ForgotPassword.tsx b/SW.Bitween.Web/ClientApp/src/pages/auth/ForgotPassword.tsx new file mode 100644 index 00000000..123c0a57 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/auth/ForgotPassword.tsx @@ -0,0 +1,75 @@ +import { useState, type FormEvent } from "react"; +import { Link, useLocation } from "react-router"; +import { useMutation } from "@tanstack/react-query"; +import { MailCheck } from "lucide-react"; +import { api } from "../../api"; +import { Button, FormError } from "../../components/ui/basics"; +import { Field, TextInput } from "../../components/ui/forms"; +import { AuthLayout } from "./AuthLayout"; + +/** + * "Forgot password?": asks for an address and emails it a reset link. + * + * The answer is the same whether the address has an account or not - the server sends the same + * reply either way - so this page can't be used to find out who has one. + */ +export function ForgotPasswordPage() { + const location = useLocation(); + const [email, setEmail] = useState((location.state as { email?: string } | null)?.email ?? ""); + + const send = useMutation({ mutationFn: () => api.forgotPassword(email.trim()) }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + send.mutate(); + }; + + return ( + + {send.isSuccess ? ( +
+ +

Check your email

+

+ If {email.trim()} belongs to a member, a link + to choose a new password is on its way. It works once, for 1 hour. +

+

+ Nothing after a few minutes? Check your spam folder, or ask your administrator. +

+ + Back to sign in + +
+ ) : ( + <> +

Reset your password

+

+ Enter the email you sign in with, and we'll send you a link to choose a new password. +

+
+ + setEmail(e.target.value)} + placeholder="you@company.com" + /> + + {send.error?.message} + +
+ + Back to sign in + + + )} +
+ ); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx b/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx index ffee7852..f07d836d 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type FormEvent } from "react"; -import { useLocation, useNavigate } from "react-router"; +import { Link, useLocation, useNavigate } from "react-router"; import { useQuery } from "@tanstack/react-query"; import { api, getAppConfig, type Session } from "../../api"; import { useSession } from "../../auth/useSession"; @@ -27,9 +27,11 @@ export function LoginPage() { const { session, signIn, signInWithMicrosoft } = useSession(); const navigate = useNavigate(); const location = useLocation(); - const from = (location.state as { from?: string } | null)?.from; + const state = location.state as { from?: string; email?: string; notice?: string } | null; + const from = state?.from; - const [email, setEmail] = useState(""); + // Arriving from a password just set with an emailed link: say so, and fill in the address. + const [email, setEmail] = useState(state?.email ?? ""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); @@ -41,6 +43,7 @@ export function LoginPage() { // offering the form would only ever produce a failed sign-in. const passwordEnabled = !appConfig.data?.disableEmailPasswordLogin; const oidcEnabled = Boolean(appConfig.data?.oidcEnabled); + const resetAvailable = Boolean(appConfig.data?.passwordResetAvailable); const oidcName = appConfig.data?.oidcDisplayName || "single sign-on"; const ssoEnabled = microsoftEnabled || oidcEnabled; @@ -92,6 +95,12 @@ export function LoginPage() {

Sign in

Welcome back to Bitween.

+ {state?.notice && ( +

+ {state.notice} +

+ )} + {passwordEnabled && (
@@ -114,6 +123,13 @@ export function LoginPage() { onChange={(e) => setPassword(e.target.value)} /> + {resetAvailable && ( +
+ + Forgot password? + +
+ )} {error} + + ); + } + + if (!token || link.isError || !link.data) { + return ( + +

This link doesn't work

+

+ It has expired or has already been used. Links work once, and only for a while. +

+

+ {appConfig.data?.passwordResetAvailable ? ( + <> + Ask for a new one with Forgot password, + or ask your administrator to invite you again. + + ) : ( + "Ask your administrator for a new one." + )} +

+ + Back to sign in + +
+ ); + } + + const invite = link.data.purpose === "Invite"; + + return ( + +

+ {invite ? `Welcome, ${link.data.name}` : "Choose a new password"} +

+

+ {invite + ? "Choose a password to finish setting up your account." + : "Once it's set, you're signed out everywhere else."} +

+

+ For {link.data.email} +

+ + + + setPassword(e.target.value)} + /> + + + setConfirm(e.target.value)} + /> + + {error || save.error?.message} + + +
+ ); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/auth/__tests__/PasswordLinks.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/auth/__tests__/PasswordLinks.test.tsx new file mode 100644 index 00000000..5f63928e --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/auth/__tests__/PasswordLinks.test.tsx @@ -0,0 +1,128 @@ +import { screen } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +/** + * The pages invite and reset emails lead to, and the one that asks for a reset. All of them are + * used signed out. That the links work, once, against a real mail server is AccountEmailTests. + */ +const invalidLink = () => + HttpResponse.json({ LINK_INVALID: ["This link has expired or has already been used. Ask for a new one."] }, { status: 400 }); + +describe("forgot password", () => { + it("is offered on the sign-in page only when this Bitween can email a reset", async () => { + renderApp("/login", { as: null, config: { passwordResetAvailable: true } }); + expect(await screen.findByRole("link", { name: "Forgot password?" })).toBeVisible(); + }); + + it("is not offered without a mail server", async () => { + renderApp("/login", { as: null }); + expect(await screen.findByRole("button", { name: "Sign in" })).toBeVisible(); + expect(screen.queryByRole("link", { name: "Forgot password?" })).not.toBeInTheDocument(); + }); + + it("asks for a link and says to check the inbox, whatever the address", async () => { + let sent: unknown; + const { user } = renderApp("/forgot-password", { + as: null, + handlers: [ + http.post(apiPath("/accounts/forgotPassword"), async ({ request }) => { + sent = await request.json(); + return new HttpResponse(null, { status: 204 }); + }), + ], + }); + + await user.type(await screen.findByRole("textbox", { name: "Email" }), "jane@acme.test"); + await user.click(screen.getByRole("button", { name: "Send reset link" })); + + expect(await screen.findByRole("heading", { name: "Check your email" })).toBeVisible(); + // "If it belongs to a member": the page can't say whether it does, because the server won't. + expect(screen.getByRole("status")).toHaveTextContent("If jane@acme.test belongs to a member"); + expect(sent).toEqual({ email: "jane@acme.test" }); + }); +}); + +describe("a password link", () => { + it("greets an invited member, sets their password and sends them to sign in", async () => { + let used: unknown; + const { user, router } = renderApp("/set-password?token=t0k3n", { + as: null, + handlers: [ + http.post(apiPath("/accounts/passwordLink"), () => + HttpResponse.json({ purpose: "Invite", name: "Jane Cooper", email: "jane@acme.test", expiresOn: "2026-10-17T10:00:00Z" }), + ), + http.post(apiPath("/accounts/usePasswordLink"), async ({ request }) => { + used = await request.json(); + return HttpResponse.json({ email: "jane@acme.test" }); + }), + ], + }); + + expect(await screen.findByRole("heading", { name: "Welcome, Jane Cooper" })).toBeVisible(); + await user.type(screen.getByLabelText("New password"), "Chosen-by-me-1"); + await user.type(screen.getByLabelText("Confirm password"), "Chosen-by-me-1"); + await user.click(screen.getByRole("button", { name: "Set password" })); + + await expect.poll(() => router.state.location.pathname).toBe("/login"); + expect(used).toEqual({ token: "t0k3n", password: "Chosen-by-me-1" }); + expect(await screen.findByRole("status")).toHaveTextContent("Your password is set. Sign in with it."); + expect(screen.getByRole("textbox", { name: "Email" })).toHaveValue("jane@acme.test"); + }); + + it("checks the two passwords match before asking the server", async () => { + let calls = 0; + const { user } = renderApp("/set-password?token=t0k3n", { + as: null, + handlers: [ + http.post(apiPath("/accounts/passwordLink"), () => + HttpResponse.json({ purpose: "PasswordReset", name: "Jane", email: "jane@acme.test", expiresOn: "2026-10-17T10:00:00Z" }), + ), + http.post(apiPath("/accounts/usePasswordLink"), () => { + calls++; + return HttpResponse.json({ email: "jane@acme.test" }); + }), + ], + }); + + expect(await screen.findByRole("heading", { name: "Choose a new password" })).toBeVisible(); + await user.type(screen.getByLabelText("New password"), "Chosen-by-me-1"); + await user.type(screen.getByLabelText("Confirm password"), "Chosen-by-me-2"); + await user.click(screen.getByRole("button", { name: "Set password" })); + + expect(await screen.findByText("The passwords don't match.")).toBeVisible(); + expect(calls).toBe(0); + }); + + it("that can't be checked right now says so, and offers to try again rather than a new link", async () => { + let calls = 0; + const { user } = renderApp("/set-password?token=t0k3n", { + as: null, + handlers: [ + http.post(apiPath("/accounts/passwordLink"), () => { + calls++; + return calls === 1 + ? HttpResponse.json({ type: "error", title: "Service unavailable", status: 503 }, { status: 503 }) + : HttpResponse.json({ purpose: "Invite", name: "Jane", email: "jane@acme.test", expiresOn: "2026-10-17T10:00:00Z" }); + }), + ], + }); + + expect(await screen.findByRole("heading", { name: "Couldn't check your link" })).toBeVisible(); + expect(screen.queryByText(/expired or has already been used/)).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByRole("heading", { name: "Welcome, Jane" })).toBeVisible(); + }); + + it("that was used or has expired says so, and where to get a new one", async () => { + renderApp("/set-password?token=spent", { + as: null, + config: { passwordResetAvailable: true }, + handlers: [http.post(apiPath("/accounts/passwordLink"), invalidLink)], + }); + + expect(await screen.findByRole("heading", { name: "This link doesn't work" })).toBeVisible(); + expect(screen.getByRole("link", { name: "Forgot password" })).toHaveAttribute("href", "/forgot-password"); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/AddMemberDialog.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/AddMemberDialog.tsx index 4fc915a2..101ae726 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/AddMemberDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/AddMemberDialog.tsx @@ -1,28 +1,85 @@ import { useState, type FormEvent } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, ApiRequestError } from "../../api"; +import { api, ApiRequestError, getAppConfig, type InviteResult } from "../../api"; import { Button, FormError } from "../../components/ui/basics"; +import { CopyField } from "../../components/ui/CopyField"; import { Checkbox, Field, PasswordInput, TextInput } from "../../components/ui/forms"; import { Dialog } from "../../components/ui/overlays"; +import { formatDateTime } from "../../lib/dates"; import { PASSWORD_HINT, validatePassword } from "../../lib/passwordPolicy"; import { keys } from "../../api/queryKeys"; +type Mode = "invite" | "password"; + +function Option({ + checked, + onChange, + title, + children, +}: { + checked: boolean; + onChange: () => void; + title: string; + children: string; +}) { + return ( + + ); +} + export function AddMemberDialog({ onClose }: { onClose: () => void }) { const queryClient = useQueryClient(); const roles = useQuery({ queryKey: keys.roles.list, queryFn: () => api.listRoles() }); + const appConfig = useQuery({ queryKey: keys.appConfig, queryFn: getAppConfig }); + const mailOn = Boolean(appConfig.data?.mailServerConfigured); + // Microsoft or single sign-on only: nobody has a password, so there is none to choose or set. + const passwordsOff = Boolean(appConfig.data?.disableEmailPasswordLogin); const [displayName, setDisplayName] = useState(""); const [email, setEmail] = useState(""); + const [mode, setMode] = useState("invite"); const [password, setPassword] = useState(""); + const [emailThem, setEmailThem] = useState(true); const [roleIds, setRoleIds] = useState([]); const [passwordError, setPasswordError] = useState(""); + // An invite that wasn't emailed, for whoever added the member to pass on. + const [handOver, setHandOver] = useState<{ name: string; result: InviteResult } | null>(null); const create = useMutation({ - mutationFn: () => api.createUser({ displayName, email, password, roleIds }), - onSuccess: () => { + mutationFn: async () => { + const inviting = !passwordsOff && mode === "invite"; + const user = await api.createUser({ + displayName, + email, + roleIds, + ...(inviting ? { invite: true } : passwordsOff ? {} : { password }), + }); + if (!(inviting || (passwordsOff && mailOn && emailThem))) return { user, result: null }; + try { + return { user, result: await api.inviteUser(user.id) }; + } catch (e) { + // They're added either way; the invite can be sent again from their page. + const message = e instanceof Error ? e.message : "The invite couldn't be made."; + return { user, result: { emailed: false, emailError: message, expiresOn: null, link: null } }; + } + }, + onSuccess: ({ user, result }) => { void queryClient.invalidateQueries({ queryKey: keys.users.all }); void queryClient.invalidateQueries({ queryKey: keys.roles.all }); - onClose(); + if (result && !result.emailed) setHandOver({ name: user.displayName, result }); + else onClose(); }, }); @@ -31,16 +88,53 @@ export function AddMemberDialog({ onClose }: { onClose: () => void }) { const submit = (e: FormEvent) => { e.preventDefault(); - // The server rejects a weak password too; checking here saves the round trip. - const problems = validatePassword(password); - if (problems.length > 0) { - setPasswordError(problems.join(" ")); - return; + if (!passwordsOff && mode === "password") { + // The server rejects a weak password too; checking here saves the round trip. + const problems = validatePassword(password); + if (problems.length > 0) { + setPasswordError(problems.join(" ")); + return; + } } setPasswordError(""); create.mutate(); }; + if (handOver) { + const { name, result } = handOver; + return ( + +
+ {result.link ? ( + <> + {result.emailError && ( + The invite email couldn't be sent: {result.emailError} + )} +

+ Send them this link to choose their password: +

+ +

+ It works once{result.expiresOn ? `, until ${formatDateTime(result.expiresOn)}` : ""}. Bitween + keeps no copy, so it can't show it again - you can make a new one from their page. +

+ + ) : ( + + They're added, but the invite failed: {result.emailError}. Open them in the list to send it + again. + + )} +
+ +
+
+
+ ); + } + return (
@@ -66,20 +160,41 @@ export function AddMemberDialog({ onClose }: { onClose: () => void }) { /> - - setPassword(e.target.value)} - placeholder="At least 8 characters" - /> - + {passwordsOff ? ( + mailOn && ( + setEmailThem(e.target.checked)} + /> + ) + ) : ( +
+ How they get in + + +
+ )} + + {!passwordsOff && mode === "password" && ( + + setPassword(e.target.value)} + placeholder="At least 8 characters" + /> + + )}
Roles @@ -103,8 +218,9 @@ export function AddMemberDialog({ onClose }: { onClose: () => void }) {
-
diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx index 5c3e123b..969e4b29 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx @@ -1,8 +1,8 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { Link } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, KeyRound, LockOpen, Trash2, UserRoundCheck, UserRoundX, X } from "lucide-react"; -import { api } from "../../api"; +import { Check, KeyRound, LockOpen, Send, Trash2, UserRoundCheck, UserRoundX, X } from "lucide-react"; +import { api, getAppConfig, type InviteResult, type User } from "../../api"; import { Can } from "../../auth/guards"; import { useSession } from "../../auth/useSession"; import { HistoryList } from "../../components/config/HistoryCard"; @@ -13,10 +13,19 @@ import { isNotFound } from "../../components/ui/isNotFound"; import { Checkbox, PasswordInput } from "../../components/ui/forms"; import { ConfirmDialog } from "../../components/ui/overlays"; import { useModalFocus } from "../../components/ui/useModalFocus"; -import { formatDate, timeAgo, timeUntil } from "../../lib/dates"; -import { statusBadge } from "./memberStatus"; +import { formatDate, formatDateTime, timeAgo, timeUntil } from "../../lib/dates"; +import { inviteActive, statusBadge } from "./memberStatus"; import { keys } from "../../api/queryKeys"; +/** Where a member's invite stands, in a sentence. */ +function inviteLine(u: User) { + if (!u.invite) return "They have no password yet, and no invite has been made."; + if (!inviteActive(u)) return `Their invite expired ${timeAgo(u.invite.expiresOn)}.`; + if (u.invite.emailedOn) + return `Invite emailed ${timeAgo(u.invite.emailedOn)}. The link works until ${formatDateTime(u.invite.expiresOn)}.`; + return `An invite link was made to send by hand. It works until ${formatDateTime(u.invite.expiresOn)}.`; +} + function Section({ title, children }: { title: string; children: ReactNode }) { return (
@@ -32,7 +41,13 @@ export function MemberDrawer({ userId, onClose }: { userId: string; onClose: () const user = useQuery({ queryKey: keys.users.detail(userId), queryFn: () => api.getUser(userId), retry: false }); const roles = useQuery({ queryKey: keys.roles.list, queryFn: () => api.listRoles() }); + const appConfig = useQuery({ queryKey: keys.appConfig, queryFn: getAppConfig }); + const mailOn = Boolean(appConfig.data?.mailServerConfigured); + const passwordsOff = Boolean(appConfig.data?.disableEmailPasswordLogin); + const resetAvailable = Boolean(appConfig.data?.passwordResetAvailable); const [draftRoleIds, setDraftRoleIds] = useState(null); + // The invite just made: a link to pass on is shown once, as a password just set is. + const [issuedInvite, setIssuedInvite] = useState(null); const [confirming, setConfirming] = useState<"remove" | null>(null); const [newPassword, setNewPassword] = useState(""); // The password that was just set, kept on screen deliberately. Bitween sends no @@ -70,6 +85,14 @@ export function MemberDrawer({ userId, onClose }: { userId: string; onClose: () onSuccess: invalidate, }); + const invite = useMutation({ + mutationFn: () => api.inviteUser(userId), + onSuccess: (result) => { + setIssuedInvite(result); + invalidate(); + }, + }); + const setPassword = useMutation({ mutationFn: (password: string) => api.setUserPassword(userId, password), onSuccess: (_result, password) => { @@ -109,10 +132,47 @@ export function MemberDrawer({ userId, onClose }: { userId: string; onClose: () {isSelf && You}

{u.email}

-
{statusBadge(u.status, u.lockedUntil)}
+
{statusBadge(u, passwordsOff)}
+ {!u.hasPassword && !passwordsOff && !isSelf && can("users.create") && ( +
+
+

{inviteLine(u)}

+ {u.invite?.emailError && !issuedInvite && ( + The invite email couldn't be sent: {u.invite.emailError} + )} + {issuedInvite?.emailed && ( +

+ + Sent to {u.email} +

+ )} + {issuedInvite?.link && ( +
+ {issuedInvite.emailError && ( + The email couldn't be sent: {issuedInvite.emailError} + )} + +

+ Send it to them yourself. It works once, and Bitween keeps no copy, so it won't be + shown again once you close this. +

+
+ )} + +

+ A new invite stops the earlier link working. +

+ {invite.error?.message} +
+
+ )} +
{editable ? (
@@ -176,8 +236,7 @@ export function MemberDrawer({ userId, onClose }: { userId: string; onClose: ()

- Bitween sends no email, so copy it and pass it on yourself - it won't be shown - again once you dismiss this. + Copy it and pass it on yourself - it won't be shown again once you dismiss this.

{u.lockedUntil ? (
@@ -229,7 +288,9 @@ export function MemberDrawer({ userId, onClose }: { userId: string; onClose: ()

- There's no reset email, so set it here and pass it on yourself. + {resetAvailable + ? "Pass it on yourself. They can also reset it themselves with Forgot password on the sign-in page." + : "There's no reset email, so set it here and pass it on yourself."}

{setPassword.error?.message} diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/MembersPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/MembersPage.tsx index cae3fce6..8c3a8f94 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/MembersPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/MembersPage.tsx @@ -1,9 +1,10 @@ import { useMemo } from "react"; -import { useNavigate, useParams, useSearchParams } from "react-router"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router"; import { useQuery } from "@tanstack/react-query"; import { UserPlus, UsersRound } from "lucide-react"; -import { api, type User } from "../../api"; +import { api, getAppConfig, type User } from "../../api"; import { Can } from "../../auth/guards"; +import { useSession } from "../../auth/useSession"; import { PageHeader } from "../../components/layout/PageHeader"; import { Avatar } from "../../components/ui/Avatar"; import { clickableRow, clickableRowClass } from "../../components/ui/clickableRow"; @@ -36,6 +37,9 @@ export function MembersPage() { const users = useQuery({ queryKey: keys.users.list, queryFn: () => api.listUsers() }); const roles = useQuery({ queryKey: keys.roles.list, queryFn: () => api.listRoles() }); + const appConfig = useQuery({ queryKey: keys.appConfig, queryFn: getAppConfig }); + const passwordsOff = Boolean(appConfig.data?.disableEmailPasswordLogin); + const { can } = useSession(); const phone = usePhone(); // The open member's drawer has its own address, so the breadcrumb names them. usePageTitle(openMemberId ? users.data?.find((u) => u.id === openMemberId)?.displayName : null); @@ -85,7 +89,8 @@ export function MembersPage() {

To bring someone in: create or pick a role under Roles, then add - them here. You set their first password and pass it on - Bitween doesn't send mail. + them here with an invite. They choose their own password from a link: Bitween emails + it once a mail server is set up in Settings, and otherwise gives it to you to send.

), @@ -118,6 +123,20 @@ export function MembersPage() {
+ {/* Without a mail server an invite is a link the admin sends by hand, and nobody can reset a + forgotten password themselves. Said once, here, where people are added. */} + {appConfig.data && !appConfig.data.mailServerConfigured && can("users.create") && ( +

+ Bitween can't email members yet, so invites give you a link to send yourself, and there's + no Forgot password on the sign-in page.{" "} + {can("settings.view") && ( + + Set up a mail server + + )} +

+ )} + {users.isPending ? ( ) : users.isError ? ( @@ -155,7 +174,7 @@ export function MembersPage() { {user.displayName} ), - status: statusBadge(user.status, user.lockedUntil), + status: statusBadge(user, passwordsOff), technical: user.email, subtitle: ( @@ -205,7 +224,7 @@ export function MembersPage() { ))} - {statusBadge(user.status, user.lockedUntil)} + {statusBadge(user, passwordsOff)} {user.lastActiveOn ? timeAgo(user.lastActiveOn) : "-"} diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/__tests__/Invites.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/__tests__/Invites.test.tsx new file mode 100644 index 00000000..5b5f8e0c --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/team/__tests__/Invites.test.tsx @@ -0,0 +1,152 @@ +import { screen, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { ADMIN, apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +/** + * Adding a member with an invite, and following one up. Whether the invite is emailed or handed + * over depends on the instance having a mail server, which the config endpoint says. + */ +const ROLES = [{ id: 2, name: "Member", description: "Runs and configures integrations.", isSystem: true, permissions: [], memberCount: 0, createdOn: "2026-01-01T00:00:00Z" }]; + +type Invite = { expiresOn: string; emailedOn: string | null; emailError: string | null } | null; + +const account = (id: number, name: string, email: string, hasPassword = true, invite: Invite = null) => ({ + id, + name, + email, + role: "Member", + disabled: false, + lockoutEnd: null, + createdOn: "2026-01-01T00:00:00Z", + roles: [{ id: 2, name: "Member" }], + hasPassword, + invite, +}); + +const inTwoDays = () => new Date(Date.now() + 2 * 864e5).toISOString(); +const twoDaysAgo = () => new Date(Date.now() - 2 * 864e5).toISOString(); + +/** The server's members, which a create adds to, and what each write carried. */ +function team(invite: (id: number) => object, ...accounts: ReturnType[]) { + const sent: { created: Record[]; invited: string[] } = { created: [], invited: [] }; + const handlers = [ + http.get(apiPath("/accounts"), () => HttpResponse.json({ result: accounts, totalCount: accounts.length })), + http.get(apiPath("/roles"), () => HttpResponse.json({ result: ROLES, totalCount: ROLES.length })), + http.get(apiPath("/audit"), () => HttpResponse.json({ result: [], totalCount: 0 })), + http.post(apiPath("/accounts"), async ({ request }) => { + const body = (await request.json()) as Record; + sent.created.push(body); + accounts.push(account(77, String(body.name), String(body.email), !body.invite)); + return HttpResponse.json(77); + }), + http.post(apiPath("/accounts/:id/invite"), ({ params }) => { + sent.invited.push(String(params.id)); + const found = accounts.find((a) => a.id === Number(params.id))!; + found.invite = { expiresOn: inTwoDays(), emailedOn: null, emailError: null }; + return HttpResponse.json(invite(Number(params.id))); + }), + ]; + return { handlers, sent }; +} + +const self = () => account(ADMIN.id, ADMIN.name, ADMIN.email); + +async function addJane(user: ReturnType["user"]) { + const dialog = within(await screen.findByRole("dialog", { name: "Add a member" })); + await user.type(dialog.getByRole("textbox", { name: "Name" }), "Jane Cooper"); + await user.type(dialog.getByRole("textbox", { name: "Email" }), "jane@acme.test"); + return dialog; +} + +describe("inviting a member", () => { + it("emails the invite when there is a mail server, and lists them as invited", async () => { + const { handlers, sent } = team(() => ({ emailed: true, emailError: null, expiresOn: inTwoDays(), link: null }), self()); + const { user } = renderApp("/team/members?add=1", { handlers, config: { mailServerConfigured: true } }); + + const dialog = await addJane(user); + // An invite is the default: nobody has to make up a password for someone else. + expect(dialog.getByRole("radio", { name: /Send an invite/ })).toBeChecked(); + expect(dialog.queryByLabelText("Password")).not.toBeInTheDocument(); + await user.click(dialog.getByRole("button", { name: "Add and email invite" })); + + await expect.poll(() => screen.queryByRole("dialog", { name: "Add a member" })).toBeNull(); + expect(sent.created[0]).toMatchObject({ name: "Jane Cooper", email: "jane@acme.test", invite: true }); + expect(sent.created[0]).not.toHaveProperty("password"); + expect(sent.invited).toEqual(["77"]); + expect(await screen.findByRole("row", { name: /jane@acme.test/ })).toHaveTextContent("Invited"); + }); + + it("hands the link over once when there is no mail server to send it", async () => { + const link = "https://bitween.acme.test/set-password?token=t0k3n"; + const { handlers } = team(() => ({ emailed: false, emailError: null, expiresOn: inTwoDays(), link }), self()); + const { user } = renderApp("/team/members?add=1", { handlers }); + + expect(await screen.findByText(/Bitween can't email members yet/)).toBeVisible(); + const dialog = await addJane(user); + await user.click(dialog.getByRole("button", { name: "Add and make invite link" })); + + const handOver = within(await screen.findByRole("dialog", { name: "Jane Cooper is added" })); + expect(handOver.getByText(link)).toBeVisible(); + expect(handOver.getByText(/Bitween keeps no copy/)).toBeVisible(); + }); + + it("can still set a password instead", async () => { + const { handlers, sent } = team(() => ({}), self()); + const { user } = renderApp("/team/members?add=1", { handlers, config: { mailServerConfigured: true } }); + + const dialog = await addJane(user); + await user.click(dialog.getByRole("radio", { name: /Set a password now/ })); + await user.type(dialog.getByLabelText("Password"), "Set-for-Jane-1"); + await user.click(dialog.getByRole("button", { name: "Add member" })); + + await expect.poll(() => screen.queryByRole("dialog", { name: "Add a member" })).toBeNull(); + expect(sent.created[0]).toMatchObject({ password: "Set-for-Jane-1" }); + expect(sent.created[0]).not.toHaveProperty("invite"); + expect(sent.invited).toEqual([]); + }); + + it("asks for no password when everyone signs in with Microsoft", async () => { + const { handlers, sent } = team(() => ({ emailed: true, emailError: null, expiresOn: null, link: null }), self()); + const { user } = renderApp("/team/members?add=1", { + handlers, + config: { mailServerConfigured: true, disableEmailPasswordLogin: true }, + }); + + const dialog = await addJane(user); + expect(dialog.queryByRole("radio")).not.toBeInTheDocument(); + expect(dialog.getByRole("checkbox", { name: /Email them that they've been added/ })).toBeChecked(); + await user.click(dialog.getByRole("button", { name: "Add member" })); + + await expect.poll(() => sent.invited).toEqual(["77"]); + expect(sent.created[0]).not.toHaveProperty("password"); + }); +}); + +describe("a member's invite", () => { + it("that expired is shown as such, and can be sent again", async () => { + const late = account(50, "Late Joiner", "late@acme.test", false, { expiresOn: twoDaysAgo(), emailedOn: twoDaysAgo(), emailError: null }); + const { handlers, sent } = team(() => ({ emailed: true, emailError: null, expiresOn: inTwoDays(), link: null }), self(), late); + const { user } = renderApp("/team/members", { handlers, config: { mailServerConfigured: true } }); + + const row = await screen.findByRole("row", { name: /late@acme.test/ }); + expect(row).toHaveTextContent("Invite expired"); + await user.click(row); + + const drawer = within(await screen.findByRole("dialog", { name: "Member details" })); + expect(drawer.getByText(/Their invite expired/)).toBeVisible(); + await user.click(drawer.getByRole("button", { name: "Resend invite" })); + + expect(await drawer.findByText("Sent to late@acme.test")).toBeVisible(); + expect(sent.invited).toEqual(["50"]); + }); + + it("whose email failed says why", async () => { + const stuck = account(51, "Stuck", "stuck@acme.test", false, { expiresOn: inTwoDays(), emailedOn: null, emailError: "Connection refused" }); + const { handlers } = team(() => ({}), self(), stuck); + renderApp("/team/members/51", { handlers, config: { mailServerConfigured: true } }); + + const drawer = within(await screen.findByRole("dialog", { name: "Member details" })); + expect(await drawer.findByText(/The invite email couldn't be sent: Connection refused/)).toBeVisible(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/memberStatus.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/memberStatus.tsx index c18eb69d..f05af0ad 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/memberStatus.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/memberStatus.tsx @@ -1,10 +1,33 @@ -import { type UserStatus } from "../../api"; +import { type User } from "../../api"; import { Badge } from "../../components/ui/basics"; +import { formatDate } from "../../lib/dates"; -export function statusBadge(status: UserStatus, lockedUntil?: string | null) { - if (status !== "active") return Disabled; +/** Whether an invite still works. */ +export const inviteActive = (user: Pick) => + Boolean(user.invite && new Date(user.invite.expiresOn) > new Date()); + +/** + * @param passwordsOff The instance signs in with Microsoft or single sign-on only, where no + * member has a password and that is nothing to report. + */ +export function statusBadge(user: User, passwordsOff = false) { + if (user.status !== "active") return Disabled; // A lockout expires on its own, so it is reported with its own tone rather than // as a failure - and never as "Active", which would say the opposite of the truth. - if (lockedUntil) return Locked; + if (user.lockedUntil) return Locked; + // Invited and yet to choose a password: they can't sign in, which "Active" would hide. + if (!user.hasPassword && !passwordsOff) { + if (inviteActive(user)) + return ( + + Invited + + ); + return ( + + {user.invite ? "Invite expired" : "Not invited"} + + ); + } return Active; } diff --git a/SW.Bitween.Web/ClientApp/src/router.tsx b/SW.Bitween.Web/ClientApp/src/router.tsx index b1d5ff8c..f37cdd24 100644 --- a/SW.Bitween.Web/ClientApp/src/router.tsx +++ b/SW.Bitween.Web/ClientApp/src/router.tsx @@ -6,6 +6,8 @@ import { LoginPage } from "./pages/auth/Login"; import { ChangePasswordPage } from "./pages/auth/ChangePassword"; import { CliLoginPage } from "./pages/auth/CliLogin"; import { OidcCallbackPage } from "./pages/auth/OidcCallback"; +import { ForgotPasswordPage } from "./pages/auth/ForgotPassword"; +import { SetPasswordPage } from "./pages/auth/SetPassword"; import { NotFoundPage } from "./pages/NotFoundPage"; import { RouteError } from "./pages/RouteError"; import { Suspense, type ReactNode } from "react"; @@ -171,6 +173,9 @@ export const routes: RouteObject[] = [ { path: "/login", element: , errorElement: }, // Where an OpenID Connect provider sends the browser back; it signs in, so it needs no session. { path: "/oidc-callback", element: , errorElement: }, + // Where invite and reset emails lead, and where a reset is asked for: no session, by definition. + { path: "/forgot-password", element: , errorElement: }, + { path: "/set-password", element: , errorElement: }, { element: , // A page that crashes shows this, with a way back, rather than react-router's developer screen. diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index ad339d00..40e64221 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -130,6 +130,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); @@ -581,7 +582,8 @@ public void ConfigureServices(IServiceCollection services) options.CircuitBreakerWindowSeconds, options.CircuitBreakerOpenSeconds, options.CircuitBreakerPerEndpoint); }, - sp => sp.GetRequiredService().MailServer); + sp => sp.GetRequiredService().MailServer, + sp => sp.GetRequiredService().PrimaryColor); // services.AddScoped(); // services.AddScoped(); @@ -832,7 +834,11 @@ private static bool IsSignInPath(PathString path) return value.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) && (value.EndsWith("/login", StringComparison.OrdinalIgnoreCase) || value.EndsWith("/accounts/servicetoken", StringComparison.OrdinalIgnoreCase) - || value.EndsWith("/accounts/oidcstart", StringComparison.OrdinalIgnoreCase)); + || value.EndsWith("/accounts/oidcstart", StringComparison.OrdinalIgnoreCase) + // The password links: asking for one, reading one, and using one, all open to anyone. + || value.EndsWith("/accounts/forgotpassword", StringComparison.OrdinalIgnoreCase) + || value.EndsWith("/accounts/passwordlink", StringComparison.OrdinalIgnoreCase) + || value.EndsWith("/accounts/usepasswordlink", StringComparison.OrdinalIgnoreCase)); } /// diff --git a/docs/admin-ui.md b/docs/admin-ui.md index fb58a635..6582c7fc 100644 --- a/docs/admin-ui.md +++ b/docs/admin-ui.md @@ -6,7 +6,7 @@ Every page and action is gated by permissions. Anything a member cannot use is h ## Signing in -The sign-in page offers email and password, Microsoft, an [OpenID Connect provider](security.md#openid-connect), or any of them together, depending on settings. After sign-in, members land on the first page their permissions allow. Sessions end after 30 minutes with no activity. +The sign-in page offers email and password, Microsoft, an [OpenID Connect provider](security.md#openid-connect), or any of them together, depending on settings. With a mail server it also offers **Forgot password?**, and invite and reset emails lead to a page where the member chooses their password. After sign-in, members land on the first page their permissions allow. Sessions end after 30 minutes with no activity. ## Operate @@ -78,7 +78,7 @@ their next save. | Page | What it does | |---|---| -| **Team** | Members: add, assign roles, set a password, unlock, disable and remove. Roles: a permission matrix with a live preview of the navigation the role would see. | +| **Team** | Members: add with an invite (emailed when a mail server is set, otherwise a link to send) or a password, resend an expired invite, assign roles, set a password, unlock, disable and remove. Invited members show as *Invited* until they choose a password. Roles: a permission matrix with a live preview of the navigation the role would see. | | **Settings** | Runtime settings by section, including branding with an instant preview. Values owned by the environment are shown read-only. Documents & storage shows the bucket's deletion rules and what the retention settings do, and asks before saving a change to them. **Mail server** is the server Email channels send through. **Notification channels** lists channels and who uses each, and links to any legacy notifiers. Each channel opens on a page of its own: Email, Microsoft Teams and Slack first, any other adapter one click away, a test message, and its recent sends. A role with the notification permissions but not Settings, such as the built-in Member and Viewer, finds only the Notification channels section here. | | **Audit trail** | Every configuration change with its old and new values. | diff --git a/docs/api-reference.md b/docs/api-reference.md index 5cf30913..e603b7b8 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -53,6 +53,9 @@ See [Entry points](entry-points.md) for status codes. | `POST /api/accounts/servicetoken` | none | A service account signs in: `{ token }`, one of its `bwt_` tokens. Returns `{ jwt, expiresIn, id, name }`, with no refresh token: trade the token again when the session ends. Shares the sign-in rate limit; unknown tokens count toward the address's lockout. | | `GET /api/accounts/profile` | signed in | The current member, roles and permissions | | `POST /api/accounts/changePassword` | signed in | Change your own password | +| `POST /api/accounts/forgotPassword` | none | `{ email }`. Emails a reset link when the address is a member's; answers the same either way. `400 PASSWORD_RESET_OFF` without a mail server or `Bitween:PublicUrl`. Shares the sign-in rate limit. | +| `POST /api/accounts/passwordLink` | none | `{ token }`, from an invite or reset link. Returns `{ purpose, name, email, expiresOn }`, and changes nothing. `400 LINK_INVALID` once used or expired. | +| `POST /api/accounts/usePasswordLink` | none | `{ token, password }`. Sets the password, spends the link and ends the member's sessions. Returns `{ email }`. | | `GET /api/settings/config` | none | Sign-in options and theme | | `GET /api/settings/myversion` | signed in | API version | | `GET /api/permissions` | signed in | The permission catalogue | @@ -307,8 +310,9 @@ See [Data sources](data-sources.md) and [Databases](databases.md). | Method and path | Permission | |---|---| -| `GET /api/accounts` | `users.view`. Each member carries `lastSignInOn` and `microsoftLinked`. | -| `POST /api/accounts` | `users.create` | +| `GET /api/accounts` | `users.view`. Each member carries `lastSignInOn`, `microsoftLinked`, `hasPassword`, and `invite` (`expiresOn`, `emailedOn`, `emailError`) while they are yet to choose a password. | +| `POST /api/accounts` | `users.create`. `{ name, email, password, roleIds }`, or `invite: true` instead of `password`. | +| `POST /api/accounts/{id}/invite` | `users.create`, and every permission the member holds. For a member with no password: returns `{ emailed, emailError, expiresOn, link }`, with `link` only when it wasn't emailed. | | `POST /api/accounts/{id}` | `users.edit`, or yourself for the display name | | `POST /api/accounts/{id}/setRoles` | `users.edit` | | `POST /api/accounts/{id}/setDisabled` | `users.edit` | diff --git a/docs/configuration.md b/docs/configuration.md index a61ca231..31bf41b1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -69,7 +69,7 @@ applications the same `Token` values. Until they match, `Bus:OnInvalidUserToken` | `AzureManagedIdentityClientId` | | Client id of a user-assigned identity. Falls back to `AZURE_CLIENT_ID` or `MSI_CLIENT_ID`. | | `StorageProvider` | `S3` | `S3`, `AS` for Azure Blob, `OC` for Oracle Cloud, or `Local`. `Local` is refused outside Development. | | `DocumentPrefix` | `temp30/Bitweendocs` | First value of the runtime setting: the storage key prefix new exchange files are written under. | -| `PublicUrl` | | The address partners reach Bitween on, such as `https://bitween.example.com`. The chart sets it from its host name. Exchange file links are built on it, including aggregation roll-ups. Without it, roll-ups link through the instance's own address, which only adapters running in Bitween can open. | +| `PublicUrl` | | The address partners reach Bitween on, such as `https://bitween.example.com`. The chart sets it from its host name. Exchange file links are built on it, including aggregation roll-ups. Without it, roll-ups link through the instance's own address, which only adapters running in Bitween can open. Password reset emails need it too. | | `ExchangeRetentionDays`, `ArchiveExchanges`, `ExchangeRetentionCron` | `0`, `true`, `0 0 4 * * ?` | First values of the retention settings. | | `Version` | | Leave it unset. A Bitween's release, as `10.0.93`, is stamped on its build by CI, so every image knows it wherever it runs: **Settings, About** shows it, with the commit as the build (`10.0.93+`), and an adapter's or connector's "needs Bitween X or later" is checked against it. A build CI didn't stamp, as a local one, is `10.0.0`. Set this only to override the stamped version. | | `AdapterPath` | `adapters` | Storage key prefix for custom adapter packages. | @@ -191,7 +191,7 @@ The retry poll and retention crons are validated before they're saved, and savin ### Mail server -The **Mail server** section is the one SMTP server every Email notification channel sends through, so a channel only names its recipients. It is read on each send. Until a host and a From address are set, Email channels can't send, and their page says so. The connection follows the [SMTP adapter's](adapters.md#nativesmtphandler) rules: a password is only sent over TLS, `CertificateThumbprint` trusts exactly that certificate, and a server that doesn't answer within 30 seconds fails the send. The password is a secret setting, so storing it needs `Bitween:SettingsEncryptionKey`. Without one, set it through configuration (`Bitween__MailServer__Password`). +The **Mail server** section is the one SMTP server every Email notification channel sends through, so a channel only names its recipients. Member invites, password resets and account notices go through it too; see [Invites and password resets](security.md#invites-and-password-resets). It is read on each send. Until a host and a From address are set, Email channels can't send, and their page says so. The connection follows the [SMTP adapter's](adapters.md#nativesmtphandler) rules: a password is only sent over TLS, `CertificateThumbprint` trusts exactly that certificate, and a server that doesn't answer within 30 seconds fails the send. The password is a secret setting, so storing it needs `Bitween:SettingsEncryptionKey`. Without one, set it through configuration (`Bitween__MailServer__Password`). The page also has a **Notification channels** section. Channels are records rather than settings: they have their own table, each opens on a page of its own, and the `notifiers` permissions cover them rather than `settings`. See [Notification channels](retries-and-alerts.md#notification-channels). diff --git a/docs/retries-and-alerts.md b/docs/retries-and-alerts.md index 4255841e..327be913 100644 --- a/docs/retries-and-alerts.md +++ b/docs/retries-and-alerts.md @@ -245,7 +245,7 @@ A picker's **New channel** opens the new channel page. Saving the channel brings | Slack | `WebhookUrl`, an incoming webhook address | A Block Kit message to the Slack channel | | Another adapter | Any handler adapter, built in or custom, and its properties | The notification as JSON, described with each kind of notification | -Email, Teams and Slack send a message Bitween writes: what happened, the facts that place it, the error without its stack trace, and a link to the exchange, which has the whole of it. The link is built on the instance's public address, `Bitween:PublicUrl`. Without one, only a test message sent from the page has a link. Their adapters, `NativeEmailMessageHandler`, `NativeTeamsMessageHandler` and `NativeSlackMessageHandler`, are only offered as channel types, never in an adapter picker. +Email, Teams and Slack send a message Bitween writes: what happened, the facts that place it, the error without its stack trace, and a link to the exchange, which has the whole of it. The link is built on the instance's public address, `Bitween:PublicUrl`. Without one, only a test message sent from the page has a link. Their adapters, `NativeEmailMessageHandler`, `NativeTeamsMessageHandler` and `NativeSlackMessageHandler`, are only offered as channel types, never in an adapter picker. Emails, like the member invites and password resets that share their layout, carry the Bitween logo inside the email, so it shows without loading anything, and are drawn in the instance's brand colour (**Settings, Brand & theme**, primary colour). Microsoft retired the Office 365 *Incoming Webhook* connector for Teams in 2026. In Teams, add the **Post to a channel when a webhook request is received** workflow to the channel and use the address it gives you. diff --git a/docs/security.md b/docs/security.md index b337a71d..6077c872 100644 --- a/docs/security.md +++ b/docs/security.md @@ -13,10 +13,20 @@ Bitween supports four ways to sign in. - Changing your own password checks the old one, and that check is limited as sign-in is: five wrong ones from one address lock the change out for 15 minutes, so a session left open can't be used to guess the password. - Passwords need at least 8 characters, including an upper-case letter, a lower-case letter, a digit and a symbol. When an administrator sets someone else's password, only the 8-character minimum applies. - Passwords are hashed with PBKDF2-HMAC-SHA256, 210,000 iterations and a random salt, and compared in constant time. Hashes stored before September 2026 use PBKDF2-SHA1 with 10,000 iterations. They still verify, and each is replaced with the current hash the next time that person signs in with their password. -- Bitween sends no email, so there is no self-service password reset. +- With a mail server and a public address, the sign-in page offers **Forgot password?**: see [Invites and password resets](#invites-and-password-resets). Without them, an administrator sets a new password instead. Turning on the **Microsoft sign-in only** setting disables this method, for Microsoft and OpenID Connect sign-in alike. New accounts are then created without a password. +### Invites and password resets + +Account emails go to one person, through the mail server set in **Settings, Mail server** (the one Email channels use), never through a notification channel. + +- **Invites.** A member can be added with an invite instead of a password (`POST /api/accounts` with `invite: true`, then `POST /api/accounts/{id}/invite`). They choose their own password from a link that works once, for 48 hours. With a mail server the link is emailed; without one it is handed back once to whoever invited, to pass on. Sending another invite stops the earlier link working. Inviting needs `users.create`, and, as setting a password does, every permission the member holds. On an instance with **Microsoft sign-in only** an invite is an email saying where to sign in, and needs a mail server. +- **Resets.** **Forgot password?** emails a link that works once, for one hour. It needs a mail server and `Bitween:PublicUrl`: a link built on the address a request came in on could be pointed at another site with a forged `Host` header. The answer is the same whether the address has an account or not, and the email is sent after the answer, so neither says who has one. A member gets at most one reset email a minute. +- **Using a link** sets the password, clears a lockout, and ends every session the member had. Every other link they had stops working. +- **Notices.** A member is emailed when their password is changed (by them, by an administrator, or with a reset link) and when their account is locked after 20 wrong passwords. +- Only a SHA-256 hash of each link is stored, so the database can't be used to sign in. `POST /api/accounts/forgotPassword`, `passwordLink` and `usePasswordLink` need no session, and share the sign-in rate limit. + ### Microsoft The sign-in page offers Microsoft once the Azure AD client id, tenant id and redirect URI settings are all set.